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

Области видимости и 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 created
Destroying demo2
Destroying 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(); (ничего не делает)
}

Если вы возвращаете объект из функции, его деструктор вызван не будет. Более того, не будет вызван и конструктор перемещения. Результат просто записывается прямо в память переменной, объявленной для результата.

🤓 для именованной переменной вроде 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 — это значение, которое может появиться в левой части присваивания. Иначе говоря, lvalue — значение, у которого есть хранилище: оно хранится в какой-то памяти. Другими словами, у lvalues есть адрес в памяти.

rvalue хранилища не имеет: это может быть временное значение или константа. rvalues могут появляться в правой части присваивания.

Более точное определение (категории выражений)

lvalue и rvalue — это категории выражений, а не позиции в присваивании.

lvalue — выражение, у которого есть идентичность: именованная переменная, элемент массива, разыменованный указатель. У него есть адрес, и обычно оно может стоять слева от =.

rvalue — выражение без идентичности: временное значение (результат арифметики, литерал 5), которое живет до конца текущего выражения.

Тонкость: rvalue не обязательно «без адреса и хранилища». Например, std::move(x) — это rvalue (точнее, xvalue), хотя он ссылается на вполне реальный объект с адресом. Поэтому определения выше — упрощения, которые ломаются на ссылках и std::move.

int a;
// a — lvalue, 5 — rvalue
a = 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 == "");