О чём лекция
Первая половина лекции — выражения: что они возвращают, что меняют по пути и почему результату одних операций можно присвоить, а других — нельзя. Отсюда категории значения lvalue и prvalue, инкремент,
sizeofи запятая. Вторая половина — память: у каждого объекта есть адрес, адрес можно хранить в указателе, а объекты можно создавать в динамической памяти, у которой нет ограничений стека, зато есть обязанность вовремя её освободить.
Маршрут
- Присваивание возвращает переменную → Выражения и операторы
- Материален ли результат выражения → Категории значения
- Две формы
++и--→ Инкремент и декремент - Размер без вычисления → sizeof; явное приведение → static_cast
- Адрес объекта и тип, который его хранит → Указатель
- Сдвиг и разность адресов → Арифметика указателей
- Указатель, переживший объект → Висячий указатель
- Объекты, которые живут дольше блока → Динамическая память
- Чего опасаться → Неопределённое поведение
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 вычисляет сумму, но отдельной переменной с этой суммой не
существует. Присваивать можно только объекту, значение которого можно изменить,
а результат обычного вычисления таким объектом не является. Отсюда два вопроса,
на которые отвечает следующий раздел:
- почему результат присваивания ведёт себя как переменная;
- почему результату других операций ничего нельзя присвоить.
2. Выражение: значение и побочный эффект
Выражение (expression) — последовательность операций и их операндов,
задающая некоторое вычисление. Примеры: 2 + 2, x * y, x + 3 * y, x = y,
y *= 10 % x.
Результат работы выражения проявляется в двух местах:
- в возвращаемом значении;
- в побочных эффектах (side effects) — изменениях состояния программы.
У x + y побочных эффектов нет, только значение. У x = 5 есть и то и другое:
в x записывается 5, и выражение возвращает x. Поэтому операции полезно
рассматривать с двух сторон: какое значение возвращают и что меняют по пути.
3. Категория значения
Каждое выражение характеризуется типом возвращаемого значения и категорией значения (value category). С типом всё ясно. Категория отвечает на вопрос: материален ли результат — существует ли он в виде объекта в памяти.
Пусть изначально x == 5, строки выполняются по порядку:
| Выражение | Тип | Побочный эффект | Категория значения |
|---|---|---|---|
x + y | int | нет | не материален |
x = 5 | int | в x хранится 5 | материален (x) |
x *= 2 | int | в 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*** // указатель на указатель на указатель на doubleint 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, после этого обращаться к нему нельзя. Две классические ошибки:
| Ошибка | Название | Как бороться |
|---|---|---|
обратились к объекту после delete | use after free | не обращаться к объекту после delete («Git gud») |
не вызвали delete | memory leak | ownership и 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.