JavaRush /Курсы /C++ SELF /std::shared_ptr: refe...

std::shared_ptr: reference counting и стоимость

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

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 должен происходить не из страха, а из необходимости.

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