1. Мотивация: интерфейс + владение
Когда вы пишете учебные примеры, очень хочется сделать так: «у меня есть ConsoleLogger logger; и я просто его вызываю». И это нормально… ровно до момента, пока вы не захотите заменить логгер на другой или протестировать код, подставив «фейковую» реализацию.
Тогда внезапно выясняется, что конкретный тип везде «протёк» в сигнатуры, поля классов и #include, и любое изменение начинает напоминать ремонт ванной комнаты без выключения воды.
Владение через абстракцию решает это очень практично: клиентский код зависит от возможностей (интерфейса), а не от конкретного класса. При этом вопрос «кто удаляет объект» закрывается автоматически: владелец — unique_ptr.
Что именно означает std::unique_ptr<IService>
Если сказать без пафоса, std::unique_ptr<IService> — это «коробка», которая:
- хранит адрес объекта;
- считает себя единственным владельцем этого объекта;
- при уничтожении вызывает delete (и, значит, должен корректно попасть в нужный деструктор).
Важно, что unique_ptr может быть пустым (не хранить объект) — это нормальное состояние, а не «сломалось». Стандартная библиотека даже отдельно обсуждает требования и дефекты, связанные с дефолтной конструируемостью unique_ptr, то есть с тем, что он может создаваться «пустым» по умолчанию.
Также у unique_ptr есть конструктор от nullptr (то есть можно явно сказать «пока пусто»), и в рабочих документах стандарта отдельно упоминаются правки, связанные с unique_ptr(nullptr_t) и его гарантиями.
Давайте зафиксируем это в маленьком коде.
#include <memory>
int main() {
std::unique_ptr<int> p1; // пустой
std::unique_ptr<int> p2 = nullptr; // тоже пустой
// if (p1) { ... } // false
}
Почему нельзя просто хранить IService* и «ну мы же аккуратно delete сделаем»
В этом месте обычно звучит легендарная фраза: «Да я аккуратно, честно!» — и где-то вдалеке начинает смеяться неопределённое поведение.
Проблема сырого указателя (IService*) не в том, что он «плохой». Он просто ничего не обещает. По типу IService* невозможно понять:
- кто владеет объектом;
- кто обязан его удалить;
- можно ли хранить этот указатель долго;
- может ли он быть nullptr;
- и не удалит ли его кто-то ещё раньше.
Именно из-за этого в реальных проектах появляются «висячие указатели», двойные удаления и утечки. std::unique_ptr — это способ «вшить» в тип обещание: владелец один.
2. Практический пример: TaskPad и логирование
Мини-приложение TaskPad и сервисы
Чтобы не улетать в абстракции уровня «представьте сферического коня в вакууме», будем продолжать условное учебное приложение: маленький CLI-менеджер задач TaskPad.
Ранее (по курсу) мы уже моделировали данные через struct, хранили их в std::vector, печатали, делали простые команды. Сейчас мы хотим добавить сервисы вокруг приложения: например, логирование. Но мы хотим, чтобы приложение не зависело от того, куда логировать: в консоль, «в никуда» (для тестов) или в строковый буфер.
Значит, нам нужен интерфейс ILogger (по сути, IService), а владеть реализацией будет TaskApp.
Интерфейс должен удаляться правильно: виртуальный деструктор
Перед тем как писать unique_ptr<ILogger>, мы обязаны сделать базовый тип корректным для полиморфного удаления. Иначе вы удалите объект через базовый указатель — и деструктор наследника может не вызваться (а это почти гарантированная утечка ресурсов и/или поломка инвариантов).
#include <string_view>
struct ILogger {
virtual ~ILogger() = default; // критически важно
virtual void info(std::string_view msg) = 0;
};
Две реализации: «в консоль» и «тихий режим»
Теперь сделаем две маленькие реализации. Они будут короткими специально: нам сейчас важно не «наворотить функционала», а увидеть механику владения.
#include <iostream>
#include <string_view>
struct ConsoleLogger : ILogger {
void info(std::string_view msg) override {
std::cout << "[info] " << msg << '\n'; // [info] ...
}
};
struct NullLogger : ILogger {
void info(std::string_view) override {
// ничего не делаем — полезно для тестов и “тихого режима”
}
};
3. Встраиваем сервис: std::unique_ptr<ILogger> в TaskApp
Поле std::unique_ptr<ILogger> и конструктор, который «забирает владение»
Здесь начинается главная тема лекции. Мы хотим, чтобы TaskApp владел логгером. Значит, логгер должен жить столько же, сколько живёт TaskApp. Идеально подходит std::unique_ptr.
#include <memory>
class TaskApp {
public:
explicit TaskApp(std::unique_ptr<ILogger> logger)
: logger_(std::move(logger)) {}
void run_demo() {
if (logger_) {
logger_->info("TaskApp started");
}
}
private:
std::unique_ptr<ILogger> logger_;
};
Обратите внимание на две детали, которые на практике спасают нервы.
- Во-первых, конструктор принимает std::unique_ptr<ILogger> по значению. Это почти всегда означает: «я забираю владение».
- Во-вторых, мы делаем std::move(logger) в поле. Это буквально «передача ключей от квартиры»: после этого параметр logger становится пустым, а владелец — TaskApp.
Сборка всего в main: создаём конкретный объект, храним через интерфейс
Теперь соединим всё вместе. Объект конкретного класса создаём через std::make_unique<Concrete>(), а сохраняем — как unique_ptr<Interface>.
#include <memory>
int main() {
auto logger = std::make_unique<ConsoleLogger>();
TaskApp app(std::move(logger));
app.run_demo(); // [info] TaskApp started
// logger теперь пустой unique_ptr
}
Здесь происходит важный «момент просветления»: TaskApp вообще не обязан знать, что там был ConsoleLogger. Он знает только контракт ILogger.
Как читать сигнатуры: «кто владеет» прямо по типу
В C++ хороший стиль API часто читается как дорожные знаки: по типу параметра вы понимаете, что от вас хотят. Ниже небольшая таблица-расшифровка (она реально помогает новичкам перестать путаться):
| Сигнатура | Что означает в 90% случаев | Что важно помнить |
|---|---|---|
|
«использую, но не владею» | объект должен жить дольше вызова |
|
«использую только для чтения/вызовов const» | часто лучший выбор для «потребителя» |
|
«использую, но объект может отсутствовать» | нужна проверка на nullptr |
|
«забираю владение» | в месте вызова почти всегда нужен std::move |
|
«создаю и отдаю владение наружу» | это уже близко к фабрике (подробно позже) |
Мы сегодня фокусируемся на вариантах с unique_ptr, потому что это как раз про владение.
Контракт на nullptr: можно ли хранить «пустой сервис»
Когда вы храните std::unique_ptr<ILogger> logger_;, вам нужно честно ответить на вопрос: а может ли логгер отсутствовать?
Иногда ответ «да» — и это нормально. Например, в NullLogger вы делаете «реализацию, которая ничего не делает», и тогда logger_ всегда не пустой. Это часто даже лучше, потому что убирает if (logger_) по всему коду.
Но иногда вы хотите разрешить именно «сервис может отсутствовать» (например, если создание сервиса может не удаться). Тогда unique_ptr как раз поддерживает пустое состояние: дефолтная конструкция и установка nullptr — легальные способы выразить «пока сервиса нет».
С точки зрения стиля, если nullptr допустим, проверка должна быть максимально рядом с использованием, чтобы не было «разыменования в пустоту».
void TaskApp::run_demo() {
if (logger_) {
logger_->info("TaskApp started");
}
}
Если сервис нужен многим: «использую, но не владею»
Очень частая ситуация: сервисом владеет приложение (TaskApp), а пользоваться им хотят разные функции. Тогда эти функции не должны принимать unique_ptr, иначе получится «каждый забирает владение себе», и через пару вызовов сервис исчезнет.
Правильный вариант — принимать ссылку или указатель без владения.
#include <string_view>
void print_banner(ILogger& log) {
log.info("=== TaskPad ===");
}
А вызываем так:
void TaskApp::run_demo() {
if (!logger_) return;
print_banner(*logger_);
logger_->info("TaskApp started");
}
Обратите внимание на аккуратный стиль: раз мы допускаем nullptr, мы проверили, а потом разыменовали *logger_ и передали как ILogger&.
Коллекция разных сервисов: std::vector<std::unique_ptr<IService>>
Иногда вы хотите хранить не один сервис, а набор однотипных «плагинов»: например, несколько «обработчиков», несколько «валидаторов», несколько «экспортёров». В рамках этой лекции мы не делаем настоящую плагинную систему, но идею показать можно.
#include <memory>
#include <vector>
struct IStartupAction {
virtual ~IStartupAction() = default;
virtual void run() = 0;
};
struct HelloAction : IStartupAction {
void run() override { /* ... */ }
};
int main() {
std::vector<std::unique_ptr<IStartupAction>> actions;
actions.push_back(std::make_unique<HelloAction>());
for (auto& a : actions) {
a->run();
}
}
Здесь важно почувствовать «магию без магии»: вектор хранит указатели на базовый тип, но реально внутри могут быть разные наследники. А unique_ptr гарантирует, что всё будет удалено корректно, когда вектор разрушится.
Схема владения: кто за что отвечает
Чтобы мозг меньше «проскальзывал» на теме владения, полезно рисовать такие схемы. Представим нашу ситуацию с TaskApp и логгером:
flowchart TD
Main["main()"]
App["TaskApp (owner)"]
LoggerPtr["unique_ptr<ILogger> logger_"]
Impl["ConsoleLogger (concrete object)"]
Main -->|"std::make_unique<ConsoleLogger>()"| LoggerPtr
LoggerPtr -->|"std::move"| App
App --> LoggerPtr
LoggerPtr --> Impl
Читать это надо так: TaskApp владеет unique_ptr, а unique_ptr владеет реальным объектом. Все остальные могут пользоваться через ILogger&/ILogger*, но не должны решать судьбу памяти.
Почему это называется «владение через абстракцию»
std::unique_ptr<IService> — это когда вы говорите: «Мне не важно, какой конкретно сервис это будет. Мне важно, что он умеет (интерфейс). Но жить он должен ровно столько же, сколько мой объект-владелец. И удаляться должен автоматически».
Это один из самых полезных практических паттернов в C++: он одновременно снижает связанность кода и делает управление временем жизни предсказуемым.
4. Типичные ошибки
Ошибка №1: у интерфейса нет виртуального деструктора.
Это выглядит безобидно, потому что «ну я же ничего не удаляю руками». Но unique_ptr удаляет руками за вас. Если у базового типа нет virtual ~IService(), то удаление через базовый указатель может не вызвать деструктор наследника. Итог — утечки ресурсов и очень странные «полуполомки», особенно если в наследнике были поля-владельцы.
Ошибка №2: попытка копировать std::unique_ptr.
Новички часто пишут auto b = a и удивляются ошибке компиляции. Это не каприз, а защита: unique_ptr обещает единственного владельца, значит копирование запрещено. Правильная операция — перемещение (std::move). И полезно привыкнуть к мысли: если вы сделали std::move, старый указатель становится пустым — и это нормально.
Ошибка №3: передают std::unique_ptr<IService>& «чтобы не копировать» там, где нужно именно владение.
Такое часто делают «из экономии», но логически это ломает смысл: по ссылке вы не забираете владение, вы просто получили доступ к чужому владельцу. В итоге непонятно, кто теперь отвечает за время жизни. Если функция должна забрать объект себе — параметр должен быть std::unique_ptr<IService> по значению, и в месте вызова должен быть std::move.
Ошибка №4: разыменовывают unique_ptr, не проверив на пустоту, хотя nullptr разрешён контрактом.
Если в вашем дизайне сервис может отсутствовать, то logger_->info("...") без проверки — это прямой путь к аварийному завершению. Либо запретите nullptr дизайном (используйте NullLogger), либо проверяйте указатель перед использованием, особенно на «краях» системы — в main, при разборе аргументов и при создании зависимостей.
Ошибка №5: достают сырой указатель через get() и где-то его «складируют».
ptr.get() — полезная штука, но это не новый владелец. Если вы сохранили ILogger* raw = logger_.get() в поле другого объекта, а потом logger_ переехал (move) или умер — raw станет висячим. Правило простое: сырые указатели, полученные из unique_ptr, должны жить очень недолго — обычно в рамках выражения или короткого вызова функции.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ