В этом параграфе мы узнаем подробнее про конструкторы, деструктор и оператор присваивания, проследим эволюцию объекта от создания до уничтожения, поговорим про временные объекты, а также рассмотрим два разных способа создать объект: на стеке или в динамической памяти. Это потребуется нам в параграфе «Идиома RAII и умные указатели» для понимания того, как объекты класса могут владеть ресурсами.
Для знакомства с жизненным циклом объекта мы напишем особый класс, который в своих специальных функциях выводит на экран соответствующие сообщения.
Класс для логгирования сообщений
Раньше мы определяли у классов только конструкторы. Они принимали параметры для инициализации объекта. На самом деле в классе можно определить ещё несколько специальных функций:
- конструктор копирования: он вызывается при создании копии другого объекта;
- оператор присваивания: вызывается при присваивании нового значения уже существующему объекту;
- деструктор: вызывается при уничтожении объекта.
Раньше в наших классах эти функции неявно дописывал за нас компилятор. Их реализация была тривиальной: конструктор просто копировал поля из объекта-образца, оператор присваивания присваивал их значения полям текущего объекта, а деструктор ничего не делал.
Напишем класс Logger
, в котором мы нарочно переопределим эти специальные функции и будем выводить в них сообщения об их вызове. Дальше мы создадим объекты этого класса и проследим по логу, в каком порядке эти функции вызываются. Начнём с конструктора и деструктора:
#include <iostream>
class Logger {
public:
Logger() { // конструктор без аргументов
std::cout << "Logger()\n";
}
~Logger() { // деструктор
std::cout << "~Logger()\n";
}
};
Имя деструктора состоит из тильды и имени класса. Как мы узнаем позже, деструкторы необходимы для освобождения ресурсов, захваченных классом. Но сейчас наш деструктор, как и конструктор, просто печатает сообщение о вызове.
Автоматические объекты
Напишем теперь простую тестовую программу, которая создаёт переменную типа Logger
и больше ничего не делает.
#include <iostream>
int main() {
Logger x;
std::cout << "Hello!\n";
}
Эта программа напечатает такой текст:
Logger() Hello! ~Logger()
Первую строчку печатает конструктор при создании объекта x
. А последнюю строчку печатает деструктор, когда выполнение программы доходит до конца блока. В нашем примере таким блоком служит тело функции. Мы видим, что для обычных переменных компилятор автоматически вызывает деструкторы, когда эти переменные выходят из своей области видимости. Поэтому иногда такие переменные называют автоматическими. Позже мы познакомимся с другими способами создать объект.
Добавим к нашей программе ещё один объект:
int main() {
Logger x1;
Logger x2;
}
Программа напечатает такой вывод:
Logger() Logger() ~Logger() ~Logger()
У каждого из объектов был вызван конструктор, а затем — деструктор. К сожалению, по этому выводу невозможно понять, в каком порядке вызывались деструкторы. Добавим индивидуальности нашим объектам, чтобы отличать их логи: будем хранить в них различные целые числа.
#include <iostream>
class Logger {
private:
int id = 0;
public:
Logger() {
std::cout << "Logger(): " << id << "\n";
}
Logger(int x) { // новый конструктор для инициализации объекта целым числом
id = x;
std::cout << "Logger(int): " << id << "\n";
}
~Logger() {
std::cout << "~Logger(): " << id << "\n";
}
};
Рассмотрим такой пример:
int main() {
Logger x1(1);
{
Logger x2(2);
}
Logger x3(3);
}
Здесь мы нарочно в функции main
создали вложенный блок и поместили туда переменную x2
. Теперь программа напечатает
Logger(int): 1 Logger(int): 2 ~Logger(): 2 Logger(int): 3 ~Logger(): 3 ~Logger(): 1
Мы видим, что автоматические объекты удаляются в порядке, который противоположен порядку создания. Объект, созданный последним, выйдет из области видимости первым. Именно таким образом ведёт себя стек. Поэтому часто про автоматические объекты говорят, что они созданы на стеке. Память для хранения таких объектов выделяется и освобождается очень быстро: для этого достаточно передвинуть «границу», которая отделяет занятую область от незанятой.
Объекты в динамической памяти
Модель стека не всегда подходит для создания объектов. Например, функция push_back
для добавления нового элемента в std::list
не может создать новый узел на стеке: он бы автоматически разрушился деструктором при выходе из функции.
В C++ можно управлять жизнью объекта вручную. Ручные объекты будут расположены уже не на стеке, а в динамической памяти. Программист сам должен следить за временем жизни таких объектов и удалять их, когда они не нужны. Создаются такие объекты конструкцией new
, которая выбирает свободный блок памяти, создаёт там объект и возвращает указатель на эту память. Такие объекты необходимо обязательно удалять конструкцией delete
, когда они станут не нужны.
int main() {
Logger* ptr1 = new Logger(1);
Logger* ptr2 = new Logger(2);
delete ptr1; // удаляем сначала объект *ptr1
delete ptr2; // потом удаляем *ptr2
}
В этой программе мы смогли поменять порядок удаления объектов по сравнению со стеком: *ptr1
, который был создан раньше *ptr2
, также удаляется раньше. Вывод такой программы:
Logger(int): 1 Logger(int): 2 ~Logger(): 1 ~Logger(): 2
Сразу заметим, что непосредственные конструкции new
и delete
довольно опасны. Очень легко допустить ситуацию, в которой delete
или будет забыт, или не будет вызван. Это в свою очередь может привести к утечке памяти и, возможно, утечке других ресурсов, которые могли быть захвачены объектом. Подробнее об этом мы поговорим в параграфе «Идиома RAII и умные указатели». Там же мы рассмотрим более безопасные способы работы с динамическими объектами.
Конструкции new
и delete
следует рассматривать как транзакции. Так, new
сначала выделяет блок динамической памяти подходящего размера, а потом конструирует в этой памяти элемент. Наоборот, delete
сначала вызывает деструктор объекта, а потом возвращает динамическую память системе.
Контейнеры стандартной библиотеки (кроме std::array
) также размещают свои элементы в динамической памяти. Например, мы могли бы получить тот же эффект, воспользовавшись контейнером std::list
. Удаление элемента из такого контейнера приводит к вызову деструктора элемента.
#include <list>
int main() {
std::list<Logger> loggers(2); // создаём список из двух элементов
loggers.pop_front(); // удаляем первый элемент
loggers.pop_back(); // удаляем второй элемент
} // тут вызывается деструктор самого списка loggers
Копирование и присваивание
Запустим такую программу:
int main() {
Logger x1(1);
Logger x2 = x1; // создаём копию
}
Мы увидим, что как будто бы вызывается один конструктор и два деструктора:
Logger(int): 1 ~Logger(): 1 ~Logger(): 1
На самом деле каждому вызову деструктора должен соответствовать вызов конструктора. В этом примере для объекта x2
вызывается конструктор копирования. Мы не переопределили его в классе Logger
, и компилятор любезно предоставил нам его реализацию по умолчанию.
Похожая странность будет и в таком примере:
int main() {
Logger x1(1);
Logger x2(2);
x2 = x1; // присваиваем значение уже созданному объекту
}
Из лога может показаться, что объект x2
вообще не удаляется, а объект x1
удаляется дважды:
Logger(int): 1 Logger(int): 2 ~Logger(): 1 ~Logger(): 1
Здесь компилятор нам предоставил по умолчанию оператор присваивания, который просто поменял значение поля id
.
Вот как примерно выглядят версии конструктора копирования и оператора присваивания, которые генерирует компилятор:
class Logger {
private:
int id = 0;
public:
// ...
// Конструктор копирования
Logger(const Logger& other) {
id = other.id; // инициализируем поле id значением из объекта-образца
}
// Оператор присваивания
Logger& operator = (const Logger& other) {
id = other.id; // пользуемся тем же оператором = для поля id
return *this;
}
};
Дефолтный конструктор копирования просто вызывает аналогичные конструкторы копирования для всех полей класса. Аналогично, дефолтный оператор присваивания вызывает операторы присваивания для полей. В конструкторе копирования нельзя принимать параметр const Logger& other
по значению как Logger other
: в этом случае параметр должен был бы копироваться, и этот конструктор стал бы рекурсивно вызывать сам себя.
Оператор присваивания по оформлению похож на оператор +=
, который мы писали раньше. Предполагается, что он возвращает ссылку на текущий объект *this
. Это позволяет писать каскадные присваивания a = b = c
: они превращаются компилятором в a = (b = c)
.
Важно понимать разницу между конструктором копирования и оператором присваивания. Конструктор копирования создаёт новый объект, а оператор присваивания модифицирует уже существующий.
Как мы знаем, каждый объект занимает определённую память, в которой расположены его поля. Если у класса есть нетривиальные конструктор или оператор присваивания, то объекты такого класса нельзя копировать или изменять, просто меняя байты в этой памяти, например, функцией std::memcpy
. Необходим полноценный вызов этих специальных функций.
Добавим логгирование в конструктор копирования и оператор присваивания. Однако нам по-прежнему хочется различать номера исходного объекта и его копии. Здесь бы нам помог глобальный счётчик объектов. Каждому новому объекту — неважно, копия это или нет, — мы бы присвоили уникальный номер. Такой счётчик можно было бы хранить в глобальной переменной. Но лучше всего спрятать его внутрь класса и сделать статическим полем, чтобы не засорять глобальное пространство имён.
Статические поля и функции
Ключевое слово static
в C++ используется в нескольких разных смыслах.
В объявлении поля в классе оно обозначает, что значение этого поля одинаково для всех объектов класса. Фактически, статическое поле является глобальной переменной, которую просто поместили в класс как в пространство имён. Ниже мы пользуемся словом static
в сочетании с inline
— этот синтаксис позволяет инициализировать такие поля прямо в классе:
#include <iostream>
class C {
public:
int x = 0; // обычное поле
inline static int sx = 0; // статическое поле, проинициализированное прямо в классе
static const int scx = 100; // статическая константа
};
int main() {
// Обращаемся со статическим полем просто как с глобальной переменной с особым именем:
std::cout << C::sx << " " << C::scx << "\n"; // 0 100
C::sx = 1;
std::cout << C::sx << " " << C::scx << "\n"; // 1 100
C c1, c2; // создадим два объекта типа C
// Обычное поле value привязано к конкретному объекту класса:
c1.x = 42;
c2.x = 17;
c2.sx = 13; // к статическому полю можно обратиться как к обычному, но оно поменяется глобально
std::cout << c1.x << " " << c1.sx << " " << c1.scx << "\n"; // 42 13 100
std::cout << c2.x << " " << c2.sx << " " << c2.scx << "\n"; // 17 13 100
}
Примером статической константы в стандартной библиотеке является std::string::npos
. Напомним, что это значение возвращает функция find
у строки, если подстрока не найдена.
Статические функции имеют похожую семантику.
Статическая функция — это просто обычная функция, которую поместили в класс как в пространство имён по семантическим соображениям. В отличие от функции из класса она не принимает неявным образом текущий объект, но может обращаться к статическим полям.
#include <iostream>
class C {
private:
int x = 0;
inline static int sx = 0;
public:
// обычная функция из класса
void f(int y) {
x = y; // есть текущий объект и доступ к его полям
sx = y;
}
// статическая функция:
static void sf(int y) {
// нет текущего объекта, и поэтому нет доступа к полю x
// но есть доступ к статическому полю
sx = y;
}
};
int main() {
C obj;
obj.f(1); // вызываем обычную функцию, в неё неявно передаётся объект obj
C::sf(2); // вызываем статическую функцию через имя класса
obj.sf(3); // вызываем статическую функцию через объект, но сам объект obj в неё не передаётся
}
Класс Logger
с глобальным счётчиком
Воспользуемся статической переменной, чтобы подсчитывать количество когда-либо созданных объектов класса. Изменять этот счётчик объектов будем в конструкторе объектов.
#include <iostream>
class Logger {
private:
inline static int counter = 0;
const int id; // константа должна быть проинициализирована в конструкторе
public:
Logger(): id(++counter) { // инициализируем id текущего объекта
std::cout << "Logger(): " << id << "\n";
}
Logger(const Logger& other): id(++counter) {
std::cout << "Logger(const Logger&): " << id << " " << other.id << "\n";
}
Logger& operator = (const Logger& other) {
// Тут никакие счётчики не меняются, ведь объект уже создан
std::cout << "Logger& operator = (const Logger&) " << id << " " << other.id << "\n";
return *this;
}
~Logger() {
std::cout << "~Logger() " << id << "\n";
}
};
В конструкторах мы не можем написать инициализацию id
вот так прямо в теле:
Logger() {
++counter;
id = counter;
// ...
}
Дело в том, что id
мы теперь сделали константным полем. Единственная возможность его проинициализировать — явно указать его значение с помощью вот такого синтаксиса перед телом конструктора:
Logger(): id(++counter) {
// ...
}
Обратите внимание на префиксный оператор ++
перед counter
. Он сначала увеличивает значение счётчика counter
, а затем уже обновлённое значение используется для инициализации поля id
. Поэтому все наши объекты будут нумероваться с единицы.
Рассмотрим теперь такую программу:
int main() {
Logger x1;
Logger x2 = x1; // это не присваивание, а инициализация нового объекта через конструктор копирования
Logger x3;
x3 = x1; // а вот это уже оператор присваивания
}
На экране мы увидим такой лог:
Logger(): 1 // создали объект x1 Logger(const Logger&): 2 1 // создали объект x2 по образцу x1 Logger() 3 // создали объект x3 Logger& operator = (const Logger&) 3 1 // вызвали оператор присваиваниия x3 = x1 ~Logger() 3 ~Logger() 2 ~Logger() 1
Инициализация подполей
Поместим наш класс Logger
внутрь другого класса, также логгирующего свои вызовы:
class OuterLogger {
private:
// Делаем два поля типа Logger
Logger innerLogger1;
Logger innerLogger2;
inline static int counter = 0;
const int id;
public:
OuterLogger(): id(++counter) {
std::cout << "OuterLogger(): " << id << "\n";
}
OuterLogger(const OuterLogger& other):
innerLogger1(other.innerLogger1), // инициализируем поля
innerLogger2(other.innerLogger2), // в порядке их объявления
id(++counter)
{
std::cout << "OuterLogger(const OuterLogger&): " << id << " " << other.id << "\n";
}
OuterLogger& operator = (const OuterLogger& other) {
innerLogger1 = other.innerLogger1; // вызываем оператор присваивания для полей
innerLogger2 = other.innerLogger2;
std::cout << "OuterLogger& operator = (const OuterLogger&) " << id << " " << other.id << "\n";
return *this;
}
~OuterLogger() {
std::cout << "~OuterLogger() " << id << "\n";
}
};
int main() {
OuterLogger outerLogger;
}
Заметьте, что у классов Logger
и OuterLogger
имеются свои независимые счётчики объектов.
Мы получим следующий лог:
Logger(): 1 Logger(): 2 OuterLogger(): 1 ~OuterLogger(): 1 ~Logger(): 2 ~Logger(): 1
Мы видим, что перед входом в тело конструктора инициализируются поля создаваемого объекта, если они, конечно, не имеют примитивного типа вроде int
. Мы можем указать, с какими аргументами их инициализировать: это сделано в конструкторе копирования класса OuterLogger
. Если инициализация поля пропущена, то для него будет вызван конструктор без аргументов. Только после этого начинает выполняться тело конструктора. Поэтому мы видим лог в таком порядке:
Logger(): 1 // конструирование поля innerLogger1 Logger(): 2 // конструирование поля innerLogger2 OuterLogger(): 1 // тело конструктора класса OuterLogger
В деструкторе всё происходит наоборот. Сначала выполняется его тело, а затем автоматически вызываются деструкторы для полей, причём в обратном порядке.
Временные объекты
Рассмотрим такой код:
#include <iostream>
void f(const Logger& x) {
std::cout << "void f(const Logger&)\n";
}
int main() {
f(Logger());
std::cout << "Hello!\n";
}
Здесь мы передаём в функцию f
временный объект Logger()
. У него нет имени. Он существует лишь пока вычисляется выражение f(Logger())
. Поэтому сообщение о вызове его деструктора мы увидим до строки Hello!
:
Logger(): 1 void f(const Logger&) ~Logger(): 1 Hello!
В C++ имеется возможность различать в функциях временные и обычные объекты. Напишем перегруженную версию функции f
, принимающую на вход так называемую rvalue-ссылку:
#include <iostream>
void f(const Logger& x) { // версия для обычных аргументов
std::cout << "void f(const Logger&)\n";
}
void f(Logger&& x) { // версия для временных аргументов типа Logger
std::cout << "void f(Logger&&)\n";
}
int main() {
f(Logger()); // вызывается перегруженная версия для временных аргументов
std::cout << "\n";
Logger x;
f(x); // вызывается обычная версия
std::cout << "\n";
}
Вывод будет таким:
Logger(): 1 void f(Logger&&) ~Logger(): 1 Logger(): 2 void f(const Logger&) ~Logger(): 2
Термин «временный объект», который мы используем, не совсем корректен. Правильнее было бы говорить об объектах категории prvalue или xvalue, но мы не будем сейчас переусложнять наш рассказ. Добавим только, что обычный объект можно принудительно рассмотреть как временный, применив к нему функцию std::move
из заголовочного файла utility
. Это может потребоваться для вызова правильной перегруженной версии какой-либо функции.
Перегрузка функций по rvalue-ссылкам особенно полезна в контейнерах и в классах, владеющих ресурсами. Она позволяет отобрать владение ресурсами у временного объекта и избежать дорогих операций копирования и инициализации. Мы увидим это в параграфе «Идиома RAII и умные указатели».
Давайте добавим в наш класс Logger
конструктор перемещения и оператор присваивания, которые будут принимать временный объект.
class Logger {
// ...
public:
// ...
// Конструктор перемещения:
Logger(Logger&& other): id(++counter) {
std::cout << "Logger(Logger&&): " << id << " " << other.id << "\n";
}
Logger& operator = (Logger&& other) {
std::cout << "Logger& operator = (Logger&&) " << id << " " << other.id << "\n";
return *this;
}
};
Запустим такой код:
#include <utility>
int main() {
Logger x1;
Logger x2 = x1; // вызывается обычный конструктор копирования
Logger x3 = Logger(); // сработает copy elision: временный объект даже не будет создаваться
Logger x4 = std::move(x1); // вызывается конструктор перемещения
}
Мы увидим на экране
Logger(): 1 Logger(const Logger&): 2 1 Logger(): 3 Logger(Logger&&): 4 1 ~Logger(): 4 ~Logger(): 3 ~Logger(): 2 ~Logger(): 1
Пояснить здесь нужно инициализацию объекта x3
. Начиная с C++17 компилятор обязан упрощать такое выражение до простого Logger x3
. Это упрощение называется copy elision.
Аналогично, протестируем оператор присваивания:
#include <utility>
int main() {
Logger x1;
Logger x2;
x2 = x1; // обычный оператор присваивания
x2 = Logger(); // присваиваем временный объект с номером 3, который тут же умирает
x2 = std::move(x1); // рассматриваем x1 как временный объект (но он при этом продолжает жить)
}
Вывод:
Logger(): 1 Logger(): 2 Logger& operator = (const Logger&): 2 1 Logger(): 3 Logger& operator = (Logger&&): 2 3 ~Logger(): 3 Logger& operator = (Logger&&): 2 1 ~Logger(): 2 ~Logger(): 1
Объекты в контейнерах
Попробуем сложить элементы нашего типа Logger
в различные контейнеры. Начнем с std::list
:
#include <iostream>
#include <list>
int main() {
std::list<Logger> container;
container.push_back(Logger());
std::cout << "\n";
Logger x;
container.push_back(x);
std::cout << "\n";
}
Вывод на экран зависит от конкретной реализации контейнера std::list
в стандартной библиотеке. Будет примерно следующее:
Logger(): 1 // создаём временный объект Logger(), который передаётся в push_back Logger(Logger&&): 2 1 // в контейнере создаётся новый объект через конструктор перемещения ~Logger(): 1 // временный объект умирает Logger(): 3 // создаётся объект x Logger(const Logger&): 4 3 // объект x копируется в контейнер ~Logger(): 3 // умирает объект x ~Logger(): 2 // умирает сам containter: сначала умирает его первый элемент ~Logger() 4 // умирает второй элемент контейнера
В нашем контейнере всего два элемента, но в итоге было создано четыре объекта! Заметим, что вместо push_back
можно было бы воспользоваться функцией emplace_back
. Она принимает на вход не сам объект, копию которого надо поместить в контейнер, а аргументы конструктора объекта, и создаёт новый объект сама:
int main() {
std::list<Logger> container;
container.emplace_back(); // аргументов нет: создаём в контейнере новый объект конструктором без аргументов
container.emplace_back();
}
Вывод на экран теперь такой:
Logger(): 1 Logger(): 2 ~Logger(): 1 ~Logger(): 2
Теперь положим элементы в вектор:
#include <vector>
int main() {
std::vector<Logger> container;
container.emplace_back();
std::cout << "\n";
container.emplace_back();
std::cout << "\n";
}
Вывод, опять же, зависит от реализации std::vector
и будет примерно таким:
Logger(): 1 // создаём первый объект в векторе Logger(): 2 // создаём второй объект в векторе Logger(const Logger&): 3 1 // но что это? ~Logger(): 1 ~Logger(): 3 ~Logger(): 2
Откуда здесь конструктор копирования, создающий третий объект? Это произошла реаллокация. После первого emplace_back
в векторе была зарезервирована память только под один элемент. Когда выполнился второй emplace_back
, произошло следующее:
- выделился новый фрагмент памяти, достаточный для хранения двух элементов;
- в нём был создан новый (второй) элемент на нужном месте: это строка
Logger(): 2
в логе; - в новый фрагмент памяти был скопирован первый элемент из старой памяти:
Logger(const Logger&): 3 1
; - элемент в старой части памяти был уничтожен:
~Logger(): 1
.
Порядок удаления элементов в деструкторе вектора стандартом не определён и может различаться в разных реализациях стандартной библиотеки. В нашем примере элементы удалялись в прямом порядке.