В ревью на легаси-проекте разгорается спор. Один инженер показывает на сырой указатель и говорит: каждая наносекунда дорога, обойдёмся без обёрток. Второй молча ставит на стол список утечек за квартал. Оба по-своему правы: завернуть все указатели в умные наугад нельзя, но и держать владение на честном слове не выходит. Правда лежит между двумя шаблонами: unique_ptr стоит ровно столько же, сколько сырой указатель, shared_ptr честно платит атомарными операциями за общее владение, а сырые указатели никуда не исчезают, просто меняют роль. Ниже механика обоих шаблонов, сценарии, где без них плохо, и места, где они только мешают.
Сырые указатели остаются в коде только в роли наблюдателей без владения
Каждый указатель в программе играет одну из двух ролей. Либо он владеет объектом и решает, когда тот разрушить, либо просто смотрит на объект, не влияя на его время жизни. Катастрофы легаси-кода начинаются там, где роли смешаны: одно и то же поле то создаёт объект, то заимствует его у соседа, и после пары рефакторингов никто не помнит, кто должен звать delete.
Правила Core Guidelines разводят роли жёстко. Управляющий памятью код не вызывает new и delete руками: создание объекта живёт в make_unique и make_shared, разрушение в деструкторах обёрток. Сырой указатель остаётся легальным гражданином, но только как не владеющий наблюдатель: посмотреть, передать, почитать. Параметр функции, который ничего не хранит, чаще лучше отдать ссылкой, а участок массива структурой span. Указатель нужен там, где объект может отсутствовать, а ссылка обязана быть живой.
Такое разделение не про удобство, а про инвариант: глядя на тип поля, инженер сразу видит владельца. В коде, где обе роли играют сырые указатели, владелец нигде не написан, и это стоит дороже наносекунд.
Как выглядит болезнь без имени в баг-трекере:
class Registry {
Config* cfg; // владеет или просто смотрит?
public:
void attach(Config* c) { cfg = c; }
~Registry() { delete cfg; }
};
Если в attach передали объект со стека, деструктор разрушит чужую память. Если передали кучу, а вызывающий тоже вызвал delete, получится двойное разрушение. Ошибка тихая, ловится под нагрузкой, а не в тестах. Строка вида std::unique_ptr<Config> cfg снимает вопрос сама: кто хранит обёртку, тот и владелец.
Как unique_ptr убирает цену владения и почему он размером с обычный указатель
unique_ptr появился в стандарте C++11 и заменил осуждённый auto_ptr, который к C++17 из языка исчез. Идея проста до прозрачности: у объекта ровно один владелец, обёртка не копируется, а только перемещается, и в деструкторе вызывает delete.
auto session = std::make_unique<Session>();
session->run();
Скобка закрылась, деструктор вызвал удаление, ни одной строчки ручного освобождения. Размер обёртки равен размеру сырого указателя, если deleter не хранит состояния: пустой класс-удалятор сворачивается через оптимизацию пустой базы. Счётчиков нет, второй аллокации нет. В release-сборке разыменование unique_ptr компилируется в тот же машинный код, что и разыменование сырого указателя, а передача по цепочке не добавляет ни инструкции.
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(5.0));
shapes.push_back(std::make_unique<Square>(3.0));
for (const auto& shape : shapes) {
shape->draw();
}
make_unique появился в стандарте C++14, на три года позже самой обёртки, и закрыл классическую дыру: в вызове вида process(std::unique_ptr<Config>(new Config()), load()) исключение из load() оставляло утёкший объект. С make_unique утечка невозможна конструктивно.
Есть и форма для массивов:
std::unique_ptr<int[]> buffer{new int[1024]};
В большинстве случаев вместо ручного массива лучше std::vector, но форма unique_ptr<T[]> нужна на границах с C-библиотеками, которые отдают буферы под delete[].
Обёртка умеет и нестандартные ресурсы. Сишный дескриптор файла закрывается сам:
struct FileCloser {
void operator()(std::FILE* f) const noexcept { std::fclose(f); }
};
std::unique_ptr<std::FILE, FileCloser> log{std::fopen("app.log", "rb")};
Лямбда вместо структуры тоже работает, но у лямбды с захваченным состоянием появляется цена: объект растёт. Кастомный deleter входит в тип unique_ptr, поэтому разные стратегии удаления это разные типы, что важно при хранении в контейнерах.
Ещё одна опора unique_ptr это работа с неполными типами. Идиома PIMPL прячет детали класса из заголовка: в объявлении живёт указатель на структуру, определённую в файле реализации, а деструктор владельца вынесен туда, где тип уже полный. Обёртка поддерживает такой трюк без жонглирования фиктивными удаляторами.
Как shared_ptr делит владение через блок управления и атомарные счётчики
shared_ptr решает другую задачу: владельцев несколько, и неизвестно, кто закончит последним. Внутри обёртки на 64-битной платформе лежат два указателя: на сам объект и на блок управления. Блок это маленькая структура с двумя счётчиками, сильным и слабым, плюс удалятор и аллокатор.
Копирование shared_ptr это атомарный инкремент сильного счётчика, разрушение копии это атомарный декремент. Сильный счётчик дошёл до нуля, вызывается деструктор объекта; слабый дошёл до нуля, освобождается сам блок. Атомарность нужна, потому что копии одного указателя живут в разных потоках, и без неё счётчик терял бы обновления. Счётчики потокобезопасны, а сам объект нет: два потока, меняющие поля под одним shared_ptr, всё равно нуждаются в мьютексе.
auto settings = std::make_shared<Config>();
auto reader = settings;
auto watcher = settings;
Цена счётчика проявляется не в одиночной операции, а в горячем цикле: атомарный инкремент держит кэш-линию, и когда несколько ядер копируют указатели на один объект, они выстраиваются в очередь за одну строку кэша. В обычном коде этого не слышно, в многопоточном профилировщике видно сразу.
Отдельная ловушка: два shared_ptr, построенных из одного сырого указателя, создают два независимых блока управления, и объект разрушается дважды. Грубая ошибка, но в спешке встречается. Правило одно: из сырого указателя shared_ptr рождается единожды, а лучшая профилактика в базе, где обёртки уже введены, полный запрет на подобный синтаксис.
make_shared и цена одной аллокации вместо двух при создании объектов
Конструкция std::shared_ptr<Config>{new Config()} покупает память дважды: сначала объект, потом блок управления. make_shared укладывает и то и другое в один непрерывный кусок. Аллокаций меньше, локальность кэша лучше, фрагментация ниже.
У одной аллокации есть обратная сторона. Пока жива хотя бы одна weak_ptr, слабый счётчик не нулевой, и память общего куска не возвращается системе, даже если сам объект давно разрушен. Для больших объектов с долгоживущими слабыми ссылками это заметно: мегабайтный кэш держит мегабайт занятым ради крошечного блока управления.
Вторая тонкость про типы. У unique_ptr удалятор зашит в тип, у shared_ptr он стёрт: две обёртки с разными удаляторами это один тип std::shared_ptr<Config>. Гибкость стоит косвенного вызова при разрушении. Зато любой shared_ptr<T> собирается из unique_ptr<T>, и передачу единственного владения в общий код легко превратить в со-владение, не трогая объявление поля:
std::unique_ptr<Config> solo = std::make_unique<Config>();
std::shared_ptr<Config> team = std::move(solo);
Обратный путь невозможен: узнав, что владельцев несколько, вернуться к одному нельзя.
Стандарт C++20 добавил специализацию std::atomic<std::shared_ptr<T>>: атомарно заменить указатель целиком, чтобы читатели одного кэша не мешали друг другу. Удобный инструмент для структур без блокировок, но гарантий неблокирующей реализации стандарт не даёт.
weak_ptr для разрыва циклов ссылок и кэшей без продления времени жизни объектов
Общее владение порождает классическую головоломку: два объекта держат shared_ptr друг на друга, сильные счётчики никогда не дойдут до нуля, и оба остаются в памяти навсегда. Родитель и потомок в дереве, субъект и подписчик, кэш и объект в нём. Разрыв цикла делается слабой ссылкой: weak_ptr хранит адрес блока управления, увеличивая только слабый счётчик и не мешая объекту исчезнуть.
std::unordered_map<long, std::weak_ptr<Session>> cache;
auto it = cache.find(42);
if (auto session = it->second.lock()) {
session->refresh();
}
Метод lock() атомарно проверяет сильный счётчик и, если объект жив, отдаёт полноценный shared_ptr, продлевающий его время жизни до конца области видимости. Если объект разрушен, возвращается пустая обёртка, и код честно обрабатывает вариант с исчезнувшим элементом. Кэш на weak_ptr не удерживает объекты силой, он просто помнит, где они лежали.
Второй сценарий слабых ссылок: наблюдение изнутри. Класс, наследующий enable_shared_from_this, получает метод shared_from_this(), который безопасно отдаёт shared_ptr на себя. Захватить такой указатель из конструктора нельзя: объект ещё не подключён к блоку управления, и попытка упадёт.
Для отладки у обёрток есть метод use_count(), показывающий сильный счётчик. Инструмент скорее учебный: в многопоточном коде число устаревает к моменту чтения, поэтому решения на его значении не строят.
Порядок перевода легаси-кода с сырых указателей на умные без правок поведения
Перевод старого модуля на умные указатели выглядит так:
- собрать карту владения: поиск по new, delete и деструкторам даёт список кандидатов;
- каждому указателю назначить роль: единственный владелец, со-владение или наблюдение;
- единоличное владение заменить на unique_ptr и make_unique, со-владение на shared_ptr и make_shared;
- наблюдателям оставить ссылки, span или сырой указатель, который ничего не хранит;
- ресурсы сторонних API обернуть обёртками с кастомными удаляторами: дескрипторы файлов, сокеты, контексты графики;
- включить AddressSanitizer с детектором утечек и прогнать сценарии, в которых раньше текло;
- закрепить правило ревью: вызовы new и delete допустимы только внутри фабрик и make-функций.
Чаще всего перевод буксует на классах с сеттерами, которые получают указатель извне и хранят его в поле: непонятно, владелец это или наблюдатель. Правило простое: объект, не созданный в конструкторе, чужой, и поле должно быть слабым или сырым. Вторая типичная находка: деструктор, вызывающий delete на поле, оставшийся после перехода на unique_ptr. Санитайзер ловит двойное разрушение мгновенно, в отличие от человека.
Где умные указатели действительно замедляют код и где сырые остаются в выигрыше
Список мест, где обёртки реально стоят времени, короткий. Копии shared_ptr в многопоточном горячем коде: атомарность и очередь за кэш-линию. Вторая аллокация, если забыть make_shared. Размер: shared_ptr вдвое толще сырого указателя, и в массиве из десяти миллионов ссылок разница превращается в десятки мегабайт. Всё.
unique_ptr в этом списке не фигурирует: обёртка не добавляет ни байта к размеру, ни инструкции к доступу. Утверждение о медленных умных указателях почти всегда относится к shared_ptr и почти всегда принадлежит людям, у которых тот стоит рядом с вводом-выводом.
Миф о дорогих умных указателях старше обёрток из C++11: в базах двухтысячных общее владение раздавалось самодельными счётчиками с мьютексами, и цена была реальной. Когда владелец один, считать нечего.
Есть и микроскопическая дисциплина на сигнатурах. Функция, которая не хранит объект, принимает ссылку или сырой указатель, а не shared_ptr по значению: лишняя копия это лишний атомарный инкремент на ровном месте. Метод, который кладёт объект в поле или отправляет в другой поток, принимает shared_ptr по значению и перемещает его дальше. Простое правило разгружает и сигнатуры, и профайлер.
Хуже скорости другой враг: shared_ptr как замена размышлений. Конструкция "обернём всё в общее владение, чтобы не думать о времени жизни" не решает задачу, а прячет: объекты живут дольше нужного, деструкторы не вызываются там, где ожидались, и профилировщик показывает горы памяти, привязанные к ссылкам, которых никто не видел. shared_ptr не сборщик мусора, он лишь честно считает копии.
Рабочее правило формулируется одной строкой: unique_ptr по умолчанию, shared_ptr там, где владельцев действительно несколько, weak_ptr там, где наблюдение не должно удерживать объект, а сырой указатель там, где нужно только заглянуть. И ни в каком виде не оставлять владение сырым указателем: каждый ручной вызов delete это строка, которая однажды не выполнится.