JavaRush /Курсы /C++ SELF /Правило выбора: unique_ptr по умолчанию, shared_ptr

Правило выбора: unique_ptr по умолчанию, shared_ptr

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

1. Введение

Когда вы впервые узнаёте про shared_ptr, мозг радостно делает вывод: «О! Теперь я могу больше никогда не думать о времени жизни объектов!». Это примерно как купить большой холодильник и решить, что теперь можно не думать о сроке годности продуктов. Думать всё равно придётся — просто проблемы станут дороже и интереснее.

Причина в том, что shared_ptr решает одну задачу: «когда удалять объект, если владельцев несколько». Но он не делает ваш дизайн автоматически правильным. Более того, если начать «мазать» shared_ptr везде, где страшно, вы получите новый класс ошибок: циклы владения, неожиданные продления времени жизни, непредсказуемые зависимости и API, в котором сложно понять, кто за что отвечает.

Поэтому нам нужна договорённость уровня проекта: по умолчанию используем unique_ptr, а к shared_ptr переходим только тогда, когда действительно есть смысл в совместном владении. И смысл должен быть не «мне так спокойнее», а «по требованиям программы это реально несколько независимых владельцев».

Почему «перейдём на shared_ptr, чтобы не думать» — опасная сделка

Представим типичный сюжет: вы вернули Task* наружу, пользователь выбрал задачу, а потом хранилище удалило задачу — и этот указатель стал висячим. У новичка появляется мысль: «О! Надо сделать shared_ptr, тогда объект будет жить, пока на него кто-то ссылается».

Иногда это действительно решает проблему, но цена может быть выше самой проблемы. Потому что вы поменяли архитектуру: теперь задачи живут «пока где-то кто-то держит». Это может привести к неожиданным эффектам: вы удалили задачу из хранилища, а она продолжает жить, потому что где-то в UI осталась копия shared_ptr. И пользователь потом удивляется: «Почему она всё ещё существует?»

То есть shared_ptr — это не «починка dangling pointer». Это переход к другой модели жизни объектов. Иногда нужной, иногда — случайно выбранной.

2. Быстрая модель ролей: владелец, совладелец и наблюдатель

Перед выбором указателя полезно научиться задавать себе один вопрос: какую роль играет эта ссылка на объект? Для этого достаточно трёх слов: владелец, совладелец, наблюдатель.

Владелец отвечает за время жизни. Пока он жив — объект гарантированно жив. Когда владелец умирает — объект должен быть уничтожен (или владение должно быть передано другому владельцу). Это естественная роль для unique_ptr.

Совладелец тоже отвечает за время жизни, но таких людей несколько. Объект жив, пока жив хотя бы один совладелец. Это shared_ptr.

Наблюдатель имеет доступ «если повезёт»: он может посмотреть на объект, если тот ещё жив, но не имеет права удерживать его в живых. Это weak_ptr (или иногда сырой указатель/ссылка как невладеющий доступ, но сегодня мы держим фокус на связке shared_ptr/weak_ptr).

Эту модель важно не просто знать, а уметь «читать глазами» по типам в коде: тип поля или параметра функции должен подсказывать, кто владеет.

3. Правило №1: начинаем с unique_ptr

Если объект имеет одного очевидного хозяина, std::unique_ptr<T> почти всегда лучший выбор. Здесь выигрывает сразу всё: понимание кода, предсказуемость времени жизни, отсутствие циклов совместного владения, меньше накладных расходов и проще отладка.

На практике unique_ptr — это «указатель-владелец», который прямо говорит: «объект принадлежит мне, и только мне». Если нужно передать владение дальше — вы делаете это явно через std::move. И это очень хорошо: в коде видно место, где ответственность переехала.

Давайте закрепим это на маленьком кусочке нашего приложения. Представим, что мы пишем консольный «менеджер задач» и хотим хранить задачи в памяти. В простейшей модели хранилище владеет задачами.


#include <memory>
#include <string>
#include <vector>

struct Task {
    int id{};
    std::string title;
};

struct TaskStorage {
    std::vector<std::unique_ptr<Task>> tasks;
};

Здесь читать легко: TaskStorage — хозяин всех задач. Никто другой «по умолчанию» не имеет права владеть задачей. Значит, и жизнь задачи понятна: пока она лежит в tasks, она существует.

Как отдавать объект наружу, не отдавая владение

Когда у нас unique_ptr, возникает частый вопрос: «А как тогда отдавать задачу наружу?». Новичок иногда хочет вернуть unique_ptr из findTask() — но тогда хранилище перестанет владеть объектом. Обычно это не то, что вы хотите для поиска.

Часто правильный ответ: хранилище владеет, а наружу отдаём невладеющий доступ. Например, сырой указатель Task* (как «может быть null»), или ссылку Task& (если гарантировано существует), или const Task* / const Task& для чтения.

Пример: ищем задачу и возвращаем невладеющий указатель. Обратите внимание: это не «возвращаем сырое владение», это «даём посмотреть».

#include <memory>
#include <vector>

Task* find_task(std::vector<std::unique_ptr<Task>>& tasks, int id) {
    for (auto& p : tasks) {
        if (p->id == id) return p.get(); // невладеющий указатель
    }
    return nullptr;
}

Здесь важная привычка: .get() — это буквально «дай мне raw-адрес, но владение оставь себе». И это нормальная операция, если вы понимаете, что делаете.

4. Когда shared_ptr действительно оправдан

std::shared_ptr имеет смысл тогда, когда по смыслу программы существует несколько независимых компонентов, каждый из которых вправе сказать: «мне нужно, чтобы объект точно был жив, пока я работаю».

Ключевое слово — независимых. Не «мне лень думать», не «я боюсь», а именно «по требованиям системы объект должен переживать перемещения между частями программы без одного центра владения».

Пример из жизни (без сложных фреймворков): у вас есть объект «Настройки приложения», и им пользуются разные модули. Если эти модули живут независимо и могут переживать друг друга, иногда удобно иметь совместное владение настройками. Но даже тут часто можно обойтись const& к одному владельцу — просто владелец должен быть явно определён.

Более «честный» пример для shared_ptr: у нас есть объект «кешированный результат вычисления», который может быть выдан нескольким частям программы, и каждая часть хочет гарантировать, что результат не уничтожится, пока она его использует.

#include <memory>
#include <string>

struct Report {
    std::string text;
};

std::shared_ptr<Report> make_report() {
    auto r = std::make_shared<Report>();
    r->text = "Weekly report";
    return r; // отдаём долю владения наружу
}

Возврат shared_ptr по значению — это читаемый контракт: «я создаю объект и отдаю его в совместное владение».

Почему shared_ptr дороже, чем кажется

Накладные расходы shared_ptr — не только про производительность. Да, там есть контрольный блок, атомарные операции счётчика (в большинстве реализаций), лишние аллокации и инструкции. Но гораздо больнее другое: дорожает мышление о программе.

С unique_ptr вы почти всегда можете ответить: «кто владеет объектом?» — посмотрев на поле. С shared_ptr ответ часто звучит так: «ну… кто-то». А затем начинается квест: поиск всех копий, понимание, почему объект ещё жив, и почему деструктор не вызвался.

Отдельная важная деталь: use_count() выглядит как спасательный круг («сейчас посмотрю, сколько владельцев!»), но в реальном коде это плохая опора для логики. У use_count() слабые гарантии, и на него нельзя строить корректность многопоточных и тонких сценариев; это скорее диагностический инструмент.

Поэтому shared_ptr — инструмент сильный, но его лучше доставать не из «кармана на каждый день», а из «ящика с инструментами», когда вы уверены, что именно он нужен.

5. weak_ptr: «ссылка нужна», но владение — нет

Если у вас уже есть shared_ptr, то следом почти всегда возникает вопрос: «А как мне сослаться на объект из другого объекта, но не создать цикл владения?». Ответ — weak_ptr.

С точки зрения правил выбора, weak_ptr — это не «третий вариант вместо unique/shared», а дополнение к shared_ptr. Он появляется, когда вы хотите построить навигацию (например, «назад» в двусвязной структуре, или «родитель» у узла), но не хотите делать эту связь владеющей.

Хороший паттерн: «храним weak_ptr, а при использовании делаем lock() один раз и работаем через полученный shared_ptr».


#include <memory>

struct Task;

struct Link {
    std::weak_ptr<Task> selected;
};

struct Task {
    int id{};
};

Здесь Link не удерживает Task в живых. Он просто помнит: «если та задача ещё существует — я смогу получить к ней доступ».

6. Шпаргалка выбора: правило, таблица и мини-схема

Если у объекта есть один очевидный хозяин (контейнер, менеджер, «хранилище», родительский объект) — используем unique_ptr. Если кому-то нужно посмотреть — даём T*, T&, const T*, const T& (в зависимости от контракта).

Если по дизайну нужно несколько независимых хозяев, и у вас нет простого центра владения, который можно сделать владельцем, — тогда shared_ptr.

Если вам нужно «сослаться, но не владеть» в мире shared_ptr — используем weak_ptr.

Чтобы это легче вспоминалось, держите короткую таблицу выбора.

Ситуация в коде Что хочется гарантировать Типичный выбор
«Объект принадлежит контейнеру/хранилищу» Ясный единственный хозяин
std::unique_ptr<T>
«Надо передать владение дальше» Новый хозяин забирает ответственность
std::move(unique_ptr)
«Несколько частей программы обязаны удерживать объект живым» Объект жив, пока он нужен хоть кому-то
std::shared_ptr<T>
«Нужно помнить ссылку, но не продлевать жизнь» Доступ только если объект ещё жив
std::weak_ptr<T> + lock()

Мини-схема принятия решения

Чтобы «не ловить дзен» каждый раз при виде умных указателей, удобно держать в голове один короткий алгоритм принятия решения:

flowchart TD
    A["Нужно хранить/передать ссылку на объект"] --> B{"Кто владеет временем жизни?"}
    B -->|Один явный хозяин| C["unique_ptr у хозяина"]
    C --> D["Наружу: T*/T&/const T& (наблюдение)"]

    B -->|Несколько независимых хозяев| E["shared_ptr у совладельцев"]
    E --> F{"Есть обратные/циклические связи?"}
    F -->|Да| G["Разрываем: weak_ptr в одной стороне"]
    F -->|Нет| H["shared_ptr везде, где нужно владение"]

7. Когда вместо shared_ptr лучше ID, а не указатель

Есть ещё один очень практичный момент: иногда мы тянемся к shared_ptr не потому, что реально нужно совместное владение, а потому что мы храним «долгоживущую ссылку» на объект, который может исчезнуть.

В таких ситуациях часто правильнее хранить не указатель, а идентификатор (например, int id) и каждый раз при необходимости делать поиск. Да, это чуть больше кода, но зато у вас нет скрытого продления жизни объекта и нет вопросов «почему это ещё живо».

Мини-идея для нашего Task-приложения: UI хранит selected_id, а не shared_ptr<Task>.

#include <optional>

struct UiState {
    std::optional<int> selected_task_id;
};

А дальше при выводе или редактировании вы делаете find_task(..., *selected_task_id) и честно обрабатываете nullptr. Это иногда намного проще, чем менять модель владения всего приложения.

8. Типичные ошибки

Ошибка №1: использовать shared_ptr «по умолчанию», потому что так спокойнее.
Это очень человеческая ловушка: хочется надеть «шлем безопасности» на всё. Но shared_ptr — не шлем, а скорее тяжёлая броня: в ней можно ходить, но быстро устанете, а иногда ещё и в дверной проём не пройдёте. Если у объекта есть один хозяин, unique_ptr делает время жизни проще и честнее.

Ошибка №2: пытаться лечить висячие указатели переходом на shared_ptr, не меняя модель.
Иногда проблема не в том, что «нужны совместные владельцы», а в том, что вы храните не то (например, держите указатель на элемент контейнера, который вы потом удаляете). Часто лучше хранить id и искать объект заново, чем делать «вечноживущий» объект через совместное владение.

Ошибка №3: хранить shared_ptr в обе стороны связи и получить цикл владения.
Как только у вас появляются двусторонние связи («родитель–ребёнок», «prev–next», «a ссылается на b и b на a»), риск цикла становится почти автоматическим. Лечится не «где-то вызвать reset», а изменением роли связи: одна сторона должна стать weak_ptr.

Ошибка №4: строить логику программы на use_count().
use_count() может быть полезен для отладки и обучения, но для корректности программы это плохой фундамент: количество владельцев может меняться, а гарантии у этого механизма ограничены. Относитесь к нему как к диагностике, а не как к условию «если владельцев 1 — можно».

Ошибка №5: путать «наблюдение» и «владение» в сигнатурах функций.
Если функция принимает std::shared_ptr<T> по значению, она становится совладельцем — даже если вы «просто хотели распечатать объект». Если владение не нужно, передавайте const std::shared_ptr<T>& (когда именно важен факт shared-владения) или вообще const T& (когда важен только доступ к данным). Чем честнее сигнатура, тем меньше сюрпризов с временем жизни.

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