Перейти к содержимому

Конструкторы, деструкторы, RAII

// Файл HeapInt.h
#pragma once // 1
#include <cassert>
class HeapInt
{
private:
int* heapValue; // 2
public:
// 3
inline HeapInt(int val)
// 4
: heapValue(new int{ val })
{
}
inline ~HeapInt()
{
// Заметка: delete проверяет на nullptr сам.
delete heapValue; // 5
}
inline int& asRef()
{
assert(heapValue != nullptr); // 6
return *heapValue;
}
HeapInt(const HeapInt& hello); // 7
HeapInt(HeapInt&& hello);
void operator=(const HeapInt& hello);
void operator=(HeapInt&& hello);
};
// Файл main.cpp
#include "HeapInt.h"
#include <utility>
void constructors()
{
HeapInt a{5}; // 8
HeapInt b{a}; // 9
HeapInt c{std::move(a)}; // 10
HeapInt d = b; // 11
HeapInt e = std::move(b); // 12
d = std::move(c); // 13
d = e; // 14
}
// Файл HeapInt.cpp
#include "HeapInt.h"
// ... 15

Дан класс HeapInt выше, представляющий собой обертку для динамически выделенного инта. В отличие от обычного int*, этот тип не позволяет nullptr как обычное пользовательское значение.

Исключение — объект, из которого значение было перемещено. Move-конструктор или move-оператор присваивания может оставить такой объект с heapValue == nullptr, чтобы прежняя память не была освобождена дважды. Перемещённый объект по-прежнему должен оставаться корректным для уничтожения и повторного присваивания, но считывать его значение до нового присваивания нельзя.

Класс следует RAII, выделяя память в конструкторе, и удаляя ее в деструкторе.

  1. Почему поле heapValue (2) приватное?

    Подсказка

    Инкапсуляция данных.

    Ответ

    Чтобы его нельзя было перезаписать извне. Наш объект позволяет перезаписывать только значение.

  2. Что означает этот синтаксис (4)? Как его расписать в теле конструктора?

    Ответ
    inline HeapInt(int value)
    {
    // : heapValue(new int{value})
    // Выделение памяти
    // new int
    int* t = new int;
    // Инициализация объекта в динамической памяти
    // {value}
    *t = value;
    // Присваивание поля
    // : heapValue(...)
    this->heapValue = t;
    }
  3. Как скомпилировать это с помощью GCC?

    Ответ

    Можно еще добавить флагов -W.

    g++ -c main.cpp -o main.o
    g++ -c HeapInt.cpp -o HeapInt.o
    g++ main.o HeapInt.o -o program

    Или одной командой:

    g++ main.cpp HeapInt.cpp -o program
  4. Зачем (1)? Какую ситуацию это предотвратит?

    Ответ

    Для того чтобы хедер включался только 1 раз.

    Если был бы еще один хедер, demo.h, который делает #include "HeapInt.h", и оба хедера бы включались в main.cpp, без pragma once, HeapInt.h включился бы 2 раза, что определит тип и функции 2 раза, что не разрешено. Поэтому программа не скомпилируется.

    Чтобы предотвратить подобные баги, которые часто неочевидны, #pragma once иcпользуют всегда в хедерах.

  5. Почему (3) определена как inline?

    Ответ

    Она определена в хедере. Если не сделать ее inline, линкер даст ошибку, если хедер был импортирован больше чем в одном compilation unit-е (повторное определение функции).

  6. Как можно обойти ограничение, что конструктор или функция должны быть inline?

    Ответ

    Оставить только прототип. Определить/реализовать в cpp файле.

  7. Зачем на (6) нужна проверка, если int* не может быть nullptr?

    Подсказка

    При передачи параметром rvalue reference (HeapInt&&), из объекта может быть забрана выделенная им память. Тот, кто забирает эту память, должен будет также обнулить передаваемый объект.

    Деталь

    Можно посчитать, что, так как каждый объект HeapInt имеет ненулевой heapValue, можно и не забирать память из HeapInt&&, а просто перекопировать значение, поскольку она будет удалена в деструкторе временного объекта. Однако, для правильной работы пункта со swap, это будет важно осуществить именно путем забирания памяти, как минимум когда heapValue == nullptr. Перемещение другого объекта в перемещенный до этого объект - это допустимая операция.

    Эта проверка остается необходимой, из-за существования move конструктора.

  8. Объясните, какой из конструкторов или перегруженных операторов вызовется для (8-14). Какие из них эквивалентны?

  9. Как запретить синтаксис на (11-12)?

Подсказка

explicit.

  1. Определите на (15) конструкторы и операторы на (7).

    Как определить конструктор или перегруженный оператор отдельно?
    class Hello
    {
    Hello(AnyParam p);
    }
    Hello::Hello(AnyParam p)
    {
    // ...
    }

    Для оператора, используется тот же синтаксис, что и для методов (имя метода будет operator=).

    А что в них делать?

    Помните, что все эти действия должны соблюдать правила RAII (только один объект владеет конкретной памятью). Любой порядок их выполнения должен быть поддерживаем.

    Конструктор копирования должен выделять свежую память и копировать значение числа с переданного объекта (можно просто вызвать конструктор с int параметром, передав значение из второго объекта).

    Move конструктор должен забрать указатель из переданного временного объекта, выставив у него nullptr. Выставлять nullptr нужно для того, чтобы тот же указатель не удалился второй раз в деструкторе.

    Оператор присваивания должен перекопировать значение по указателю heapValue второго объекта в уже имеющийся heapValue.

    Оператор присваивания с rvalue reference параметром должен забрать указатель из переданного объекта.

  2. В какой еще ситуации вызовется rvalue reference перегрузка конструктора или оператора присваивания?

    Перегрузка?

    Перегрузка означает функция с тем же именем, но другими параметрами.

    Ответ

    Со временными объектами. То есть для значений типа ожидаемого объекта, которые не были сохранены в переменную, а переданы сразу функции.

  3. Сколько раз вызовется деструктор для типа HeapInt после (14)?

    Ответ

    Столько раз, сколько переменных этого типа определено.

    Например, a был перемещен через std::move, но его деструктор все равно вызовется.

  4. Объясните, какая по вашему мнению была мотивация того, что у этого типа не был определен дефолтный конструктор?

    Определение понятия

    Контракт класса означает что-то типа правил, которые всегда выполняются при существовании объекта, вне зависимости от того, какие операции с ним были выполнены.

    Для этого класса контракт заключается в том, что heapValue не может быть равен nullptr, кроме явно оговоренного состояния объекта после перемещения.

    В более широком смысле слова, контракт означает требования для входных данных (preconditions) и гарантии для выходных данных (postconditions), и может упомянаться в интерфейсе функции. Контракт класса это по сути условия, которые должен выполнять каждый из методов этого класса, общий контракт для каждого метода, связанный со скрытым параметром (this).

    Ответ

    Для соблюдения контракта класса.

    Был выбор:

    • сделать дефолтный конструктор, который бы вписывал nullptr в heapValue, что бы нарушило контракт класса;

    • выделять память в дефолтном конструкторе, что обычно делать не рекомендую (дефолтные конструкторы должны быть дешевыми);

    • не делать конструктор вообще.

  5. Объясните, почему считывать значение объекта после применения std::move это неправильная операция?

    Ответ

    Объект может перестать быть действительным (вошел в невалидное состояние).

    Например, heapValue может стать равен nullptr. И только в такой ситуации.

  6. Создайте функцию void swap(HeapInt& a, HeapInt& b), которая меняет значения, без выделения новой динамической памяти.

    Подсказка 1

    Имплементация для обычного типа, не использующего RAII, будет следующая:

    void swap(Whatever& a, Whatever& b)
    {
    Whatever temp = a;
    a = b;
    b = temp;
    }
    Подсказка 2

    Move операции позволяют “выгружать” значение из одного объекта в другой.

    Подсказка 3

    Подумайте, какие операции бы использовались в 1 подсказке (конструктор копирования, оператор присваивания). Замените их на move операции.