Course program← Программирование на языке C++
1Введение в язык C++
2Как выполняются программы на C++
3Структуры и классы
4Объектно-ориентированное программирование
5Дополнительные возможности C++
6Шаблоны
7Методические материалы

Собираем класс массива

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, и написано оно лучше. Смысл этого упражнения не в том, чтобы получить свой контейнер, а в том, чтобы понимать, что происходит внутри чужого, – и уметь читать код, где эти вещи сделаны руками.