JavaRush /Курсы /C++ SELF /Владение через абстракцию — std::unique_ptr<IService&g...

Владение через абстракцию — std::unique_ptr<IService>

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

1. Мотивация: интерфейс + владение

Когда вы пишете учебные примеры, очень хочется сделать так: «у меня есть ConsoleLogger logger; и я просто его вызываю». И это нормально… ровно до момента, пока вы не захотите заменить логгер на другой или протестировать код, подставив «фейковую» реализацию.

Тогда внезапно выясняется, что конкретный тип везде «протёк» в сигнатуры, поля классов и #include, и любое изменение начинает напоминать ремонт ванной комнаты без выключения воды.

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

Что именно означает std::unique_ptr<IService>

Если сказать без пафоса, std::unique_ptr<IService> — это «коробка», которая:

  1. хранит адрес объекта;
  2. считает себя единственным владельцем этого объекта;
  3. при уничтожении вызывает 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% случаев Что важно помнить
void f(ILogger& log)
«использую, но не владею» объект должен жить дольше вызова
void f(const ILogger& log)
«использую только для чтения/вызовов const» часто лучший выбор для «потребителя»
void f(ILogger* log)
«использую, но объект может отсутствовать» нужна проверка на nullptr
void f(std::unique_ptr
      
        log)
      
«забираю владение» в месте вызова почти всегда нужен std::move
std::unique_ptr
      
        make()
      
«создаю и отдаю владение наружу» это уже близко к фабрике (подробно позже)

Мы сегодня фокусируемся на вариантах с 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, должны жить очень недолго — обычно в рамках выражения или короткого вызова функции.

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