1. Знакомимся с std::make_shared
Когда вы впервые видите std::make_shared, мозг часто делает неправильный вывод: «А, это просто удобнее писать, меньше скобочек». Удобнее — да. Но главная причина появления make_shared не про красоту кода. Она про корректность, производительность и даже немного про «не дать программисту случайно отстрелить себе ногу».
Представьте: вы хотите создать объект и сразу отдать его под совместное владение. Самая очевидная (и исторически ранняя) идея — сделать new, а потом завернуть указатель в std::shared_ptr. Вроде бы логично: «создал → завернул». Проблема в том, что вокруг этого сценария есть тонкости: дополнительные выделения памяти, риск сделать два владельца «на один и тот же сырой указатель», и даже неприятные вещи, связанные с тем, что shared_ptr обязан хранить не только адрес объекта.
std::make_shared решает именно эти практические проблемы. Он создаёт объект сразу под управлением shared_ptr и делает это так, чтобы реализация стандартной библиотеки могла работать максимально эффективно.
Контрольный блок shared_ptr
Если смотреть на shared_ptr как на «умный указатель», легко вообразить, что внутри него живёт только адрес объекта — примерно как в T*. Но shared_ptr устроен сложнее: он должен понимать, сколько владельцев существует, и когда пора уничтожать объект. А ещё он должен помнить, как именно уничтожать объект (каким удалятором/стратегией).
Для этого и существует контрольный блок (control block). Это служебная структура в памяти, которую разделяют все копии shared_ptr, указывающие на один и тот же объект. В простейшей модели в контрольном блоке лежат:
- счётчик “сильных” владельцев (сколько shared_ptr сейчас держат объект);
- (обычно) счётчик “слабых” ссылок — он понадобится для std::weak_ptr, но сегодня мы лишь отметим, что место под это предусмотрено;
- информация о том, как уничтожить объект, когда владельцев не останется (deleter), плюс иногда детали про аллокатор.
Важно: сам shared_ptr не обязан содержать все эти данные внутри себя. Обычно shared_ptr хранит два адреса: адрес объекта и адрес контрольного блока. А контрольный блок уже живёт отдельно и разделяется между копиями.
Вот простая схема (не байт‑в‑байт, но по смыслу верно):
flowchart LR
P1["shared_ptr p1"] -->|указывает| Obj["Объект T"]
P1 -->|указывает| CB["Контрольный блок"]
P2["shared_ptr p2 (копия)"] -->|указывает| Obj
P2 -->|указывает| CB
CB --> C1["use_count (shared owners)"]
CB --> C2["weak_count (weak owners)"]
CB --> D["deleter / служебные данные"]
Именно поэтому копирование shared_ptr дешёвое: объект не копируется, контрольный блок не копируется — увеличивается счётчик в контрольном блоке.
Кстати, вокруг make_shared даже были отдельные обсуждения в рабочей истории стандарта (например, про детали разрушения под‑объектов при make_shared). Это хороший индикатор того, что тема не игрушечная: стандартный комитет обсуждает то, что действительно может влиять на корректность программ.
2. Как создать первый shared_ptr
Перед тем как хвалить make_shared, давайте честно сравним два подхода. Начнём с «классики».
Создание через new
Сейчас вы можете написать так:
#include <iostream>
#include <memory>
#include <string>
struct Task {
int id{};
std::string title{};
};
int main() {
std::shared_ptr<Task> p(new Task{1, "Buy milk"});
std::cout << p->id << ": " << p->title << '\n'; // 1: Buy milk
}
Работает? Да.
Но в этом коде есть две идеи, которые нам не нравятся:
- Вы напрямую делаете new, а значит где-то рядом в голове должна жить мысль «кто удалит объект?». Да, shared_ptr удалит. Но вы уже успели потрогать сырой указатель и потенциально можете сделать глупость позже (например, повторно завернуть тот же Task* в ещё один shared_ptr).
- Реализация shared_ptr при таком способе почти неизбежно сделает два выделения памяти: одно под Task, второе под контрольный блок.
Создание через std::make_shared
Теперь тот же смысл, но “по‑современному”:
#include <iostream>
#include <memory>
#include <string>
struct Task {
int id{};
std::string title{};
};
int main() {
auto p = std::make_shared<Task>(Task{1, "Buy milk"});
std::cout << p->id << ": " << p->title << '\n'; // 1: Buy milk
}
Или ещё проще (без промежуточного Task{...}):
#include <iostream>
#include <memory>
#include <string>
struct Task {
int id{};
std::string title{};
};
int main() {
auto p = std::make_shared<Task>(1, "Buy milk"); // вызывает Task{1, "Buy milk"}
std::cout << p->id << ": " << p->title << '\n'; // 1: Buy milk
}
Смысл тот же, но теперь создание объекта и создание контрольного блока — единый «атомарный» шаг на уровне API: вы сразу получаете корректный shared_ptr, и у вас нет стадии «в руках сырой указатель, и я надеюсь, что дальше всё будет аккуратно».
4. Одна аллокация вместо двух
Обычно фраза «одна аллокация вместо двух» звучит как что-то скучное из мира оптимизаций. Но здесь это не только про скорость. Это ещё и про практическое качество программы: меньше вызовов аллокатора, меньше фрагментации памяти, лучше локальность данных. В сумме — меньше “случайной” просадки производительности.
Сравним картинкой.
shared_ptr(new T(...)): чаще всего две независимые области памяти
flowchart TD
A["heap: выделение #1"] --> Obj["Объект T"]
B["heap: выделение #2"] --> CB["Контрольный блок"]
SP["shared_ptr"] --> Obj
SP --> CB
make_shared<T>(...): обычно одна общая область памяти
flowchart TD
M["heap: одно выделение"] --> Both["[Контрольный блок][Объект T] (в одном блоке)"]
SP["shared_ptr"] --> Both
Что это даёт на практике:
Во-первых, меньше аллокаций — меньше накладных расходов. Аллокатор памяти — это не магический бесконечно быстрый автомат. Если вы создаёте тысячи объектов, «лишнее выделение памяти на каждую сущность» начинает быть заметным.
Во-вторых, контрольный блок и объект часто оказываются рядом в памяти. Это повышает шанс, что когда программе нужно обратиться и к объекту, и к счётчику, данные попадут в кэш процессора более удачно. Да, мы не уходим в микро‑архитектуру, но идея простая: «рядом лежит — быстрее читается».
В-третьих, код становится проще для чтения и аудита: по make_shared видно, что вы создаёте объект под shared ownership. Это уже “сигнал намерения”.
5. Практические плюсы и ограничения make_shared
Меньше «сырого» кода — меньше шансов ошибиться
Этот раздел не про скорость, а про вашу будущую спокойную жизнь.
Когда вы пишете:
auto p = std::make_shared<Task>(1, "Buy milk");
вы не видите сырого Task* вообще. Негде ошибиться. Негде “случайно” сохранить указатель куда-то в глобальную переменную. Негде “случайно” сделать второй shared_ptr из того же адреса.
А когда вы пишете:
Task* raw = new Task{1, "Buy milk"};
std::shared_ptr<Task> p1(raw);
std::shared_ptr<Task> p2(raw); // опасно!
вы буквально держите в руках гранату, у которой чека уже где-то рядом на столе. В этом анти‑примере два shared_ptr создадут два независимых контрольных блока, и оба будут считать себя обязанными удалить один и тот же объект. Итог обычно печальный: double-free и очень творческий краш (иногда не сразу, чтобы вам было интереснее).
make_shared не делает вас бессмертным программистом, но сильно сокращает площадь для ошибок.
Шпаргалка: когда make_shared выигрывает по умолчанию
Иногда полезно увидеть сравнение не в виде философии, а в виде компактной “шпаргалки”.
| Критерий | |
|
|---|---|---|
| Количество аллокаций | обычно 1 | обычно 2 (объект + контрольный блок) |
| Риск “сырых” ошибок | меньше (raw pointer не виден) | больше (raw pointer явно участвует) |
| Читаемость намерения | высокая (“создаю shared‑объект”) | средняя (“создаю, потом заворачиваю”) |
| Возможность кастомного deleter | неудобно / не напрямую | напрямую через конструктор shared_ptr |
| Подходит, если объект уже создан где-то | нет (он создаёт новый) | да (можно принять T*, но осторожно) |
Мы ещё не дошли до кастомных удаляторов по курсу, поэтому из таблицы вам важно запомнить главное правило дня: если вы создаёте объект прямо сейчас и хотите shared ownership — начинайте с make_shared.
6. Мини‑планировщик задач: make_shared в приложении
Сейчас сделаем практический кусок. Мы не строим “огромную архитектуру”, но хотим почувствовать, зачем вообще shared‑владение может понадобиться. Возьмём наш условный CLI‑планировщик задач: у нас есть список задач, и есть “выбранная задача”, с которой пользователь работает отдельно (например, смотрит детали). Если “выбранная задача” и “общий список” должны указывать на один и тот же объект, удобнее хранить задачи как shared_ptr<Task>.
Модель задачи
#include <string>
struct Task {
int id{};
std::string title{};
bool done{};
};
Состояние приложения: список задач + выбранная задача
#include <memory>
#include <vector>
struct AppState {
std::vector<std::shared_ptr<Task>> tasks;
std::shared_ptr<Task> selected; // может быть пустым
};
Обратите внимание: selected — это тоже владелец. Если задача выбрана, мы хотим, чтобы она точно жила, пока мы с ней работаем.
Функция добавления задачи: создаём через make_shared
#include <memory>
#include <string>
#include <utility>
std::shared_ptr<Task> add_task(AppState& app, int id, std::string title) {
auto t = std::make_shared<Task>(Task{id, std::move(title), false});
app.tasks.push_back(t);
return t; // отдаём ещё одного владельца вызывающему коду
}
Тут сразу несколько полезных моментов.
Во-первых, make_shared создаёт объект “как надо” и возвращает первый shared_ptr.
Во-вторых, мы кладём этот же shared_ptr в tasks и возвращаем наружу. То есть задача получает двух владельцев: список и тот, кто вызвал add_task (например, чтобы сразу сделать selected = ...).
В-третьих, никаких new — и это хорошо: меньше шансов устроить себе приключение.
Выбор задачи по id: делимся владением, копируя shared_ptr
#include <memory>
std::shared_ptr<Task> find_task_by_id(const AppState& app, int id) {
for (const auto& t : app.tasks) {
if (t && t->id == id) {
return t; // копируем shared_ptr => +1 владелец
}
}
return {}; // пустой shared_ptr
}
А теперь “мини‑main”, который связывает это вместе:
#include <iostream>
#include <memory>
#include <string>
#include <vector>
int main() {
AppState app{};
add_task(app, 1, "Buy milk");
add_task(app, 2, "Read C++ book (the big one)");
app.selected = find_task_by_id(app, 2);
if (app.selected) {
std::cout << "Selected: " << app.selected->title << '\n';
// Selected: Read C++ book (the big one)
}
}
Заметьте важную вещь: find_task_by_id возвращает новый shared_ptr, но на тот же объект. Это и есть “копирование ручки владения”.
7. Типичные ошибки при использовании std::make_shared
Ошибка №1: считать, что make_shared — это “просто сокращение записи”, и продолжать делать shared_ptr<T>(new T(...)) по привычке.
Такое часто случается у новичков: “и так работает же”. Но вы добровольно возвращаете себе лишние аллокации и лишнюю поверхность для ошибок с сырыми указателями. Если объект создаётся здесь и сейчас — make_shared должен стать рефлексом.
Ошибка №2: создавать несколько shared_ptr из одного и того же raw‑указателя.
Это один из самых опасных анти‑паттернов в современном C++. Два shared_ptr, построенные от одного T*, почти наверняка означают два контрольных блока и попытку дважды удалить один объект. make_shared как раз хорош тем, что убирает raw‑указатель из вашего поля зрения, а значит уменьшает шанс “так случайно получилось”.
Ошибка №3: думать, что контрольный блок — это “какая-то внутренняя магия”, про которую можно не знать совсем.
В повседневном коде вы действительно не обязаны помнить детали реализации, но базовая модель должна быть в голове: есть объект и есть контрольный блок со счётчиками. Это помогает понимать, почему копирование shared_ptr увеличивает владельцев, почему объект не уничтожается сразу, и почему “совместное владение” всегда имеет цену.
Ошибка №4: возвращать из функции сырой указатель на объект, которым владеет shared_ptr, “для удобства”.
Такой код часто рождается из желания “сэкономить на копировании” или “упростить интерфейс”. На практике это приводит к тому, что часть кода начинает жить в мире raw‑указателей и уже не читает контракт владения. Если вам нужно вернуть владение — возвращайте shared_ptr. Если вам нужно дать доступ без владения — это отдельный разговор про дизайн API (сегодня мы фиксируемся на создании через make_shared).
Ошибка №5: разыменовывать shared_ptr, не проверяя его на пустоту, если по логике он может быть пустым.
std::shared_ptr умеет быть пустым (nullptr) и это нормальное состояние. Если функция вроде find_task_by_id может не найти задачу, она возвращает пустой shared_ptr, и перед p->field нужно сделать if (p) — иначе программа упадёт, и будет “ой, оно само”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ