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 (спільні власники)"]
CB --> C2["weak_count (слабкі власники)"]
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, "Купити молоко"});
std::cout << p->id << ": " << p->title << '\n'; // 1: Купити молоко
}
Працює? Так.
Але в цьому коді є дві речі, які нам не подобаються:
- Ви напряму викликаєте 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, "Купити молоко"});
std::cout << p->id << ": " << p->title << '\n'; // 1: Купити молоко
}
Або ще простіше, без проміжного Task{...}:
#include <iostream>
#include <memory>
#include <string>
struct Task {
int id{};
std::string title{};
};
int main() {
auto p = std::make_shared<Task>(1, "Купити молоко"); // викликає Task{1, "Купити молоко"}
std::cout << p->id << ": " << p->title << '\n'; // 1: Купити молоко
}
Зміст той самий, але тепер створення обʼєкта і створення контрольного блока — це єдиний «атомарний» крок на рівні API: ви одразу отримуєте коректний shared_ptr, і не виникає етапу «у мене в руках сирий вказівник, і я сподіваюся, що далі все буде акуратно».
3. Одна алокація замість двох
Зазвичай фраза «одна алокація замість двох» звучить як щось нудне зі світу оптимізацій. Але тут ідеться не лише про швидкість. Це ще й про практичну якість програми: менше викликів алокатора, менше фрагментації памʼяті, краща локальність даних. У підсумку це означає менше випадкових просідань продуктивності.
Порівняймо це на схемі.
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 одразу видно, що ви створюєте обʼєкт для спільного володіння. Це хороший сигнал наміру.
4. Практичні плюси й обмеження make_shared
Менше «сирого» коду — менше шансів помилитися
Цей розділ не про швидкість, а про те, як зробити ваше життя спокійнішим.
Коли ви пишете:
auto p = std::make_shared<Task>(1, "Купити молоко");
ви взагалі не бачите сирого Task*. Тут просто ніде схибити. Ніде випадково зберегти вказівник десь у глобальній змінній. Ніде випадково створити другий shared_ptr з тієї самої адреси.
А коли ви пишете:
Task* raw = new Task{1, "Купити молоко"};
std::shared_ptr<Task> p1(raw);
std::shared_ptr<Task> p2(raw); // небезпечно!
ви буквально тримаєте в руках гранату, а чека вже десь поруч на столі. У цьому антиприкладі два shared_ptr створять два незалежні контрольні блоки, і обидва вважатимуть, що саме вони мають видалити один і той самий обʼєкт. Підсумок зазвичай сумний: double-free і аварійне завершення програми — інколи не одразу, щоб було ще цікавіше.
make_shared не зробить вас безсмертним програмістом, але суттєво зменшує простір для помилок.
Шпаргалка: коли make_shared виграє за замовчуванням
Іноді корисно побачити порівняння не у вигляді філософії, а як компактну шпаргалку.
| Критерій | |
|
|---|---|---|
| Кількість алокацій | зазвичай 1 | зазвичай 2 (обʼєкт + контрольний блок) |
| Ризик помилок із сирими вказівниками | менший (сирий вказівник не зʼявляється) | вищий (сирий вказівник бере участь явно) |
| Читабельність наміру | висока («створюю обʼєкт для спільного володіння») | середня («створюю, а потім обгортаю») |
| Можливість задати власний deleter | незручно / не безпосередньо | безпосередньо через конструктор shared_ptr |
| Підходить, якщо обʼєкт уже створено | ні (він створює новий) | так (можна прийняти T*, але обережно) |
До користувацьких видаляторів ми в межах курсу ще не дійшли, тому з таблиці вам важливо запамʼятати головне правило дня: якщо ви створюєте обʼєкт просто зараз і хочете спільне володіння, починайте з make_shared.
5. Мініпланувальник задач: make_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, "Купити молоко");
add_task(app, 2, "Прочитати велику книгу з C++");
app.selected = find_task_by_id(app, 2);
if (app.selected) {
std::cout << "Вибрано: " << app.selected->title << '\n';
// Вибрано: Прочитати велику книгу з C++
}
}
Зверніть увагу на важливий момент: find_task_by_id повертає ще один shared_ptr до того самого обʼєкта. Саме так і працює спільне володіння: копіюється не обʼєкт, а shared_ptr.
6. Типові помилки під час використання std::make_shared
Помилка № 1: вважати, що make_shared — це просто скорочення запису, і продовжувати писати shared_ptr<T>(new T(...)) за звичкою.
Таке часто трапляється у новачків: «і так працює». Але цим ви добровільно повертаєте собі зайві алокації й зайвий простір для помилок із сирими вказівниками. Якщо обʼєкт створюється тут і зараз, make_shared має стати рефлексом.
Помилка № 2: створювати кілька shared_ptr з одного й того самого сирого вказівника.
Це один із найнебезпечніших антипатернів у сучасному C++. Два shared_ptr, побудовані від одного T*, майже напевно означають два контрольні блоки й спробу двічі видалити один обʼєкт. make_shared якраз і корисний тим, що прибирає сирий вказівник із вашого поля зору, а отже зменшує шанс, що «так випадково вийшло».
Помилка № 3: думати, що контрольний блок — це якась внутрішня магія, про яку можна зовсім не знати.
У повсякденному коді ви справді не зобовʼязані памʼятати всі деталі реалізації, але базова модель має бути в голові: є обʼєкт і є контрольний блок із лічильниками. Це допомагає зрозуміти, чому копіювання shared_ptr збільшує кількість власників, чому обʼєкт не знищується одразу і чому спільне володіння завжди має ціну.
Помилка № 4: повертати з функції сирий вказівник на обʼєкт, яким володіє shared_ptr, «для зручності».
Такий код часто народжується з бажання заощадити на копіюванні або спростити інтерфейс. На практиці це призводить до того, що частина коду починає жити у світі сирих вказівників і вже не читає контракт володіння. Якщо вам потрібно повернути володіння, повертайте shared_ptr. Якщо вам потрібно дати доступ без володіння, це вже окрема розмова про дизайн API. Сьогодні ми зосереджуємося на створенні через make_shared.
Помилка № 5: розіменовувати shared_ptr, не перевіривши його на порожність, якщо за логікою він може бути порожнім.
std::shared_ptr може бути порожнім (nullptr), і це нормальний стан. Якщо функція на кшталт find_task_by_id може не знайти задачу, вона повертає порожній shared_ptr, і перед p->field треба зробити if (p) — інакше програма впаде, а далі почнеться «ой, воно саме».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ