Области видимости и RAII
Области видимости
Заголовок раздела «Области видимости»В C++ у областей видимости две роли:
- Они делают переменные невидимыми за своими пределами;
- Они управляют временем жизни локальных объектов с автоматической длительностью хранения: при выходе из области такие объекты уничтожаются, и их деструкторы освобождают ресурсы.
Заметьте, это не касается динамической памяти:
newвозвращает указатель, и сам этот указатель — обычный локальный объект области видимости. Когда область заканчивается, указатель уничтожается, но объект в куче при этом никак не затрагивается.
int main(){ { // `pointer` — обычная локальная переменная: она живет в этой области. int* pointer = new int{5}; }
// Область закончилась: указателя больше нет, но int, на который он указывал, // все еще лежит в куче нетронутым (и теперь недостижим — это утечка памяти).
return 0;}Можно сказать, что области видимости позволяют управлять временем жизни автоматических объектов.
Области видимости для ограничения видимости
Заголовок раздела «Области видимости для ограничения видимости»Области создаются с помощью { ... }.
int main(){ int a = 1;
{ int b = 2; a = 5; // можно, `a` видна во внешней области b = 10; // можно, `b` видна в текущей области }
b = 10; // нельзя, `b` не видна, потому что её область закончилась // (фигурная скобка закрыта).
a = 15; // по-прежнему можно, потому что она видна в текущей области
return 0;}Функции обязаны определять область видимости для своего тела.
Переменные с одним именем можно объявлять заново, если они находятся в разных областях. Гарантии, что они будут указывать на одну и ту же память, нет.
int main(){ int a = 5;
// Переобъявлять переменную в той же области нельзя. // int a;
{ // Переобъявить её здесь тоже нельзя. // int a;
int b = 10; }
{ // Это можно, потому что области не пересекаются. int b = 20;
{ // Вот это было бы запрещено. // int b = 30; } }
return 0;}Область внутри другой области называется вложенной областью. Области можно вкладывать друг в друга неограниченно.
Вообще, помещение сущностей внутрь сущностей того же рода называется вложением (nesting).
Есть и концепция глобальной области видимости — области, находящейся вне любых функций.
Области видимости для очистки ресурсов
Заголовок раздела «Области видимости для очистки ресурсов»Концептуально локальная переменная с автоматической длительностью хранения уничтожается в конце области.
Для простых типов вроде int это буквально ничего не значит.
Для сложного типа, например std::string, это означает
возврат памяти, выделенной под массив символов, обратно C++-рантайму.
Деструктор
Заголовок раздела «Деструктор»Свою логику, вызываемую при уничтожении объекта, можно задать с помощью деструктора. Деструктор — это специальная функция без возвращаемого типа, которая вызывается в этом контексте автоматически. Вызвать его можно и вручную, но осторожно: для обычного автоматического объекта деструктор потом вызовется еще раз при выходе из области, а уничтожать объект дважды нельзя.
Просто проиллюстрируем, как это работает.
#include <iostream>
struct Demo{ int i;
// Это деструктор ~Demo() { std::cout << "Destroying demo" << this->i << std::endl; }};
int main(){ Demo demo1{1}; Demo demo2{2}; std::cout << "Demos created" << std::endl;
return 0;
// Компилятор неявно добавляет следующее: // `demo2.~Demo();` // `demo1.~Demo();`}Что выводит:
Demos createdDestroying demo2Destroying demo1Объявление деструктора можно отделить от определения так же, как вы делаете это с обычными методами.
Заметьте, деструктор будет вызван только в этом контексте. Если переприсвоить переменную, для объекта, уже находящегося в этой памяти, он вызван не будет.
int main(){ Demo demo1{1}; Demo demo2{2}; demo2 = demo1; // Это копирует байты объекта, но не вызывает деструктор `demo2`.
return 0;
// выполняется `demo2.~Demo()` и печатается "Destroying demo 1" // выполняется `demo1.~Demo()` и печатается "Destroying demo 1"}Чтобы один и тот же объект не оказался удален дважды в такой ситуации, придется перегрузить оператор присваивания.
Неявные деструкторы
Заголовок раздела «Неявные деструкторы»Допустим, у вас есть структура с полем, тип которого имеет деструктор.
#include <iostream>
struct Test{ ~Test() { std::cout << "Destroying test" << std::endl; }};
struct Person{ Test test;};
int main(){ Person person; person.test = {}; return 0;
// Компилятор автоматически вызывает деструктор каждого поля. // `person.test.~Test();`
// Он также удалит временный объект, использованный для присваивания, // но об этом подробнее позже.}Конструкторы
Заголовок раздела «Конструкторы»Конструкторы — это специальные функции без возвращаемого типа, используемые для инициализации объектов. Вызывать конструкторы в каком-либо ином контексте нельзя.
Конструкторы были придуманы, чтобы позволить инициализировать закрытые члены данных (поля) в ООП. Конструкторы могут делать и больше, но делать их сложными обычно не рекомендуется.
Конструкторы для инициализации новых объектов
Заголовок раздела «Конструкторы для инициализации новых объектов»Если они выходят из-под контроля, можно перейти к фабричным функциям.
Например, определим конструктор без параметров (конструктор по умолчанию), который выводит что-то в консоль.
#include <iostream>
class Demo{ int memory;
public: Demo() { // память в этой точке не инициализирована (содержит мусор). std::cout << "Created " << this->memory << std::endl; this->memory = 5; }
~Demo() { // здесь память равна 5. std::cout << "Destroyed " << this->memory << std::endl; }};
int main(){ { // Это на самом деле вызывает конструктор без параметров. // Разработчики C++ решили, что объекты никогда не должны оставаться неинициализированными. Demo demo;
// Добавлено компилятором неявно: // demo.~Demo(); }
{ // Явно вызывает конструктор без параметров. Demo demo{};
// Эквивалентный синтаксис: // Demo demo = Demo();
// Добавлено компилятором неявно: // demo.~Demo(); }
return 0;}Практический сценарий — например, выделить память и освободить её в деструкторе.
Для этого используются операторы new и delete.
#include <iostream>#include <assert.h>
class Buffer{// private: size_t length; int* firstElement;
public: Buffer(size_t elementCount) // Записывает `elementCount` прямо в length до того, как выполнится наш код. // Это называется инициализатором члена. : length(elementCount) //, anotherField(8) { // ВАЖНО: firstElement здесь содержит мусор.
// Нам нужно выделить память, а в инициализаторе этого не сделать. // Поэтому я решил написать это в теле конструктора. this->firstElement = new int[elementCount];
// Изначально элементы не инициализированы. }
~Buffer() { delete[] firstElement; }
int& elementAt(size_t index) { assert(index < this->length); return firstElement[index]; }};
int main(){ Buffer buffer{5};
// Выводит мусор. std::cout << buffer.elementAt(0) << std::endl;
// Делаем что-то с буфером. int& secondElement = buffer.elementAt(1); secondElement = 10;
return 0;
// Компилятор вставил следующее автоматически. // buffer.~Buffer();}Да, чтение неинициализированной памяти выше — формально неопределенное поведение: здесь мы делаем это намеренно, чтобы пронаблюдать мусорное значение.
Ломаем код выше
Заголовок раздела «Ломаем код выше»Сломать код выше и получить утечки памяти или повторные освобождения по-прежнему крайне просто:
int main(){ Buffer buffer1{10}; Buffer buffer2{20}; // Это почленное копирование. buffer2 = buffer1; return 0;
// буфер длины 20 никогда не освободится // буфер длины 10 освободится дважды (ошибка времени выполнения)}Конструкторы копирования
Заголовок раздела «Конструкторы копирования»Можно определить конструктор, принимающий ссылку на объект,
который будет вызываться при копирующей инициализации.
Это включает обычные определения с { } или определения с немедленным присваиванием.
#include <iostream>
class Demo{ int id;
public: // Чтобы вообще создать объект, нужен конструктор с параметрами. Demo(int idParameter) : id(idParameter) { }
Demo(const Demo& other) : id(other.id) { std::cout << "Copying " << other.id << std::endl; }};
int main(){ Demo a{1};
// Вызывает конструктор копирования. Demo b{a};
// Тоже вызывает конструктор копирования. Demo c = a;
// Это НЕ вызывает конструктор копирования. // Он просто выполняет почленное копирование. b = c;}
constв параметре конструктора копирования не случаен: без него нельзя скопировать константный объект или временный. Если типу по какой-то причине нужно это запрещать, можно принять неконстантную ссылку — но для обычного копируемого типа пишутconst Demo&.
Перемещение объекта
Заголовок раздела «Перемещение объекта»Перемещение объекта в другой объект заставляет один украсть ресурсы другого. Это часто используется при конструировании объектов или записи значений в поля, чтобы не вызывать конструктор копирования.
Рассмотрим пример, создающий 2 копии std::string:
struct Person{ std::string name;};
int main(){ // Вызывает конструктор `std::string`, // который копирует символы в динамически выделенный буфер. std::string name = "John Brown";
Person person;
// Создает копию `name`, выделяя еще один буфер и копируя символы. // Затем записывает эту копию в `person.name`. person.name = name;
return 0;
// Компилятор неявно добавляет следующее: // `person.name.~string();` (из неявного деструктора Person, который удаляет все поля) // `name.~string();`}Так что же мы делаем: мы перемещаем name в person.name вместо копирования.
В данном случае это означает, по сути, скопировать объект строки в person.name,
а затем очистить переменную name, чтобы она больше не ссылалась на буфер.
После вызова она становится непригодной к использованию.
🤓 «очистка» перемещаемой строки здесь — деталь реализации. Стандарт технически гарантирует меньше: перемещенный объект остается в валидном, но неопределенном состоянии (он даже может остаться непустым) — требований к его содержимому больше нет. На практике же каждый современный компилятор на любом современном железе оставляет
std::stringиstd::vectorпосле перемещения пустыми, как и написано выше.
#include <iostream>
struct Person{ std::string name;};
int main(){ std::string name = "John Brown"; Person person;
std::cout << name << std::endl; // выводит "John Brown" std::cout << person.name << std::endl; // не выводит ничего
person.name = std::move(name);
std::cout << name << std::endl; // не выводит ничего std::cout << person.name << std::endl; // выводит "John Brown"
return 0;}Конструкторы перемещения
Заголовок раздела «Конструкторы перемещения»В общем случае можно определять конструкторы, принимающие rvalue-ссылки (&&). По сути, это ссылка, из которой предполагается красть ресурсы.
Не зацикливайтесь здесь на деталях, что такое rvalues, я объясню позже.
Rvalue-ссылки можно использовать и как обычные параметры.
#include <iostream>
class Demo{ int* memoryPointer;
int getValue() { if (memoryPointer == nullptr) return 0; return *memoryPointer; }
public: Demo(int value) { // Выделяем int в куче (просто для примера, я знаю, что это бессмысленно), // и сохраняем указатель на него в `memoryPointer`. this->memoryPointer = new int{value}; }
~Demo() { std::cout << "Destructor called for " << this->getValue() << std::endl; // Освобождаем (удалять nullptr допустимо). delete this->memoryPointer; }
// Конструктор перемещения Demo(Demo&& other) { std::cout << "Move constructor called for " << other.getValue() << std::endl;
// Крадем указатель у другого объекта. this->memoryPointer = other.memoryPointer;
// Обнуляем указатель у другого объекта. // `nullptr` — то же самое, что `0`: он просто обнуляет указатель. other.memoryPointer = nullptr; }};
int main(){ Demo demo1{1}; Demo demo2{2};
// Это вызывает конструктор перемещения. Demo demo3 = std::move(demo1);
return 0;
// Компилятор неявно добавляет следующее: // demo3.~Demo(); (очищает память с 1) // demo2.~Demo(); (очищает память с 2) // demo1.~Demo(); (ничего не делает)}Оптимизация возвращаемого значения (RVO)
Заголовок раздела «Оптимизация возвращаемого значения (RVO)»Если вы возвращаете объект из функции, его деструктор вызван не будет. Более того, не будет вызван и конструктор перемещения. Результат просто записывается прямо в память переменной, объявленной для результата.
🤓 для именованной переменной вроде
demo1ниже это деталь реализации (NRVO). Стандарт технически гарантирует устранение копирования только для временных объектов — например,return Demo{1};(гарантированно начиная с C++17). Для именованного объекта, если NRVO не сработал, вызывается конструктор перемещения или копирования, аdemo1уничтожается как обычно. На практике же каждый современный компилятор на любом современном железе устраняет копию и здесь.
Я не описал, как на самом деле работает возврат объектов из функций, потому что сам не знаю точной механики.
Объект может быть возвращен в одном или нескольких регистрах, а затем скопирован в переменную на стеке, или функция может принимать скрытый «выходной» адрес, по которому записывает результат. Я не знаю, в какой ситуации какой вариант использует компилятор, и есть ли более хитрые способы это устроить.
Если вы знаете или хотите узнать больше, буду признателен за PR с кратким объяснением или за ссылки на ресурсы.
Demo test(){ Demo demo1{1}; Demo demo2{2}; return demo1;
// `demo2.~Demo();` вставлен компилятором, как обычно. // Вызов деструктора для `demo1` сюда НЕ вставлен.}
int main(){ // Компилятор может реализовать это, передавая в `test` скрытый указатель на локальную `demo`, // и отдавая эту память локальной `demo1` внутри `test`, // так что память `demo1` на самом деле не хранится в стековом кадре `test`. // Можно считать, что `demo1` — это ссылка на `demo`. // Учтите, компилятор не обязан делать именно так, // это лишь один из способов реализовать такое поведение. Demo demo = test(); return 0;
// `demo.~Demo();` вставлен компилятором, как обычно.}Именно из-за RVO не следует перемещать объекты из функций.
Demo test(){ Demo demo{1}; // НЕ делайте так — это создаст лишнюю копию. // Это вызовет конструктор перемещения в выходную память, // а также вызовет деструктор локальной `demo` в конце функции. return std::move(demo);}lvalue и rvalue
Заголовок раздела «lvalue и rvalue»lvalue — это значение, которое может появиться в левой части присваивания. Иначе говоря, lvalue — значение, у которого есть хранилище: оно хранится в какой-то памяти. Другими словами, у lvalues есть адрес в памяти.
rvalue хранилища не имеет: это может быть временное значение или константа. rvalues могут появляться в правой части присваивания.
Более точное определение (категории выражений)
lvalue и rvalue — это категории выражений, а не позиции в присваивании.
lvalue — выражение, у которого есть идентичность: именованная переменная,
элемент массива, разыменованный указатель. У него есть адрес,
и обычно оно может стоять слева от =.
rvalue — выражение без идентичности: временное значение (результат арифметики,
литерал 5), которое живет до конца текущего выражения.
Тонкость: rvalue не обязательно «без адреса и хранилища». Например,
std::move(x)— это rvalue (точнее, xvalue), хотя он ссылается на вполне реальный объект с адресом. Поэтому определения выше — упрощения, которые ломаются на ссылках иstd::move.
int a;
// a — lvalue, 5 — rvaluea = 5;
{ int b;
// b и a — оба lvalue // lvalues могут появляться и в правой части b = a;}
{ std::vector<int> vec; // vec — lvalue, {} — rvalue vec = {};}
{ int& t = a; // t — lvalue t = 6;}
{ int* t = &a; // *t — lvalue *t = 7;}rvalue-ссылки обозначаются T&& — это ссылка на объект, из которого предполагается «красть» ресурсы.
Заставить выражение считаться rvalue можно с помощью std::move:
сам по себе он ничего не перемещает, а лишь приводит выражение к rvalue-ссылке.
std::vector<int> a{};a.push_back(5);
{ // Это создает копию a в t std::vector<int> t{ a }; a[0] = 6; assert(t[0] == 5); assert(a[0] == 6);}{ // a становится пустой, теперь t владеет памятью, на которую раньше указывала a std::vector<int> t{ std::move(a) }; assert(a.size() == 0); assert(t[0] == 5);}Это позволяет избежать копирования. В примере ниже будет создана только одна копия строк Test и Magic.
Заметьте: из-за оптимизации коротких строк разницы в использовании памяти вы можете не увидеть, но если строки достаточно велики, они будут копироваться в динамически выделенную память больше раз, чем требуется.
void addBook(Book&& book){ *someMemory = std::move(book);}
// ...
Book temp;temp.author = "Test";temp.title = "Magic";addBook(std::move(temp));assert(temp.author == "");