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 владение становится частью типа, то есть частью контракта, который видно прямо по объявлению переменной.
Вот маленькая таблица для сравнения:
| Инструмент | Что хранит | Кто освобождает ресурс | Типичная проблема |
|---|---|---|---|
|
адрес | «кто-то где-то» | утечки, double-free, use-after-free |
|
адрес + контракт владения | сам 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 чаще всего не нужен: наличие выражается самим указателем-владельцем.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ