JavaRush /Курсы /C++ SELF /std::unique_ptr и

std::unique_ptr и std::make_unique

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

1. T* — адрес, но не владелец

Когда вы впервые увидели указатели, они выглядели как суперсила: можно хранить адрес, передавать его в функции, проверять на nullptr. Но у указателя есть неприятная особенность: он слишком честный. Он говорит только «вот адрес», но не говорит «кто должен освобождать память» и «когда это нужно сделать». А без этого контракт владения превращается в игру «угадай, где delete».

Представьте, что T* — это бумажка с адресом квартиры. Бумажка сама по себе не отвечает на вопросы: кто хозяин квартиры, кто платит аренду, и можно ли там вообще жить. Вот ровно так же int* p не говорит, является ли p владельцем памяти (и должен ли он делать delete), или это просто «показать пальцем» на объект, которым владеет кто-то другой.

Давайте посмотрим на минимальный пример проблемы (это антипример, так делать в реальном коде не хочется):

#include <iostream>

int main() {
    int* p = new int(42);
    std::cout << *p << '\n'; // 42

    // Ой. А где delete?
}

Если забыть delete, получится утечка. Если delete написать, но потом кто-то ещё тоже сделает delete по тому же адресу, будет double-free. Если удалить, а потом случайно использовать *p, получите use-after-free. Это всё не «редкие ошибки», это ежедневная практика, если писать на ручной памяти без строгой дисциплины.

2. Что такое std::unique_ptr: RAII и один владелец

Сейчас мы подходим к одной из самых «спасательных» идей modern C++: вместо того чтобы таскать по коду голый адрес и надеяться на аккуратность людей, мы начинаем хранить ресурс в специальном объекте-владельце. Этот объект знает, что он владеет ресурсом, и обязуется освободить его в своём деструкторе. Это и есть RAII в действии: «Resource Acquisition Is Initialization».

std::unique_ptr<T> — умный указатель, который владеет объектом типа T и освобождает его автоматически, когда сам unique_ptr уничтожается. При этом слово unique (уникальный) означает ключевое: владелец один. То есть в каждый момент времени у одного динамического объекта должен быть ровно один unique_ptr, который отвечает за освобождение.

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

Отдельно приятно, что стандартная библиотека давно и последовательно развивает этот подход, включая уточнения гарантий и поведения unique_ptr в стандартных редакциях.

3. Создание через std::make_unique

Первый практический шаг с unique_ptr обычно начинается с двух вещей: подключить заголовок <memory> и перестать писать new руками там, где можно. Да, звучит как «сектантский лозунг», но это та секта, где вместо свечек — более безопасный код.

Правильный идиоматический способ создать unique_ptr — это std::make_unique<T>(...). Он создаёт объект и сразу упаковывает его в unique_ptr. Исторически появление и закрепление make_unique было важным шагом в сторону более безопасного и единообразного кода.

Самый простой пример:

#include <iostream>
#include <memory>

int main() {
    auto p = std::make_unique<int>(42);
    std::cout << *p << '\n'; // 42
}

Обратите внимание на «магию спокойствия»: delete нигде не написан, но память будет освобождена автоматически при выходе из main, потому что p — локальная переменная, и её деструктор будет вызван при выходе из области видимости.

Чуть более «живой» пример со структурой:

#include <iostream>
#include <memory>
#include <string>

struct User {
    std::string name;
    int age{};
};

int main() {
    auto u = std::make_unique<User>(User{"Alice", 30});
    std::cout << u->name << " " << u->age << '\n'; // Alice 30
}

Тут мы уже видим вторую важную деталь: доступ к полям идёт через ->, как у обычного указателя. unique_ptr старается ощущаться как «почти указатель», но с бонусом «я ещё и владею».

4. Доступ, пустота и модель владения

Доступ к объекту: *p и p->field

После создания unique_ptr хочется делать с ним то же, что вы делали с T*: разыменовывать, читать поля, менять поля, передавать дальше. И тут unique_ptr ведёт себя максимально ожидаемо: *p даёт доступ к объекту, а p->field — к полям/методам объекта.

Это удобно, потому что вам не нужно учить «новый синтаксис доступа». В голове остаётся прежняя модель: «там лежит адрес, по нему лежит объект». Просто теперь этот адрес не просто адрес, а адрес в надёжной упаковке с автоматическим освобождением.

Минипример с изменением значения:

#include <iostream>
#include <memory>

int main() {
    auto p = std::make_unique<int>(10);
    *p += 5;

    std::cout << *p << '\n'; // 15
}

И пример с полями структуры:

#include <iostream>
#include <memory>
#include <string>

struct Note {
    std::string text;
};

int main() {
    auto n = std::make_unique<Note>(Note{"hello"});
    n->text += " world";

    std::cout << n->text << '\n'; // hello world
}

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

Пустой unique_ptr и проверка if (p)

Жизнь редко устроена так, что объект «всегда есть». Иногда он появляется позже, иногда он опционален, иногда его создание могло быть пропущено из-за логики программы. Именно поэтому unique_ptr умеет быть пустым — то есть хранить nullptr внутри.

К счастью, проверяется это просто. unique_ptr можно использовать в условии: if (p), и это читается почти как обычная человеческая фраза «если указатель не пустой».

Посмотрим на базовый сценарий: сначала пусто, потом создаём объект.

#include <iostream>
#include <memory>

int main() {
    std::unique_ptr<int> p; // пустой

    if (!p) {
        std::cout << "empty\n"; // empty
    }

    p = std::make_unique<int>(7);

    if (p) {
        std::cout << *p << '\n'; // 7
    }
}

Здесь важно привыкнуть к дисциплине: если «пустота возможна», то перед *p или p->... делаем проверку. Это как пристёгиваться ремнём: да, иногда поездка короткая, но однажды это спасёт нервную систему.

Адрес против владельца: мини-схема

Полезно на секунду остановиться и «нарисовать в голове», что же изменилось. До unique_ptr вы могли хранить адрес, но владение было в воздухе. С unique_ptr владение становится частью типа, то есть частью контракта, который видно прямо по объявлению переменной.

Вот маленькая таблица для сравнения:

Инструмент Что хранит Кто освобождает ресурс Типичная проблема
T*
адрес «кто-то где-то» утечки, double-free, use-after-free
std::unique_ptr<T>
адрес + контракт владения сам unique_ptr в деструкторе нужно помнить про пустоту, не смешивать с ручным delete

И простая блок-схема владения:

flowchart TD
    A[Создали ресурс] --> B{Кто владелец?}
    B -->|T*| C[Непонятно по типу]
    C --> D[Риск: забыли delete / сделали delete дважды]
    B -->|unique_ptr| E[Владелец известен]
    E --> F[Выход из scope -> деструктор -> освобождение]

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

5. Практический пример: TaskBox с опциональными деталями

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

Сделаем так: у Task будет поле details, но не std::string, а std::unique_ptr<TaskDetails>. Тогда nullptr будет означать «деталей нет».

Модели данных

Сначала опишем структуры. Этот кусок кода небольшой, но он задаёт основу всему примеру.

#include <memory>
#include <string>

struct TaskDetails {
    std::string description;
};

struct Task {
    int id{};
    std::string title;
    std::unique_ptr<TaskDetails> details; // либо есть, либо nullptr
};

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

Создание задач: с деталями и без

Теперь добавим две маленькие функции-фабрики. Они возвращают готовый Task (обычный объект), а внутри, если нужно, создают TaskDetails через make_unique.

#include <memory>
#include <string>

Task makeTask(int id, std::string title) {
    Task t;
    t.id = id;
    t.title = std::move(title);
    return t;
}

Task makeTaskWithDetails(int id, std::string title, std::string description) {
    Task t;
    t.id = id;
    t.title = std::move(title);
    t.details = std::make_unique<TaskDetails>(TaskDetails{std::move(description)});
    return t;
}

Здесь мы используем std::move для строк как оптимизацию «не копировать лишнее». Если у вас пока это выглядит чуть туманно — нормально: строки просто «переезжают» внутрь структуры. Глубокую механику переноса мы разберём отдельно, а идея «не дублировать большие строки» интуитивно понятна.

Печать задачи с проверкой details

Теперь напишем печать. Здесь важно аккуратно проверять указатель на пустоту.

#include <iostream>

void printTask(const Task& t) {
    std::cout << "#" << t.id << ": " << t.title;

    if (t.details) {
        std::cout << " (" << t.details->description << ")";
    }

    std::cout << '\n';
}

Смысл читается как русский язык: «если детали есть — допечатай».

Собираем main: несколько задач в std::vector

Теперь соберём минимальный main. Мы пока не делаем интерактивный ввод, чтобы не отвлекаться от темы владения — просто создадим несколько задач и распечатаем.

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

int main() {
    std::vector<Task> tasks;

    tasks.push_back(makeTask(1, "Buy milk"));
    tasks.push_back(makeTaskWithDetails(2, "Study C++", "unique_ptr basics"));

    for (const Task& t : tasks) {
        printTask(t);
    }
}

Ожидаемый вывод:

#1: Buy milk
#2: Study C++ (unique_ptr basics)

Заметьте важную вещь: мы нигде не пишем delete. При этом внутри задач могут быть динамически созданные TaskDetails. Они будут освобождены автоматически, когда будет уничтожен объект Task, а он будет уничтожен, когда vector очистится или выйдет из области видимости. Это и есть RAII в реальной жизни, без лозунгов.

6. Когда срабатывает деструктор: проверяем автоматичность

В учебных примерах иногда сложно поверить «оно правда само?». Поэтому давайте сделаем очень простую диагностику: добавим деструктор в TaskDetails, который печатает сообщение. Да, в реальных проектах вы не печатаете из деструктора (обычно), но для обучения это отличный «фонарик».

#include <iostream>
#include <memory>
#include <string>

struct TaskDetails {
    std::string description;

    ~TaskDetails() {
        std::cout << "TaskDetails destroyed: " << description << '\n';
    }
};

int main() {
    {
        auto d = std::make_unique<TaskDetails>(TaskDetails{"temporary info"});
        std::cout << "Inside scope\n"; // Inside scope
    }
    std::cout << "Outside scope\n"; // Outside scope
}

Ожидаемый вывод будет примерно таким:

Inside scope
TaskDetails destroyed: temporary info
Outside scope

То есть уничтожение произошло ровно на границе блока { ... }. И это ключевой эффект: вы начинаете мыслить не «где бы мне написать delete», а «где заканчивается область жизни владельца». Это переключает мозг в более надёжный режим.

7. Типичные ошибки при знакомстве с unique_ptr

Ошибка №1: разыменование пустого unique_ptr.
Поскольку unique_ptr может быть в состоянии nullptr, выражения *p и p->field без проверки иногда превращаются в падение программы. Это часто случается после рефакторинга: раньше объект всегда создавался, потом добавили условие — и внезапно указатель стал пустым. Если пустота возможна по смыслу, проверка if (p) должна стать привычкой.

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

Ошибка №3: создание unique_ptr через new в прикладном коде «по старой памяти».
Иногда пишут что-то вроде std::unique_ptr<int> p(new int(5));. Технически это может компилироваться, но как стиль это быстро приводит к более сложным ошибкам, особенно когда в выражении появляется несколько ресурсов. Базовая дисциплина простая: создаём через std::make_unique<T>(...), потому что это читабельнее и легче проверять глазами.

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

Ошибка №5: хранить «где-то рядом» отдельный флаг наличия, вместо того чтобы использовать пустое состояние.
Новички иногда делают так: bool hasDetails; TaskDetails* details; и дальше вручную синхронизируют эти два поля. Это почти гарантированно рассинхронизируется со временем. unique_ptr уже несёт в себе идею «есть/нет» (через nullptr), поэтому отдельный bool чаще всего не нужен: наличие выражается самим указателем-владельцем.

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