JavaRush /Курсы /C++ SELF /std::make_shared — контрольный блок и преимущества

std::make_shared — контрольный блок и преимущества

C++ SELF
43 уровень , 4 лекция
Открыта

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
}

Работает? Да.

Но в этом коде есть две идеи, которые нам не нравятся:

  1. Вы напрямую делаете new, а значит где-то рядом в голове должна жить мысль «кто удалит объект?». Да, shared_ptr удалит. Но вы уже успели потрогать сырой указатель и потенциально можете сделать глупость позже (например, повторно завернуть тот же Task* в ещё один shared_ptr).
  2. Реализация 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 выигрывает по умолчанию

Иногда полезно увидеть сравнение не в виде философии, а в виде компактной “шпаргалки”.

Критерий
std::make_shared<T>(...)
std::shared_ptr<T>(new T(...))
Количество аллокаций обычно 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) — иначе программа упадёт, и будет “ой, оно само”.

1
Задача
C++ SELF, 43 уровень, 4 лекция
Недоступна
Карточка задачи
Карточка задачи
1
Задача
C++ SELF, 43 уровень, 4 лекция
Недоступна
Владельцы по блокам
Владельцы по блокам
1
Задача
C++ SELF, 43 уровень, 4 лекция
Недоступна
Выбранная задача
Выбранная задача
1
Задача
C++ SELF, 43 уровень, 4 лекция
Недоступна
Аргумент владельца
Аргумент владельца
1
Опрос
unique_ptr, 43 уровень, 4 лекция
Недоступен
unique_ptr
unique_ptr
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ