Занятие 14. Умные указатели, функции и лямбды
Занятие 11 закончилось обещанием: «голые new/delete в коде не пишем — delete на всех путях сделают за нас». Сегодня смотрим, кто и как. Заодно закрываем второй сюжет — «поведение как параметр»: от указателей на функции из C до лямбд. Замеры в тексте реальные (Apple M-серия, clang++ -O2).
Проблема: кто зовёт delete?
Голый new вешает на программиста обязанность «delete на каждом пути» — и компилятор не проверяет ни одного (четыре катастрофы занятия 11). RAII из занятия 7 закрывает простые случаи: vector и string владеют своей памятью сами. Но если объект в куче передают между функциями — кто ответственный? По сигнатуре Widget* f(); это не видно.
Владение — это ответственность за удаление. Идея занятия: записать её прямо в тип.
| Сколько владельцев | Тип |
|---|---|
| ровно один | std::unique_ptr<T> |
| несколько равноправных | std::shared_ptr<T> |
| ноль — только смотрю | std::weak_ptr<T> или T* |
Все три живут в <memory> с C++11. Сырой указатель никуда не исчез — он остался в роли «смотреть, но не владеть».
unique_ptr: единственный владелец
Утечка из занятия 11 — и её лекарство
Та же функция, что текла в занятии 11, — но теперь строкой владеет unique_ptr: деструктор указателя зовёт delete на раннем return, при исключении — на любом пути. Это RAII, упакованный в шаблон.
Правила пользования: создавать std::make_unique<T>(аргументы) (слова new в коде нет), дальше как с обычным указателем — *p, p->size(), проверка if (p); сырой адрес «посмотреть» — p.get().
Сколько это стоит? Нисколько
unique_ptr — абстракция за ноль рублей: в памяти это ровно тот же адрес, что у сырого указателя, а «ум» — деструктор — существует только на этапе компиляции. Вызовы -> и * компилируются в те же инструкции. Безопасность не куплена скоростью.
Копировать нельзя. Передать — можно
Два unique_ptr на один объект — это два delete (пожар из занятия 7). Поэтому копия запрещена на компиляции — ошибка, а не UB в рантайме. std::move — «передать ключи»: владельцем становится приёмник, источник обнуляется. Так объект уезжает из функции-фабрики наружу, в вектор, в поле другого объекта — владелец всегда ровно один. (Полная механика move-семантики — отдельное занятие впереди; сегодня достаточно смысла «передача владения».)
Наконец-то честный полиморфный контейнер
Полиморфизм работает только через указатель или ссылку (занятие 7), и раньше это означало «вектор сырых указателей + ручная чистка». Теперь вектор владеет фигурами: очистка вектора удалит каждую. virtual ~Shape обязателен — удаление идёт через указатель на базу.
shared_ptr: совместное владение
Счётчик ссылок: последний гасит свет
Каждая копия увеличивает счётчик в общем контрольном блоке, деструктор — уменьшает; дошли до нуля — последний владелец удаляет объект. Никто не «главный»: владение действительно общее. make_shared кладёт объект и контрольный блок одной аллокацией — и быстрее, и дружелюбнее к кэшу (занятие 8).
Цена общего владения
- Память:
sizeof= 16 байт (указатель на объект + указатель на контрольный блок), плюс сам блок в куче. - Время: счётчик атомарный (владельцы могут жить в разных потоках) — каждая копия и каждый деструктор дороже обычного инкремента.
- Семантика: «владелец кто-то, когда-нибудь удалится» — по коду больше не видно, когда объект умрёт.
Правило курса
По умолчанию — unique_ptr. shared_ptr — только когда несколько владельцев с независимыми временами жизни действительно нужны по задаче, а не «на всякий случай».
Ахиллесова пята: цикл
main() закончился, но счётчики застряли на единице навсегда: подсчёт ссылок видит только «на меня кто-то смотрит» — цикл из владельцев для него невидим (сборщики мусора в других языках циклы находят, подсчёт ссылок — принципиально нет). Типовые сюжеты: дерево с указателем на родителя, двусвязный список, «наблюдатель, который держит наблюдаемого».
weak_ptr: смотрит, но не держит
weak_ptr ссылается на объект, не увеличивая strong-счётчик: жизнь не продлевает. Пользоваться напрямую нельзя — сначала lock():
Наш замер: после reset() обоих владельцев w.expired() честно вернул 1. Рецепт для пар «родитель–ребёнок»: вниз — владение (unique/shared), вверх — наблюдение (weak).
Сводка: кто есть кто
| Тип | Владеет? | Копируется? | Цена | Когда |
|---|---|---|---|---|
T* |
нет (в нашем курсе) | да | 0 | посмотреть на объект, который точно вас переживёт |
unique_ptr<T> |
да, единолично | нет — только move | 0 | по умолчанию для кучи |
shared_ptr<T> |
да, совместно | да, счётчик++ | 16 Б + блок + атомики | настоящее совместное владение |
weak_ptr<T> |
нет | да | 16 Б | наблюдение; разрыв циклов |
Смысл таблицы — не «какой указатель лучше», а то, что сигнатура теперь говорит правду: unique_ptr<T> f() — «забирай и владей», T* f() — «посмотри, но владелец не ты». Компилятор проверяет эту правду на каждом присваивании.
Указатели на функции: код — тоже данные
Машинный код функций лежит в сегменте кода (карта памяти — занятие 3), и у каждой функции есть адрес:
Зачем: поведение как параметр. Классика — qsort из C:
Цена дизайна: void* стирает типы, а каждый вызов cmp — прыжок по указателю, содержимое которого компилятор не видит насквозь. Значит, компаратор не инлайнится — а вызовов при сортировке $10^7$ элементов наберётся порядка $10^8$. И второе ограничение: указатель хранит только адрес — никакого состояния при функции нет.
Лямбда-выражения
Функция на месте использования
Синтаксис: [захват](параметры) { тело }. Под капотом компилятор пишет за вас класс с operator() (функтор) и создаёт его объект: лямбда — это объект с невыразимым типом, потому auto (занятие 11). Лямбда без захватов ([]) конвертируется в обычный указатель на функцию.
Захваты: состояние при функции
[x] — копия в момент создания; [&x] — ссылка; [=] / [&] — «всё, что использую», копией или ссылкой. Замер размеров показывает, что захваты физически лежат внутри объекта-лямбды:
Ловушка захвата по ссылке
Лямбда с [&], пережившая свои переменные, — висячая ссылка (сюжет string_view из занятия 9). Правило: [&] — только если лямбда умирает раньше захваченного. Передали в std::sort и забыли — можно; сохранили «на потом» — только по значению.
Бенчмарк: лямбда против указателя на функцию
Сортировка $10^7$ случайных int:
Один алгоритм-идея, одни данные — 65 % разницы. Дело не в «C медленный»: std::sort — шаблон, тип лямбды известен на компиляции, её тело инлайнится прямо в цикл сортировки, сравнение стоит пару инструкций. qsort зовёт компаратор через непрозрачный указатель: ~$10^8$ настоящих вызовов с передачей аргументов через void*.
Знакомый мотив: constexpr (занятие 11) двигал в компиляцию вычисления — шаблон + лямбда двигают туда диспетчеризацию. Чем больше компилятор знает заранее, тем меньше платит рантайм.
Проверьте себя
-
Что напечатает?
-
Где утечка и как чинить?
-
Напишите компаратор: отсортируйте
vector<pair<string, int>>по убыванию числа, при равенстве — по алфавиту.
Итоги
| Инструмент | Суть | Правило курса |
|---|---|---|
unique_ptr |
единственный владелец, delete в деструкторе, 0 байт сверху | тип по умолчанию для кучи; создавать make_unique |
std::move |
передача владения: источник пустеет | отдавать unique_ptr из фабрик и в контейнеры |
shared_ptr |
счётчик ссылок, последний удаляет | только при настоящем совместном владении |
weak_ptr |
наблюдает, не продлевает жизнь; lock() |
обратные ссылки и разрыв циклов |
| указатель на функцию | адрес кода, поведение как параметр, без состояния | читать; в новом коде — лямбды |
| лямбда | функтор на месте использования, захваты = состояние | компараторы и предикаты; [&] — только вниз по стеку |
Сквозная мысль — продолжение занятия 11: намерение записываем в тип. Владение — в тип указателя, поведение — в объект-лямбду; проверяет и оптимизирует всё это компилятор.
Домашнее задание (сдача через Git)
- Санация утечек: возьмите свои три «катастрофы» из ДЗ занятия 11 и перепишите на
unique_ptr/контейнеры; приложите отчёты leaks до и после (до: N утечек, после: 0). - Дерево с родителем:
Nodeсvector<unique_ptr<Node>>детей и weak-родителем (после перевода узлов на shared — объясните в комментарии, почему родитель именно weak); докажите leaks-ом отсутствие утечек. - Калькулятор-диспетчер: таблица операций
+ − * /сначала на указателях на функции, потом на лямбдах; сравните код в комментарии. - Компараторы: топ-k строк файла по длине (при равенстве — лексикографически) через
std::sortс лямбдой. - * SimpleUnique: свой мини-unique_ptr — конструктор от
T*, деструктор, запрет копий (= delete), move-конструктор; тест с leaks. Правило пяти из занятия 7 в действии.
Следующее занятие — семинар «Префиксные суммы и RMQ»: сумма любого отрезка за $O(1)$ после $O(n)$ подготовки, двумерный вариант и разреженная таблица для минимума. В начале пары — разбор рубежного контроля.