1. Граница владения
Перед тем как нажимать на кнопки get(), reset() и release(), полезно остановиться на полсекунды и задать себе один взрослый вопрос: «кто будет владельцем ресурса после этой операции?».
В C++ ошибки владения редко выглядят как «красивое сообщение об ошибке» — чаще это либо утечки, либо внезапные падения, либо «вроде работает, но иногда» (что особенно страшно).
Представьте, что ресурс — это единственный ключ от квартиры. unique_ptr — человек, который этот ключ хранит и обязуется закрыть квартиру, когда уйдёт. Тогда get() — это «дать ключ посмотреть, но не отдавать», reset() — это «выкинуть ключ и закрыть квартиру (или поменять квартиру)», а release() — это «отдать ключ навсегда другому человеку и больше не отвечать за квартиру». И вот на последнем пункте люди обычно драматично ошибаются.
Нарисуем простую блок‑схему выбора операции:
flowchart TD
A["Хочу что-то сделать с unique_ptr"] --> B{"Нужно ли сменить владельца?"}
B -->|Нет, просто доступ| C["get() (наблюдение)"]
B -->|Да| D{"Кто будет владеть после операции?"}
D -->|Никто: ресурс должен исчезнуть или быть заменён| E["reset() (уничтожить/заменить)"]
D -->|Кто-то другой, но не unique_ptr| F["release() (отдать наружу) + немедленный план владения"]
Главная мысль: get() не меняет владельца, reset() уничтожает (или заменяет) ресурс у текущего владельца, release() делает владельцем… кого-то снаружи, и это опасно.
2. get(): «дай посмотреть»
Когда вы впервые видите p.get(), рука может потянуться воспринимать это как «ну это же указатель, значит можно с ним делать всё». И вот тут C++ хитро улыбается.
get() действительно возвращает T*, но по смыслу это временный наблюдатель: доступ к объекту даётся, а ответственность за освобождение остаётся у unique_ptr.
Типичный сценарий get() — у вас есть функция, которая исторически принимает сырой указатель (или так удобнее выразить “nullable”), но не должна удалять объект. Тогда вы передаёте p.get(), и всё хорошо — пока вы не пытаетесь сделать из этого указателя второго владельца или хранить его «на потом».
Мини‑пример: у нас есть “сессия” приложения, которую мы печатаем. Печать не должна владеть сессией — она просто читает данные.
#include <iostream>
#include <memory>
#include <string>
struct Session {
std::string user;
};
void print_session(const Session* s) {
if (!s) { std::cout << "no session\n"; return; } // no session
std::cout << "user = " << s->user << '\n'; // user = Alice
}
А теперь использование get():
#include <memory>
int main() {
auto sess = std::make_unique<Session>(Session{"Alice"});
print_session(sess.get());
}
Обратите внимание: print_session принимает const Session*, то есть «могу получить nullptr». Это хороший контракт для “наблюдения”. А sess остаётся владельцем.
Теперь важный анти‑пример, который компилируется, выглядит невинно, но логически преступен:
#include <memory>
int main() {
auto a = std::make_unique<int>(5);
std::unique_ptr<int> b(a.get()); // ОПАСНО: второй владелец!
}
Почему это плохо: и a, и b думают, что они владельцы одного и того же int. Когда оба разрушатся — будет двойное освобождение. То есть вы своими руками уничтожили смысл unique_ptr.
Ещё одна “тихая” ловушка get() — хранить указатель дольше, чем гарантирована жизнь владельца. Если вы взяли T* raw = p.get(), а потом где-то раньше времени сделали p.reset(), то raw теперь смотрит в «уже освобождённую память». Указатель не станет автоматически nullptr, потому что он «не умный» (он просто адрес).
3. reset(): уничтожить или заменить ресурс
reset() — это операция, которая чаще всего ощущается как «навести порядок». Она нужна, когда владелец должен либо отпустить ресурс (стать пустым), либо заменить ресурс на другой.
И это звучит просто — но полезно держать в голове: reset() не просто меняет адрес внутри unique_ptr, он ещё и запускает уничтожение старого объекта (если он был).
В человеческом смысле reset() отвечает на вопрос: «я, как владелец, больше не хочу владеть этим объектом». И тогда объект уничтожается прямо сейчас, а не «когда-нибудь потом». Поэтому reset() — хороший инструмент, когда вы хотите заранее освободить ресурс (например, чтобы память освободилась до конца функции).
Мини‑пример: у нас есть опциональная сессия. По команде “logout” мы хотим удалить сессию.
#include <iostream>
#include <memory>
#include <string>
struct Session { std::string user; };
int main() {
auto sess = std::make_unique<Session>(Session{"Alice"});
sess.reset(); // сессия уничтожена, sess пустой
std::cout << std::boolalpha << !sess << '\n'; // true
}
Теперь вариант «замена ресурса». Представим, что “login” пересоздаёт сессию.
#include <memory>
#include <string>
struct Session { std::string user; };
int main() {
auto sess = std::make_unique<Session>(Session{"Alice"});
sess = std::make_unique<Session>(Session{"Bob"}); // старый Session уничтожен
}
Технически тут не написан reset(), но по смыслу происходит то же самое: старый ресурс освобождён, новый принят. Можно писать и так:
#include <memory>
#include <string>
struct Session { std::string user; };
int main() {
auto sess = std::make_unique<Session>(Session{"Alice"});
sess.reset(new Session{"Bob"}); // рабочий вариант, но make_unique обычно лучше
}
Почему std::make_unique лучше — мы обсуждали раньше: он делает намерение понятнее и меньше провоцирует «ручные куски» управления.
Важно понять эффект: после reset() или замены, все “наблюдатели” (сырые T*, полученные через get() ранее) становятся потенциально опасными. Не потому что reset() плохой, а потому что вы поменяли/уничтожили объект, а наблюдатели об этом не знают.
4. release(): отдать владение наружу
release() — самая коварная из трёх операций. Она выглядит как «почти get()», но делает противоположное по смыслу.
Если get() даёт сырой указатель без передачи владения, то release() отдаёт сырой указатель с передачей владения: unique_ptr перестаёт быть владельцем и становится пустым (nullptr).
То есть после release() ответственность за delete возвращается… к вам. Да, к тому самому “вам”, который пришёл в unique_ptr именно потому, что не хотел думать про delete.
Поэтому правило простое: release() используем только тогда, когда вы точно знаете, кто станет следующим владельцем, и обычно — немедленно.
Мини‑пример “показать механику”:
#include <iostream>
#include <memory>
int main() {
auto p = std::make_unique<int>(10);
int* raw = p.release(); // p пустой
std::cout << (p == nullptr) << '\n'; // 1
delete raw; // теперь мы обязаны удалить сами (иначе утечка)
}
В реальном “современном” коде так делать не хочется. Более безопасный сценарий — вы сделали release() только чтобы тут же передать владение другому владельцу (например, если у вас есть API, принимающий T* и обещающий “теперь я владелец”).
Например, мы можем сделать функцию “принять владение и завернуть обратно в unique_ptr”, чтобы на границе “старого/нового мира” не держать сырые указатели долго:
#include <memory>
std::unique_ptr<int> adopt(int* p) {
return std::unique_ptr<int>(p);
}
int main() {
auto a = std::make_unique<int>(7);
auto b = adopt(a.release()); // владение переехало, a пустой
}
Смысл правильный: сырой указатель существует очень коротко и сразу снова оказывается под RAII‑владельцем.
Ещё раз: release() — это не “получить указатель”. Это “отдать владение”.
Шпаргалка: get() vs reset() vs release()
Когда голова устала, таблица спасает. Здесь важно не «как выглядит», а «что становится с владельцем» и «кто отвечает за удаление».
| Операция | Что возвращает | Меняет ли владельца? | Состояние unique_ptr после | Кто удаляет объект |
|---|---|---|---|---|
|
|
Нет | Не меняется | Всё ещё unique_ptr |
|
ничего | Да (владелец меняет/удаляет ресурс) | Обычно (или новый ресурс) |
unique_ptr удаляет старый ресурс сам |
|
|
Да (владение уходит наружу) | Становится |
Тот, кто получил (вручную или новый владелец) |
Эта логика хорошо укладывается в «правило одного владельца»: get() владельца не добавляет, reset() владельца не добавляет (владелец тот же, просто ресурс меняется), а вот release() буквально говорит: «теперь владельца внутри unique_ptr нет».
5. Практический пример: ToDo‑сессия и границы владения
Чтобы тема не оставалась чисто теоретической, давайте продолжим собирать наше учебное консольное приложение (маленький ToDo/заметки). Мы не уходим в ООП, поэтому сделаем простые struct и функции.
Начнём с данных: задачи и сессия.
#include <string>
#include <vector>
struct Task {
std::string text;
bool done{};
};
struct Session {
std::string user;
std::vector<Task> tasks;
};
Теперь сделаем две функции “наблюдателя”: одна печатает сессию, другая печатает количество задач. Они принимают const Session*, потому что сессии может не быть (например, пользователь не вошёл).
#include <iostream>
void print_user(const Session* s) {
if (!s) { std::cout << "guest\n"; return; } // guest
std::cout << s->user << '\n'; // Alice
}
void print_task_count(const Session* s) {
if (!s) { std::cout << 0 << '\n'; return; } // 0
std::cout << s->tasks.size() << '\n'; // 2
}
А теперь в main создадим unique_ptr<Session> и будем безопасно отдавать “посмотреть” через get():
#include <memory>
int main() {
std::unique_ptr<Session> sess; // пусто: не залогинен
print_user(sess.get());
sess = std::make_unique<Session>(Session{"Alice", {{"Buy milk", false}}});
print_user(sess.get());
}
Здесь get() — идеальный инструмент: мы не передаём владение, мы просто даём доступ для чтения.
Теперь добавим “logout”, где естественно использовать reset():
#include <iostream>
#include <memory>
int main() {
auto sess = std::make_unique<Session>(Session{"Alice", {}});
sess.reset(); // удалили сессию
std::cout << std::boolalpha << !sess << '\n'; // true
}
Смысл простой: владелец тот же (sess), но он стал пустым, а объект уничтожен.
И наконец — ситуация, когда кто-то “снаружи” хочет забрать владение. В нормальном современном дизайне лучше бы принимать unique_ptr напрямую, но допустим, у нас есть условно “legacy API”:
#include <iostream>
void legacy_take_session(Session* s) {
// Представим, что legacy-система обещает: "я удалю s сама"
std::cout << "legacy got session\n"; // legacy got session
delete s;
}
Тогда граница выглядит так:
#include <memory>
int main() {
auto sess = std::make_unique<Session>(Session{"Alice", {}});
legacy_take_session(sess.release()); // отдали владение наружу
// sess теперь пустой, и это нормально
}
Здесь release() оправдан: вы действительно отдаёте владение наружу, и следующий владелец явно определён (legacy‑функция). Но если legacy‑функция не удалит объект — будет утечка. Если она удалит, а вы потом попробуете удалить ещё раз — будет double‑free. Поэтому release() требует дисциплины и ясного контракта.
6. Типичные ошибки
Ошибка №1: делать второго владельца из get().
Это одна из самых неприятных логических ошибок, потому что выглядит “естественно”: вы получили T* и завернули его в новый unique_ptr. Проблема в том, что первый unique_ptr никуда не делся и продолжает считать себя владельцем. В результате два владельца попытаются освободить один ресурс. Правильная мысль такая: get() — только посмотреть, владельца не создаём.
Ошибка №2: использовать сырой указатель после reset() или замены ресурса.
Часто это происходит случайно: вы сохранили Task* t = ...get(), потом где-то выше по коду сделали reset() (или просто присвоили новый unique_ptr), и указатель внезапно стал висячим. unique_ptr здесь не виноват: он честно уничтожил объект. Виноваты мы, потому что забыли, что наблюдатель не продлевает жизнь владельца.
Ошибка №3: воспринимать release() как “просто получить T*”.
release() — это “я больше не владею, забирайте и отвечайте сами”. Если после release() вы не передали указатель новому владельцу и не удалили его вручную, утечка почти гарантирована. Поэтому release() используют редко и обычно “на границе” со старым API, где владение выражается сырьём.
Ошибка №4: думать, что после release() unique_ptr всё ещё можно разыменовывать.
После release() unique_ptr становится пустым (nullptr). Он валиден как объект (его можно присвоить заново), но разыменовывать *p или p->field уже нельзя без проверки. Это та же дисциплина, что и после std::move: источник чаще всего становится пустым и должен считаться пустым, пока вы не доказали обратное.
Ошибка №5: смешивать “кто владеет” и “кто пользуется” в одной функции без контракта.
Например, функция принимает Session* и где-то внутри иногда делает delete, а иногда нет. Снаружи невозможно понять, кто отвечает за ресурс. В итоге один вызов даёт утечку, другой — double‑free. Даже если вы вынуждены работать с сырыми указателями, держите чёткое правило: либо функция всегда владеет и всегда удаляет, либо никогда не удаляет (и это должно быть видно из дизайна/названия/документации). unique_ptr как раз помогает сделать такие границы очевиднее.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ