This lesson is not translated yet – the Russian original is below.
Все части модуля собираются в одном примере: конструкторы, деструктор, константность, правило трёх. Класс массива – канонический учебный пример именно потому, что в нём ничего лишнего, а задеты все механизмы сразу.
Что должен уметь класс
Голый int* с отдельной переменной длины работает, но требует дисциплины: не забыть
delete[], не потерять размер, не скопировать по ошибке. Класс превращает эту дисциплину в
свойство типа.
class Array {
public:
explicit Array(size_t size, int value = 0);
Array(const Array& other);
~Array();
Array& operator=(const Array& other);
size_t Size() const;
int& operator[](size_t i);
int operator[](size_t i) const;
private:
size_t size_;
int* data_;
};
Инвариант: data_ указывает на блок ровно из size_ элементов, выделенный через new[], и
владеет им единолично. Каждый метод обязан этот инвариант сохранять – на нём держатся все
остальные.
Конструктор и деструктор
Array::Array(size_t size, int value)
: size_(size)
, data_(new int[size])
{
for (size_t i = 0; i < size_; ++i) {
data_[i] = value;
}
}
Array::~Array() {
delete[] data_;
}
explicit у конструктора с одним обязательным параметром – не формальность. Без него компилятор
считал бы допустимым неявное преобразование int в Array:
void Process(const Array& a);
Process(10); // без explicit: молча создаст массив на 10 элементов
Правило: конструктор, который можно вызвать с одним аргументом, помечайте explicit, если только преобразование не является намеренной частью интерфейса. У std::string конструктор от const char* сознательно неявный – передавать литерал в функцию, ждущую строку, удобно. У std::vector конструктор от размера объявлен explicit по той же логике, что и здесь.
Копирование
Владеющий указатель означает, что почленная копия неверна:
Array::Array(const Array& other)
: size_(other.size_)
, data_(new int[other.size_])
{
for (size_t i = 0; i < size_; ++i) {
data_[i] = other.data_[i];
}
}
Array& Array::operator=(const Array& other) {
if (this != &other) {
delete[] data_;
size_ = other.size_;
data_ = new int[size_];
for (size_t i = 0; i < size_; ++i) {
data_[i] = other.data_[i];
}
}
return *this;
}
Это и есть правило трёх в сборе: деструктор, конструктор копирования, оператор присваивания – три метода, существующие по одной причине.
Доступ к элементам
size_t Array::Size() const { return size_; }
int& Array::operator[](size_t i) { return data_[i]; }
int Array::operator[](size_t i) const { return data_[i]; }
Две версии нужны обе. Неконстантная возвращает ссылку – через неё пишут. Константная возвращает копию и доступна у константного объекта, где первая была бы запрещена.
Array a(3, 0);
a[0] = 42; // неконстантная версия
const Array& c = a;
int value = c[0]; // константная
// c[0] = 1; // ошибка компиляции, и это правильно
Проверять границы или нет – решение дизайна. Стандартная библиотека предлагает обе политики:
operator[] быстрый и без проверки, at() проверяет и бросает исключение. Смешивать их в одном
методе не стоит: цена проверки должна быть видна в имени.
Возвращать int по значению из константной версии допустимо только потому, что int дёшев. Для тяжёлого типа обе версии возвращают ссылку, отличаясь константностью: T& operator[](size_t) и const T& operator[](size_t) const. Именно так устроены контейнеры стандартной библиотеки.
Что получилось
void Use() {
Array a(5, 1);
Array b = a; // честная копия: свой буфер
b[0] = 99; // a не изменился
a = b; // старый буфер a освобождён, содержимое скопировано
} // оба деструктора освободили каждый своё
Ни одного new и delete в коде пользователя. Утечка невозможна, двойное освобождение
невозможно, копия – настоящая копия. Всё это – следствие того, что владение выражено типом, а не
соглашением между людьми.
Чего здесь не хватает
Класс намеренно упрощён, и полезно знать, где именно.
Тип элемента зафиксирован. Array умеет только int. Обобщение по типу – это шаблоны,
модуль 6, и класс Array мы там дописываем.
Нет перемещения. Array b = MakeArray(); копирует буфер, хотя источник сразу же умрёт.
Перемещающие конструктор и присваивание (C++11) забирают чужой буфер вместо копирования.
Нет изменения размера. Ни PushBack, ни Resize. Настоящий динамический массив держит
ёмкость отдельно от размера и растёт с запасом, иначе каждая вставка стоила бы полного
копирования.
Исключения не рассмотрены. Если new int[size_] в операторе присваивания бросит исключение
после delete[] data_, объект останется с висячим указателем. Идиома copy and swap решает и
это: копия создаётся до того, как тронуто состояние объекта.
Всё перечисленное уже реализовано в std::vector, и написано оно лучше. Смысл этого упражнения не в том, чтобы получить свой контейнер, а в том, чтобы понимать, что происходит внутри чужого, – и уметь читать код, где эти вещи сделаны руками.