1. Зачем и как работает std::shared_ptr
Если честно, std::unique_ptr — это тот самый идеальный «один хозяин, один ключ от квартиры». Он простой, быстрый и понятный. Поэтому логичный вопрос: зачем вообще изобретать std::shared_ptr, если можно просто «передавать unique_ptr по цепочке»? Ведь мы уже умеем std::move, и всё вроде работает.
Проблема появляется, когда объект нужен в нескольких местах одновременно, и ни одно место не может честно сказать: «я главный владелец, я точно живу дольше остальных». Это типичный сценарий для разделяемого состояния: конфигурации приложения, общего кеша, общей модели данных, какого-то «контекста» работы.
Представьте ситуацию в вашем консольном приложении: есть общий AppContext (например, настройки вывода и статистика), и им пользуются и парсер команд, и логика команд, и модуль печати. Каждый модуль живёт «своей жизнью» (по времени, по областям видимости), и вам не хочется протаскивать ссылки через все функции мира, как будто вы несёте пианино через узкий коридор.
Вот тут и появляется идея: «пусть будет объект, у которого несколько владельцев».
Reference counting и контрольный блок
Чтобы shared_ptr работал, ему нужно как-то понять: когда пора уничтожать объект. С unique_ptr всё просто: разрушился unique_ptr — удалили объект. А вот если владельцев много, нужен «учёт владельцев». Это и называется reference counting: счётчик ссылок (на самом деле счётчик владельцев).
Объект существует, пока счётчик владельцев больше нуля.
Появился новый владелец — счётчик увеличился.
Владелец исчез — счётчик уменьшился.
Счётчик стал нулём — объект уничтожается.
Технически рядом с самим объектом хранится служебная структура (обычно её называют контрольный блок): в ней лежит счётчик владельцев и другая внутренняя информация. В этой лекции нам не нужно знать все детали контрольного блока, но важно понимать, что он существует и он тоже стоит памяти и времени.
Для наглядности представим это схемой:
flowchart TD
A["shared_ptr p1
use_count = 1"] --> C["Контрольный блок
owners = 1"]
B["shared_ptr p2
use_count = 2"] --> C
C --> D["Объект T
(жив, пока owners > 0)"]
Обратите внимание: p1 и p2 — это не «два объекта». Объект T один. Владельцев (ручек владения) — несколько.
2. Базовый синтаксис: создаём и копируем shared_ptr
Сейчас будет очень типичная ошибка новичка: думать, что «копирование shared_ptr копирует объект». Это не так. Копируется не объект, а ручка владения. Поэтому полезно прямо в коде увидеть: адрес объекта один, а счётчик растёт.
Начнём с минимального примера:
#include <iostream>
#include <memory>
int main() {
auto p = std::make_shared<int>(10);
std::cout << *p << '\n'; // 10
std::cout << "use_count=" << p.use_count() << '\n'; // use_count=1
}
Здесь std::make_shared<int>(10) создаёт объект int со значением 10 и первого владельца. Функция use_count() показывает, сколько владельцев сейчас существует. Важно: в реальном проекте use_count() обычно используют как диагностику, а не как условие корректности.
Теперь добавим копирование:
#include <iostream>
#include <memory>
int main() {
auto p1 = std::make_shared<int>(5);
auto p2 = p1; // копируем "ручку"
std::cout << "p1.use_count=" << p1.use_count() << '\n'; // p1.use_count=2
std::cout << "p2.use_count=" << p2.use_count() << '\n'; // p2.use_count=2
std::cout << "same value=" << (*p1 == *p2) << '\n'; // same value=1
}
Если вы сейчас подумали «а почему не 1 и 1?» — вы только что поймали главную идею. Оба shared_ptr указывают на один объект и делят владение.
Чтобы добить ощущение «объект один», можно вывести адрес (для простоты — как p.get(); про get() поговорим аккуратно позже, а пока используем его только как «посмотреть адрес»):
#include <iostream>
#include <memory>
int main() {
auto p1 = std::make_shared<int>(7);
auto p2 = p1;
std::cout << "p1.get=" << p1.get() << '\n';
std::cout << "p2.get=" << p2.get() << '\n';
// адреса будут одинаковые
}
3. Время жизни: когда удаляется объект
В прошлых темах мы привыкли: вышли из scope — локальные переменные уничтожились. С shared_ptr это всё ещё верно, но объект под ним уничтожится только тогда, когда исчезнет последний владелец. Это самое важное место, где shared_ptr меняет ваше «чувство времени» в коде.
Сделаем маленький тип, который печатает сообщение в деструкторе:
#include <iostream>
#include <memory>
struct Tracked {
int id{};
~Tracked() { std::cout << "~Tracked(" << id << ")\n"; }
};
int main() {
auto p = std::make_shared<Tracked>(Tracked{1});
std::cout << "created\n"; // created
}
При выходе из main переменная p уничтожится, и поскольку это последний владелец, объект будет удалён. Вы увидите:
created
~Tracked(1)
Теперь добавим второго владельца в отдельном блоке:
#include <iostream>
#include <memory>
struct Tracked {
int id{};
~Tracked() { std::cout << "~Tracked(" << id << ")\n"; }
};
int main() {
auto p1 = std::make_shared<Tracked>(Tracked{2});
{
auto p2 = p1;
std::cout << "inside, count=" << p1.use_count() << '\n'; // inside, count=2
} // p2 уничтожен, но объект ещё жив
std::cout << "after block, count=" << p1.use_count() << '\n'; // after block, count=1
} // p1 уничтожен, теперь владельцев 0 => объект удалён
Ключевое наблюдение: уничтожение p2 не уничтожило объект. Оно лишь уменьшило число владельцев.
Отдельно стоит запомнить, что shared_ptr может быть пустым (то есть хранить nullptr), и это нормальное состояние. Например, вы можете «отпустить владение» через reset():
#include <iostream>
#include <memory>
int main() {
auto p = std::make_shared<int>(123);
p.reset(); // отпустили владение
if (!p) {
std::cout << "empty\n"; // empty
}
}
Важно понимать смысл: reset() уменьшает количество владельцев. Уничтожится ли объект — зависит от того, был ли это последний владелец.
4. Стоимость shared_ptr: память и скорость
Очень хочется сказать: «ну окей, shared_ptr умнее, значит просто лучше». Но в программировании «просто лучше» бывает редко. Обычно это «лучше в одном, хуже в другом». У shared_ptr плата за удобство есть почти всегда: он хранит больше данных и делает больше работы при копировании.
Цена состоит из нескольких частей.
Первая часть — размер самого умного указателя. unique_ptr обычно примерно размером с один сырой указатель (условно: один адрес). shared_ptr обычно хранит как минимум два адреса: адрес объекта и адрес контрольного блока. Точные числа зависят от реализации, но тенденция именно такая.
Давайте посмотрим на sizeof (не как «гарантию стандарта», а как практическое наблюдение на вашем компиляторе):
#include <iostream>
#include <memory>
int main() {
std::cout << "sizeof(unique_ptr<int>)=" << sizeof(std::unique_ptr<int>) << '\n';
std::cout << "sizeof(shared_ptr<int>)=" << sizeof(std::shared_ptr<int>) << '\n';
}
На большинстве 64-битных систем вы часто увидите, что shared_ptr<int> заметно больше. Не пугайтесь: это не «плохо», это просто счёт за функциональность.
Вторая часть — дополнительные выделения памяти. Когда вы создаёте объект под shared_ptr, где-то должен жить контрольный блок. У std::make_shared есть важная оптимизация: он старается сделать это эффективнее. Но для нас достаточно модели: shared_ptr обычно требует больше внутренней «инфраструктуры», чем unique_ptr.
Третья часть — стоимость копирования. Копирование unique_ptr запрещено, зато перемещение обычно очень дешёвое (переложили адрес). Копирование shared_ptr разрешено, но оно не «бесплатное»: при копировании нужно увеличить счётчик владельцев, при уничтожении — уменьшить. И это дополнительные операции.
Если свести к интуитивной таблице:
| Что сравниваем | unique_ptr | shared_ptr |
|---|---|---|
| Сколько владельцев | один | много |
| Копирование | нельзя | можно (увеличивает счётчик) |
| Перемещение | да, обычно дёшево | да, обычно дёшево, но модель сложнее |
| Доп. инфраструктура | минимальная | контрольный блок + счётчики |
| Цена «в голове» | проще | сложнее (нужно думать о совместном владении) |
И вот практическое правило, которое вы должны ощущать «в пальцах»: shared_ptr выбирают не потому, что он «круче», а потому что он нужен по смыслу. Если по смыслу владелец один — берите unique_ptr и радуйтесь жизни.
5. Практический пример: общий контекст приложения
Чтобы shared_ptr не оставался «умным указателем в вакууме», встроим его в маленький фрагмент нашего условного консольного приложения (пусть это будет мини-трекер задач TaskBook). Идея простая: у нас есть общий контекст, который нужен нескольким компонентам одновременно.
Сделаем AppContext, где лежит настройка verbose и счётчик команд. Контекст будет жить, пока жив хоть один «компонент», который им пользуется.
#include <iostream>
#include <memory>
#include <string>
struct AppContext {
bool verbose{false};
int commands_executed{0};
};
struct CommandRunner {
std::shared_ptr<AppContext> ctx;
void run(const std::string& cmd) {
ctx->commands_executed++;
if (ctx->verbose) {
std::cout << "run: " << cmd << '\n'; // run: help
}
}
};
int main() {
auto ctx = std::make_shared<AppContext>();
ctx->verbose = true;
CommandRunner runner{ctx};
runner.run("help");
std::cout << ctx->commands_executed << '\n'; // 1
}
Здесь важно, что ctx лежит и в main, и внутри runner. Это два владельца одного контекста. Контекст будет существовать, пока существует хотя бы один из них.
Обратите внимание на психологический момент: теперь объект контекста может пережить ту область видимости, где он «был создан», если его владельца скопировали куда-то ещё. Это и есть сила shared_ptr, но это же и причина, почему shared_ptr требует дисциплины: время жизни становится менее «очевидным на глаз».
Чтобы посмотреть, что владельцев действительно несколько, можно временно вывести use_count():
#include <iostream>
#include <memory>
struct AppContext { int x{0}; };
int main() {
auto ctx = std::make_shared<AppContext>();
std::cout << ctx.use_count() << '\n'; // 1
auto ctx2 = ctx;
std::cout << ctx.use_count() << '\n'; // 2
}
И ещё раз: use_count() — это хорошая подсказка для обучения и отладки, но плохая основа для «логики программы».
6. Типичные ошибки
Ошибка №1: думать, что копирование shared_ptr копирует объект.
Это очень частая ловушка: вы видите auto b = a; и мозг автоматически вспоминает «копирование значения». Но shared_ptr — это не значение объекта, это «ручка владения». Объект один, просто владельцев стало больше. Если вам нужна копия объекта, вы должны копировать сам объект (например, создать новый через make_shared<T>(*old)), а это уже другое действие и другой смысл.
Ошибка №2: разыменовывать пустой shared_ptr.
shared_ptr может быть пустым: после reset(), после перемещения, или просто потому что вы так его инициализировали. Проверка if (p) — не «слабость», а нормальная гигиена. *p и p->field без проверки — это ставка на удачу, а удача в программировании обычно заканчивается в пятницу вечером.
Ошибка №3: ожидать, что reset() «всегда удаляет объект».
reset() всего лишь делает текущий shared_ptr пустым и уменьшает число владельцев. Если владельцы ещё есть — объект останется жить. Это нужно принять как базовую механику reference counting: объект удаляется только при переходе счётчика в ноль.
Ошибка №4: использовать use_count() как часть бизнес-логики.
Иногда хочется написать что-то вроде: «если use_count() == 1, значит я один владелец, значит можно делать X». Это хрупкая логика: счётчик может меняться из-за копий в других местах, из-за оптимизаций, из-за особенностей реализации. use_count() оставьте как диагностический прибор, как термометр.
Ошибка №5: применять shared_ptr «на всякий случай», вместо того чтобы выбрать простой дизайн.
Самая дорогая ошибка — не техническая, а архитектурная: использовать shared_ptr там, где по смыслу владелец один. Вы платите усложнением модели времени жизни и накладными расходами, а выигрыша не получаете. Если вы пока сомневаетесь, начинайте с unique_ptr. Переход к shared_ptr должен происходить не из страха, а из необходимости.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ