О чём лекция

Первая половина лекции — выражения: что они возвращают, что меняют по пути и почему результату одних операций можно присвоить, а других — нельзя. Отсюда категории значения lvalue и prvalue, инкремент, sizeof и запятая. Вторая половина — память: у каждого объекта есть адрес, адрес можно хранить в указателе, а объекты можно создавать в динамической памяти, у которой нет ограничений стека, зато есть обязанность вовремя её освободить.

Маршрут

  1. Присваивание возвращает переменную → Выражения и операторы
  2. Материален ли результат выражения → Категории значения
  3. Две формы ++ и -- → Инкремент и декремент
  4. Размер без вычисления → sizeof; явное приведение → static_cast
  5. Адрес объекта и тип, который его хранит → Указатель
  6. Сдвиг и разность адресов → Арифметика указателей
  7. Указатель, переживший объект → Висячий указатель
  8. Объекты, которые живут дольше блока → Динамическая память
  9. Чего опасаться → Неопределённое поведение

1. Присваивающие операции

Значение переменной меняют присваиванием (=):

y = 5;
x = y;
x = y * y;

Кроме обычного присваивания есть составные — присваивающие арифметические операции:

x += 5;  // x = x + 5
y %= 2;  // y = y % 2
y |= 9;  // y = y | 9

Опечатка в лонгриде

В материалах у третьей строки комментарий // z = z | 9. Речь, конечно, о y = y | 9.

Присваивающие операции не только меняют значение, но и возвращают результат — новое значение. Его можно напечатать:

std::cout << (y = 5);      // 5
std::cout << (x = y);      // 5
std::cout << (x = y * y);  // 25
 
std::cout << (x += 5);  // 30
std::cout << (y %= 2);  // 1
std::cout << (y |= 9);  // 9

Строки выполняются по порядку и меняют переменные: после y %= 2 в y лежит 5 % 2 == 1, и 1 | 9 == 9.

Раз присваивание возвращает значение, присваивания можно выстраивать в цепочку. Присваивание группируется справа налево, поэтому x = y = 5 — это x = (y = 5):

x = y = 5;
z = x = y *= y;  // z = (x = (y *= y))

Результат присваивания — сама переменная

Более того, результат присваивания — не просто число, а сама исходная переменная. Значит, ей можно снова что-то присвоить:

(x = y) = 5;   // x == 5
(x = y) *= 0;  // x == 0

Сначала значение y записывается в x, затем в ту же переменную x записывается 5.

А вот слева от присваивания не может стоять литерал или результат неприсваивающей операции:

5 = x;       // ошибка
x * y = 10;  // ошибка
x + y = 10;  // ошибка

Выражение x + y вычисляет сумму, но отдельной переменной с этой суммой не существует. Присваивать можно только объекту, значение которого можно изменить, а результат обычного вычисления таким объектом не является. Отсюда два вопроса, на которые отвечает следующий раздел:

  1. почему результат присваивания ведёт себя как переменная;
  2. почему результату других операций ничего нельзя присвоить.

2. Выражение: значение и побочный эффект

Выражение (expression) — последовательность операций и их операндов, задающая некоторое вычисление. Примеры: 2 + 2, x * y, x + 3 * y, x = y, y *= 10 % x.

Результат работы выражения проявляется в двух местах:

  1. в возвращаемом значении;
  2. в побочных эффектах (side effects) — изменениях состояния программы.

У x + y побочных эффектов нет, только значение. У x = 5 есть и то и другое: в x записывается 5, и выражение возвращает x. Поэтому операции полезно рассматривать с двух сторон: какое значение возвращают и что меняют по пути.

3. Категория значения

Каждое выражение характеризуется типом возвращаемого значения и категорией значения (value category). С типом всё ясно. Категория отвечает на вопрос: материален ли результат — существует ли он в виде объекта в памяти.

Пусть изначально x == 5, строки выполняются по порядку:

ВыражениеТипПобочный эффектКатегория значения
x + yintнетне материален
x = 5intв x хранится 5материален (x)
x *= 2intв x хранится 10материален (x)
y + (x *= 6)intв x хранится 60не материален
x / 2.doubleнетне материален

В четвёртой строке внутри есть материальный x *= 6, но снаружи — сложение, и результат всего выражения — просто число.

Материальная категория называется lvalue, нематериальная — prvalue. Значения lvalue соответствуют некоторому месту в памяти, поэтому могут стоять слева от присваивания (за некоторыми исключениями). Теперь ясна и природа ошибок:

5 = x;       // error: lvalue required as left operand of assignment
x * y = 10;  // error: lvalue required as left operand of assignment

Подробнее, с проверкой через взятие адреса, — Категории значения.

Не из лекции

«Некоторые исключения» — это, например, const-переменная: она lvalue, адрес у неё есть, но присвоить ей нельзя. Кроме lvalue и prvalue в стандарте есть третья категория, xvalue; она понадобится вместе с std::move (см. раздел 12).

4. Инкремент и декремент

Инкремент и декремент (increment, decrement) — унарные операции, увеличивающие или уменьшающие аргумент на 1. Как и присваивание, применимы только к lvalue:

int x = 0;
 
x++;  // постфиксный инкремент (x = 1)
++x;  // префиксный инкремент (x = 2)
x--;  // постфиксный декремент (x = 1)
--x;  // префиксный декремент (x = 0)
 
++5;        // error: lvalue required as increment operand
(x + 1)--;  // error: lvalue required as decrement operand

Побочный эффект у обеих форм одинаковый — переменная меняется на единицу. Различаются они возвращаемым значением:

int x = 0;
 
int a = ++x;  // x стало равным 1, а получило 1
int b = x++;  // b получило 1, затем x стало равным 2
  • префиксные ++x, --x возвращают обновлённый аргумент — сам x, lvalue;
  • постфиксные x++, x-- возвращают старое значение, до обновления.
int x = 0;
std::cout << ++x << '\n';  // 1 (x == 1)
std::cout << x++ << '\n';  // 1 (x == 2)
 
--x = 10;  // x == 10
x-- = 11;  // ошибка компиляции

Неточность в лонгриде

В материалах сказано, что постфиксные версии возвращают «старое значение (до обновления) lvalue». Это не так: старое значение больше нигде не хранится — в x уже лежит новое, — поэтому постфиксная форма возвращает копию, prvalue. Это видно по соседним примерам из самого лонгрида: x-- = 11 и &(x++) не компилируются (lvalue required), GCC 16.2.

Подробнее — Инкремент и декремент.

5. sizeof, static_cast, запятая

sizeof

sizeof — унарная операция, возвращающая размер в байтах:

  • применённая к типу — сколько байт занимает объект этого типа;
  • применённая к выражению — размер типа его возвращаемого значения.

Важно: sizeof не вычисляет выражение, а лишь анализирует его тип.

long long x;
 
sizeof('a');    // 1
sizeof(1 / 0);  // 4
sizeof(x = 1);  // 8 (x не меняется!)
sizeof(++x);    // 8 (x не меняется!)

Деления на ноль во второй строке не происходит: деления нет вообще. Подробнее — sizeof.

static_cast

static_cast принудительно выполняет допустимые неявные преобразования значений одного типа в другой:

static_cast<новый тип>(выражение)
int x = 2;
int y = 2'000'000'000;
std::cout << x / y << '\n';                      // целочисленное деление
std::cout << static_cast<float>(x) / y << '\n';
 
std::cout << x * y << '\n';                      // переполнение (UB)
std::cout << static_cast<int64_t>(x) * y << '\n';

Первая строка печатает 0: оба операнда int, деление целочисленное. Во второй один операнд стал float — и деление стало вещественным. Третья строка переполняет int; в четвёртой умножение идёт уже в 64 битах и даёт 4 000 000 000. Оба сюжета разобраны в static_cast и Арифметические преобразования типов.

Операция запятая

Запятая объединяет несколько выражений в одно:

++x, y--, std::cout << x + y, 5;
  • запятая гарантирует, что сначала будет вычислено выражение слева;
  • результат операции — результат правого операнда.

Результат всей строки — 5, остальные части нужны ради побочных эффектов.

Не из лекции

Не всякая запятая в коде — эта операция. В int a = 1, b = 2; и в вызове f(a, b) запятая — разделитель, и порядок вычисления аргументов функции не гарантирован. Чтобы получить операцию, нужны скобки: int w = (y++, z); — сначала y++, потом w получает z.

6. Модель памяти

Напоминание:

  • память в C++ — нумерованная последовательность байт;
  • номер байта в памяти называется его адресом;
  • есть несколько областей памяти: глобальная (статическая), автоматическая (стековая), динамическая и потоковая;
  • динамическую память разберём в этой лекции (раздел 10), потоковую не обсуждали и не будем.

Глобальные и автоматические переменные разбирались в L02, см. области действия.

Операция взятия адреса

Адрес объекта даёт унарная операция &:

int x;
std::cout << &x << '\n';  // отображается в 16-ричном виде

Её можно применять только к lvalue. Более того, возможность взять адрес — критерий lvalue:

// так можно
&x, &(x = 8), &(++x);
 
// а так нельзя
&5, &(x + 8), &(x++);

Опечатка в лонгриде

В материалах написано «можно применять только к value значениям» и «критерий value». Из примеров и раздела 1.4 ясно, что имеется в виду lvalue.

Адреса в C++ имеют специальный тип — указатель на тип объекта:

int x;
const float y = 0;
 
&x;  // указатель на int [int*]
&y;  // указатель на const float [const float*]

7. Указатели

Указатель (pointer) — тип данных, позволяющий хранить адрес другого объекта в памяти. Если T — некоторый тип, то T* — указатель на T. Так что указатели — целое семейство типов:

int*          // указатель на int
float*        // указатель на float
long long*    // указатель на long long
const char**  // указатель на указатель на константный char
double***     // указатель на указатель на указатель на double
int x = 0;
const float y = 0;
 
int* px = &x;
int* px2 = &(++x);  // == px
 
const float* cpy = &y;
const int* cpx = &x;
 
int** ppx = &px;
const int** cppx = &cpx;
 
float* py = &y;  // CE: py нарушает константность y

++x возвращает сам x, поэтому px2 указывает туда же, куда px. Последняя строка не компилируется: через float* можно было бы изменить константный y.

Разыменование

Разыменование (dereference) *p — унарная операция над указателем, возвращающая lvalue того объекта, на который он указывает. Через него объект можно и читать, и менять:

int x = 1;
float y = 2.5;
 
int* px = &x;
float* py = &y;
 
*px = 11;
*py = 3.5;
 
std::cout << *px << ' ' << *py;  // 11 3.5
std::cout << x << ' ' << y;      // 11 3.5

Ошибка в примере лонгрида

В материалах int* py = &y; при float y. Такая строка не компилируется: cannot convert 'float*' to 'int*' in initialization (GCC 16.2). Выше исправлено на float* py. В формальном определении там же написано «возвращающая value» — снова имеется в виду lvalue.

Звёздочка в объявлении int* px и звёздочка в выражении *px — разные вещи: первая часть типа, вторая — операция.

Указатели и const

Указатель бывает константным в двух смыслах.

Указатель на константу — неизменяемым считается объект, на который указывают:

int x = 0;
const int y = 0;
 
const int* px = &x;  // либо int const*
const int* py = &y;  // либо int const*
 
std::cin >> *px;   // CE: объект считается неизменяемым
std::cout << *py;  // OK: объект доступен для чтения

Обратите внимание: сам x не константный, но через px его менять нельзя.

Константный указатель — неизменяемым является сам указатель:

int x;
int y;
 
int* const px = &x;
 
std::cin >> *px;   // OK
std::cout << *px;  // OK
 
px = &y;  // CE: указатель неизменяемый
++px;     // CE

Опечатка в лонгриде

Во второй строке с OK в материалах std::cout << *py, хотя py в этом примере не объявлен. Исправлено на *px.

Константность можно комбинировать:

const int* px = ...;        // указатель на константу
int const* py = ...;        // указатель на константу
int* const pz = ...;        // константный указатель
const int* const pa = ...;  // константный указатель на константу
int const* const pb = ...;  // константный указатель на константу

Лайфхак: типы указателей читаются справа налево. int const* const — константный указатель на константу int. Подробнее — const и constexpr.

Указатели на указатели

Указатель — это тип, значит, на него тоже можно сделать указатель:

float x;
float* px = &x;
float** ppx = &px;
float*** pppx = &ppx;

И const с ними тоже дружит:

const float* const** const p = ...;
// константный указатель на указатель на константный указатель на константу float

Нулевой указатель

Чтение неинициализированной переменной — UB. Указателей это касается так же:

int* p;
std::cout << p << ' ' << *p;  // UB

Конкретного числового начального значения указателю в общем случае не присвоить:

int* p = &x;  // Ok
int* q = p;   // Ok
int* r = 10;  // CE: преобразование из int в int* запрещено

Отсюда два вопроса: как задать начальное значение «пустого» указателя и как отличить корректный указатель от указывающего неизвестно куда?

В языке есть исключение: любому указателю можно присвоить 0, который соответствует нулевому адресу. По стандарту по нулевому адресу ничего находиться не может, поэтому разыменование такого указателя — UB.

int* p = 0;
const float* pp = 0;

Но это создаёт асимметрию: целые числа присваивать нельзя, а 0 — внезапно можно. Правильный способ в современном C++ — литерал nullptr:

int* p = nullptr;
const float* pp = nullptr;

nullptr — значение специального типа std::nullptr_t, которое неявно приводится к нулевому указателю любого типа.

Правило: для нулевых указателей используйте только nullptr.

Не из лекции

Исключение касается именно литерала 0. Переменная со значением ноль уже не годится: int z = 0; int* p = z; — ошибка компиляции, как и const int k = 0; int* q = k; (GCC 16.2, -std=c++20).

Указатель на void

Напоминание: void — специальный тип, обозначающий отсутствие объекта. Если нужно сохранить адрес ячейки памяти без учёта типа того, что там лежит, используют void*:

int x;
void* p = &x;
 
std::cout << p;   // Ok
std::cin >> *p;   // CE: не ясно, что записывать
std::cout << *p;  // CE: не ясно, что читать

Адрес хранится, но разыменовать его нельзя: неизвестно, объект какого типа и какого размера там лежит. Вернуть тип можно через static_cast — это понадобится в разделе 10.

8. Арифметика указателей

Адреса — целые числа. Значит ли это, что к ним применима целочисленная арифметика? Частично:

  • Указатели одного типа можно вычитать. Результат — количество элементов типа T, которое поместится в промежутке: разница в байтах / sizeof(T).

    int x, y, z;
    std::cout << &z - &x << '\n';  // скорее всего, 2
  • К указателю можно прибавить целое число. Результат — указатель, сдвинутый на n шагов размера sizeof(T).

    int32_t* p = ...;
    p + 5;    // + 20 байт
    p - 4;    // - 16 байт
    p += 10;
    p -= 12;
    ++p;
    p--;
  • Остальные арифметические операции недопустимы.

При этом разность указателей на отдельные переменные или сдвиг указателя на переменную с точки зрения стандарта разумного результата не даёт. Разыменование таких указателей — UB. Для чего арифметика указателей нужна на самом деле, станет ясно с массивами (L04 - Массивы).

«Скорее всего, 2» — не так

Тот же пример на GCC 16.2 (-O0 и -O2) печатает 1: компилятор вправе раскладывать x, y, z в памяти как угодно. А по стандарту вычитать указатели на разные переменные — уже неопределённое поведение, не только разыменовывать результат. Пример хорош как иллюстрация того, почему так делать нельзя.

Подробнее — Арифметика указателей.

9. Висячий указатель

int main() {
  int* p = nullptr;
  {
    int x = 0;
    p = &x;
  }
  return *p;
}

Область действия x заканчивается раньше, чем область действия указателя на неё. После закрывающей скобки p хранит адрес объекта, которого уже нет. Чтение или запись по такому указателю — UB.

Важно: не допускайте провисания указателей. Подробнее — Висячий указатель.

10. Динамическая память

Зачем

До сих пор переменные жили в глобальной области и на стеке. У этого есть ограничения:

  • строго фиксированное время жизни: глобальные переменные живут до конца программы, локальные — до конца текущего блока;
  • размер: обычно эти области ограничены несколькими мегабайтами, а реальным программам часто нужно гораздо больше.

Выражение new

Выражения вида new T, new T(...), new T[...] создают в динамической области объект типа T и возвращают указатель на него:

int* pa = new int;      // без инициализации
int* pb = new int(11);  // инициализация числом 11

Ошибка в лонгриде: new int[13]

В материалах третьей строкой идёт int* pc = new int[13]; // инициализация числом 13. Это неверно: квадратные скобки создают массив из 13 неинициализированных int и возвращают указатель на нулевой элемент. Такую память освобождают через delete[], а не через delete. Разбирается в L04 - Массивы, см. Динамический массив.

Через полученный указатель и работают с объектом. Объект в динамической области живёт с момента new — и не зависит от блоков. Поэтому его можно создать внутри блока и пользоваться им снаружи:

int* p = nullptr;
{
  int x = 11;
  p = new int(13);
}
std::cout << x;   // CE: x уже уничтожен
std::cout << *p;  // Ok

Терминология

Ошибка компиляции в предпоследней строке — это вопрос области видимости: имени x после блока нет. То, что сам объект уничтожен, — вопрос области действия. Компилятор ругается на первое. См. L02, Область видимости и Область действия.

Но если только создавать объекты, память постепенно «засоряется».

Выражение delete

Память в динамической области можно и нужно освобождать, как только она перестала быть нужной. delete <указатель> удаляет объект и возвращает память системе:

int* p = new int;
// ...
delete p;
  • Замечание 1: удалять можно только память, выделенную через new. В остальных случаях delete — UB.
  • Замечание 2: удаление нулевого указателя — корректная операция, она ничего не делает.

Утечка памяти

При некорректной работе с указателями или несвоевременной очистке возникает утечка памяти (memory leak) — неконтролируемый рост лишней памяти:

int* p = new int;
p = nullptr;  // потеряли указатель на динамическую память — утечка
new float;    // выделили память, указатель не сохранили
p = new int;  // забыли удалить после использования — утечка

Аккуратное использование new и указателей и своевременный delete — залог успешной борьбы с утечками.

Не из лекции

Утечку ловит санитайзер. Для int* p = new int; p = nullptr; сборка g++ -g -fsanitize=address печатает при выходе ERROR: LeakSanitizer: detected memory leaks … 4 byte(s) leaked in 1 allocation(s) (GCC 16.2). См. Компиляция и сборка.

Другие способы выделения

В C основным способом выделения динамической памяти была функция malloc. Ключевые отличия от new:

  • возвращает void*;
  • требует явно указать количество байт;
  • не инициализирует память.
void* v = std::malloc(11);
int* p = static_cast<int*>(std::malloc(sizeof(int)));
*p = 0;
 
std::free(v);
std::free(p);

В C++ есть аналог malloc/free — функции operator new и operator delete:

void* v = operator new(11);
int* p = static_cast<int*>(operator new(sizeof(int)));
*p = 0;
 
operator delete(v);
operator delete(p);

Они используются крайне редко и в специфических ситуациях. Основной способ — выражения new/delete. Факт: new и delete внутри себя вызывают именно эти функции.

Не из лекции

std::malloc и std::free объявлены в <cstdlib>. Память от malloc освобождают только free, от new — только delete; смешивать нельзя.

11. Ошибки при работе с new

new создаёт объект и возвращает указатель на него. Объект нужно разрушить через delete, после этого обращаться к нему нельзя. Две классические ошибки:

ОшибкаНазваниеКак бороться
обратились к объекту после deleteuse after freeне обращаться к объекту после delete («Git gud»)
не вызвали deletememory leakownership и RAII (resource acquisition is initialization)

Владение (ownership) и RAII на лекции только названы — подробно их будут разбирать позже.

12. Кто должен освобождать память

Рассмотрим несколько способов передать объект в функцию. Во всех примерах Node — некоторая структура с полем value; n->value — обращение к полю объекта по указателю.

Не из лекции

В лонгриде Node не определён, а DoSomething вызывается раньше, чем объявлен, — в таком виде примеры не компилируются. Чтобы их собрать, нужно что-то вроде struct Node { int value; }; и объявление void DoSomething(Node* node); перед DoOtherThing. Структуры, auto, -> и функции в курсе ещё не разбирались; здесь важна только логика владения.

Передача указателя на динамический объект

Объект создан через new, поэтому его нужно удалить ровно один раз. Ответственность может остаться у вызывающей функции:

void DoOtherThing() {
  auto n = new Node{};
  n->value = 1;
  DoSomething(n);
  delete n;  // освобождает вызывающая функция
}
 
void DoSomething(Node* node) {
  std::cout << node->value << std::endl;
}

Либо её можно передать вызываемой функции:

void DoOtherThing() {
  auto n = new Node{};
  n->value = 1;
  DoSomething(n);
}
 
void DoSomething(Node* node) {
  std::cout << node->value << std::endl;
  delete node;  // освобождает вызываемая функция
}

Оставлять оба delete нельзя: один объект будет удалён дважды.

Копирование динамического объекта

Можно создать отдельную копию объекта и передать её функции. Тогда каждый объект освобождается отдельно:

void DoOtherThing() {
  auto n = new Node{};
  n->value = 1;
 
  DoSomething(new Node{*n});
  delete n;
}
 
void DoSomething(Node* node) {
  std::cout << node->value << std::endl;
  delete node;
}

Передача указателя на обычный объект

Если объект создан без new, вызывать delete нельзя. Он будет уничтожен автоматически при выходе из блока:

void DoOtherThing() {
  Node n;
  n.value = 1;
  DoSomething(&n);
}
 
void DoSomething(Node* node) {
  std::cout << node->value << std::endl;
}

Терминология

В лонгриде — «при выходе из своей области видимости». В терминах L02 время жизни объекта — это область действия; для автоматической переменной обе заканчиваются на одной закрывающей скобке, но говорим мы здесь о существовании объекта.

Передача объекта по значению

Функция получает копию объекта, копия уничтожается автоматически после завершения функции:

void DoOtherThing() {
  Node n;
  n.value = 1;
  DoSomething(n);
}
 
void DoSomething(Node node) {
  std::cout << node.value << std::endl;
}

Передача с перемещением

std::move тоже используется при передаче по значению. Он разрешает переместить содержимое объекта вместо копирования:

void DoOtherThing() {
  Node n;
  n.value = 1;
  DoSomething(std::move(n));
}
 
void DoSomething(Node node) {
  std::cout << node.value << std::endl;
}

Здесь динамическая память вручную не освобождается: объект и параметры уничтожаются автоматически.

Ответ

  • Кто выделил, тот и освобождает.
  • Если только он не передал владение другому.
  • Если другой сделал себе копию, он освобождает её сам.

Сводка — в Динамическая память.

Грабли

  • 5 = x и x + y = 10 — lvalue required. Слева от присваивания нужен объект, а не результат вычисления.
  • x++ возвращает старое значение копией, поэтому x++ = 1 и &(x++) не компилируются, а --x = 10 и &(++x) — компилируются.
  • sizeof(x = 1) не присваивает. Операнд sizeof не вычисляется.
  • const int* p и int* const p — разные вещи. Читать справа налево.
  • Неинициализированный указатель — UB при чтении. Пустой указатель — nullptr, а не 0.
  • &z - &x для разных переменных ничего не значит. Арифметика указателей имеет смысл только внутри массива.
  • Указатель на локальную переменную нельзя использовать после её блока — висячий указатель.
  • new int[13] — массив из 13 элементов, а не число 13; освобождается через delete[].
  • Каждому new — ровно один delete. Ноль — утечка, два — двойное удаление, обращение после — use after free.
  • delete для объекта не из new — UB.

После этой лекции должно получаться

  • объяснить, почему (x = y) = 5 компилируется, а x + y = 10 — нет;
  • определить категорию значения выражения через вопрос «можно ли взять адрес»;
  • предсказать вывод int a = ++x; int b = x++;;
  • объяснить, почему sizeof(++x) не меняет x;
  • прочитать тип int const* const и сказать, что через такой указатель можно делать, а что нельзя;
  • объяснить, чем nullptr лучше 0;
  • найти висячий указатель в коде с вложенным блоком;
  • перечислить три способа получить утечку памяти;
  • по коду с передачей указателя в функцию сказать, кто должен вызвать delete.