1. Введение
Когда вы начинаете писать функции, очень хочется, чтобы всё выглядело одинаково: «передал аргумент — функция поработала — вернула результат». Но с std::unique_ptr есть тонкость: он не просто «указатель», он контракт владения. А контракт — штука строгая: если два участника думают, что «владеют одним и тем же», то рано или поздно случится двойное освобождение памяти (или другой вариант веселья).
Тут важно морально принять одну мысль: тип параметра должен рассказывать историю. Не в комментарии «// тут владение», а прямо в сигнатуре. В хорошем C++ коде вы часто можете понять «кто владеет объектом после вызова» просто посмотрев на тип параметра. И это не эстетика — это способ не ловить баги уровня «падает раз в неделю на компьютере у тестировщика Пети».
Кстати, требования к операциям unique_ptr (вроде корректности reset() и постусловий move-присваивания) в стандартной библиотеке формализуются очень тщательно — вплоть до отдельных замечаний и исправлений формулировок.
2. Передача по значению: функция забирает владение
Когда вы видите параметр по значению типа std::unique_ptr<T>, это почти всегда означает следующее: функция становится владельцем ресурса. То есть после вызова у вызывающего кода владение исчезнет (он «отдал ключи от квартиры»), а внутри функции ресурс будет уничтожен автоматически при выходе из области видимости — если вы его не передадите дальше.
Это мощный и очень честный контракт: у вас прямо в сигнатуре написано «я забираю владение». И приятный бонус: вы не можете случайно забыть, что владение переехало, потому что компилятор заставит вас написать std::move(...) в месте вызова.
Мини-пример: «съесть объект» (consume)
В нашем учебном мини-приложении будем вести простую сущность Note (заметка), а также «активную заметку», которой приложение владеет.
#include <iostream>
#include <memory>
#include <string>
#include <utility>
struct Note {
std::string title;
};
void consume_note(std::unique_ptr<Note> note) {
if (note) {
std::cout << "Consumed: " << note->title << '\n'; // Consumed: Buy milk
}
} // note уничтожится здесь автоматически
int main() {
auto n = std::make_unique<Note>(Note{"Buy milk"});
consume_note(std::move(n));
std::cout << std::boolalpha << (n == nullptr) << '\n'; // true
}
Обратите внимание на «ритуал» std::move(n). Он выглядит как «магическое слово», но смысл предельно человеческий: «я отдаю это владение». Без std::move компилятор вас не пустит — и это замечательно.
Когда это правильно
Передача unique_ptr по значению удобна, когда функция действительно должна стать владельцем, например: сохранить объект куда-то, поставить в очередь, добавить в контейнер, «принять на хранение».
Представьте, что у нас есть хранилище заметок std::vector<std::unique_ptr<Note>>. Тогда функция «добавить заметку в базу» логично забирает владение.
#include <memory>
#include <string>
#include <utility>
#include <vector>
struct Note {
std::string title;
};
void add_note(std::vector<std::unique_ptr<Note>>& notes,
std::unique_ptr<Note> note) {
notes.push_back(std::move(note));
}
int main() {
std::vector<std::unique_ptr<Note>> notes;
auto n = std::make_unique<Note>(Note{"Read C++ book"});
add_note(notes, std::move(n));
}
Здесь красивый момент: add_note забирает владение, и дальше уже контейнер владеет объектом.
Маленькая, но важная мысль про читаемость
Если функция принимает std::unique_ptr<T> по значению, вы как читатель кода должны ожидать, что:
- объект может быть уничтожен внутри функции;
- объект может быть передан дальше (ещё один std::move);
- вызывающая сторона должна считать, что после вызова «у неё этого больше нет».
То есть это как сдача куртки в гардероб: вам выдают номерок, но куртка уже не у вас.
3. Передача по ссылке: функция управляет владельцем
Иногда функция не должна стать владельцем «навсегда», но ей нужно изменить состояние владельца у вызывающего кода. Например, заменить текущую активную заметку на новую, закрыть заметку (сделать пусто), или «инициализировать, если пусто».
Для этого параметр делают ссылкой на unique_ptr: std::unique_ptr<T>&.
Важно уловить смысл: ссылка тут не на T, а на объект-владельца (unique_ptr). То есть функция не «берёт на ручки заметку», она «лезет в ваш гардеробный номерок и меняет, что там лежит».
Мини-пример: установить активную заметку
#include <memory>
#include <string>
#include <utility>
struct Note {
std::string title;
};
void set_active(std::unique_ptr<Note>& active, std::unique_ptr<Note> next) {
active = std::move(next); // владение переезжает в active
}
int main() {
std::unique_ptr<Note> active;
auto n = std::make_unique<Note>(Note{"Plan weekend"});
set_active(active, std::move(n));
}
Здесь важен баланс: параметр next по значению означает «я отдаю заметку», а active по ссылке означает «измени моего владельца».
Когда это правильно
Передача std::unique_ptr<T>& уместна, когда функция должна:
- заменить объект у владельца;
- «заполнить», если было пусто;
- передать владение дальше из этого владельца (например, вынуть активный объект и отдать в хранилище — но это уже очень близко к «вынуть» и требует аккуратности).
Чуть позже (в соседней лекции) вы разберёте операции get()/reset()/release()/swap(). Там станет особенно видно, как легко «порезаться» на границе владения, если относиться к unique_ptr как к обычному указателю.
4. Наблюдать, но не владеть: const T& и const T*
Когда вы пишете функцию «распечатай заметку», «посчитай длину заголовка», «проверь, что заголовок не пустой» — функция не должна владеть объектом. Ей нужно только использовать объект.
И вот здесь начинаются типичные ошибки новичков: они тащат в параметрах std::unique_ptr<T>, потому что «ну мы же везде теперь используем unique_ptr». В результате функция внезапно требует std::move и «забирает заметку», хотя вам всего лишь нужно было прочитать поля. Это как попросить у друга ручку «на секунду», а потом уйти с ней в закат, потому что формально он вам её подарил.
Правильный способ «просто почитать»: const Note&
Если объект обязан существовать, берите const T&.
#include <iostream>
#include <string>
struct Note {
std::string title;
};
void print_note(const Note& note) {
std::cout << note.title << '\n';
}
int main() {
Note n{"Hello"};
print_note(n); // Hello
}
Ссылка говорит: «объект есть», const говорит: «я его не меняю».
Когда объект может отсутствовать: const Note*
Если заметка может быть «не выбрана» (то есть unique_ptr пустой), удобнее выразить это через указатель const T*:
#include <iostream>
#include <string>
struct Note {
std::string title;
};
void print_note_ptr(const Note* note) {
if (note) {
std::cout << note->title << '\n';
} else {
std::cout << "(no active note)\n"; // (no active note)
}
}
Тут контракт ещё честнее: nullptr означает «нет объекта».
Как вызвать, если у нас unique_ptr
Если у вас есть std::unique_ptr<Note> active;, то «наблюдателя» можно передать так, чтобы не трогать владение.
В минимальном виде это выглядит так (операцию get() подробно вы разберёте позже; сейчас воспринимайте её как «дать посмотреть адрес»):
#include <memory>
void print_note_ptr(const Note* note);
int main() {
std::unique_ptr<Note> active;
print_note_ptr(active.get()); // либо адрес, либо nullptr
}
Главная идея: наблюдение отделяем от владения. unique_ptr — про владение. T*/const T* и T&/const T& — про использование.
Редкий, но полезный случай: const std::unique_ptr<T>&
Иногда функция должна «посмотреть» не только на сам объект, но и на состояние владельца: пустой он или нет, какой у него deleter (позже), или просто не хочется раскрывать наружу T*/T& (например, вы пишете утилиту для диагностики владельца).
Тогда можно принимать const std::unique_ptr<T>&. Это значит: «я не забираю владение, не меняю владельца, просто смотрю на него».
#include <iostream>
#include <memory>
struct Note {
int id{};
};
void debug_owner(const std::unique_ptr<Note>& p) {
std::cout << std::boolalpha << static_cast<bool>(p) << '\n';
}
int main() {
std::unique_ptr<Note> p;
debug_owner(p); // false
}
Но держите в голове практическое правило: если вы хотите работать с самим объектом, чаще читается и проще звучит параметр const T& или const T*. const unique_ptr<T>& — это уже «я хочу говорить именно про владение».
5. Критерии выбора: что передавать в параметрах
Когда вы стоите перед выбором сигнатуры, полезно задавать себе один вопрос: что будет с владением до и после вызова? Если вы умеете ответить на него человеческими словами, сигнатура обычно выводится почти автоматически.
Ниже — таблица-решалка. Её можно держать в голове как шпаргалку, пока не появится привычка.
| Задача функции (по смыслу) | Сигнатура параметра | Что это значит «по-человечески» |
|---|---|---|
| Функция забирает владение и дальше сама решает судьбу ресурса | |
«Отдай мне объект, у тебя его больше не будет» |
| Функция может заменить объект у владельца (управляет владельцем) | |
«Дай мне доступ к твоему владельцу, я могу поменять, чем он владеет» |
| Функция просто читает объект, объект обязан существовать | |
«Я посмотрю, но не изменю; объекта нет быть не может» |
| Функция читает объект, но объекта может не быть | |
«Я посмотрю, если он есть; иначе переживу» |
| Функция наблюдает именно владельца (проверить пустоту и т.п.) | |
«Я читаю состояние владельца, но не трогаю владение» |
Заметьте, что в этой таблице «по значению или по ссылке» — это только часть решения. Часто правильный ответ вообще звучит как «никак: unique_ptr тут не нужен, бери const T&».
6. Практический мини-код: активная заметка и архив
Сейчас соберём маленький кусочек кода, который показывает все три роли: создать заметку, установить активную, распечатать активную, добавить в архив. Он не претендует на полноценное приложение, но это хороший «скелет», который легко расширять дальше.
Модель и функции
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>
struct Note {
std::string title;
};
std::unique_ptr<Note> make_note(std::string title) {
return std::make_unique<Note>(Note{std::move(title)});
}
void set_active(std::unique_ptr<Note>& active, std::unique_ptr<Note> next) {
active = std::move(next);
}
void print_note_ptr(const Note* note) {
if (note) {
std::cout << "Active: " << note->title << '\n';
} else {
std::cout << "Active: (none)\n";
}
}
void archive(std::vector<std::unique_ptr<Note>>& storage, std::unique_ptr<Note> note) {
if (note) {
storage.push_back(std::move(note));
}
}
int main() {
std::vector<std::unique_ptr<Note>> archive_storage;
std::unique_ptr<Note> active;
print_note_ptr(active.get()); // Active: (none)
auto n = make_note("Buy milk");
set_active(active, std::move(n));
print_note_ptr(active.get()); // Active: Buy milk
archive(archive_storage, std::move(active)); // активная заметка уехала в архив
print_note_ptr(active.get()); // Active: (none)
}
Здесь вся «драма владения» видна без единого комментария про владение — потому что типы и std::move делают её явной. И это хороший стиль: меньше скрытой магии, больше прозрачных договорённостей.
7. Типичные ошибки
Ошибка №1: принимать std::unique_ptr<T> по значению там, где вы хотели «просто прочитать».
Это классика: вы пишете void print(std::unique_ptr<Note> n), а потом удивляетесь, почему нужно std::move, и почему после печати заметка исчезла. Это не «особенность C++», это ваш контракт: параметр по значению для unique_ptr означает «отдай владение». Для печати почти всегда лучше const Note& (если объект обязан быть) или const Note* (если может отсутствовать).
Ошибка №2: пытаться вызвать «забирающую» функцию без std::move.
Новичок видит consume(p) и думает: «ну я же просто передаю переменную». Но unique_ptr не копируется, а передача по значению требует копию или перенос. Если функция принимает std::unique_ptr<T>, вызов обязан выглядеть как consume(std::move(p)). Если вам не нравится std::move — значит, вы не хотите отдавать владение, и нужно менять сигнатуру.
Ошибка №3: использовать объект после std::move, как будто ничего не произошло.
После set_active(active, std::move(n)) переменная n обычно становится пустой. Она валидна, её можно снова заполнить, можно сравнить с nullptr, но разыменовывать нельзя без проверки. Это частая причина «вроде всё работало, а потом упало».
Ошибка №4: пытаться «помочь» и сделать копию unique_ptr вручную через сырой указатель.
Иногда студент делает страшное: берёт T* raw = p.get(); (или ещё хуже — где-то получает T*) и затем создаёт второй std::unique_ptr<T> из того же адреса. Это ломает саму идею «единоличный владелец»: два владельца попытаются освободить один ресурс. Если вам нужно разделённое владение — это другой инструмент и другая тема; здесь правило простое: один ресурс — один unique_ptr.
Ошибка №5: передавать std::unique_ptr<T>&, когда вы на самом деле хотели «забрать владение».
Обратная ситуация: вы хотите, чтобы функция приняла объект «с концами», но пишете foo(std::unique_ptr<T>&). Тогда вызывающий код не обязан делать std::move, и из вызова не видно, что владение куда-то уедет. Как итог — код становится менее честным. Если функция должна стать владельцем, принимайте std::unique_ptr<T> по значению: это заставит вызывающего явно подтвердить передачу владения.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ