JavaRush /Курсы /C++ SELF /Передача std::unique_ptr в функции

Передача std::unique_ptr в функции

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

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. Критерии выбора: что передавать в параметрах

Когда вы стоите перед выбором сигнатуры, полезно задавать себе один вопрос: что будет с владением до и после вызова? Если вы умеете ответить на него человеческими словами, сигнатура обычно выводится почти автоматически.

Ниже — таблица-решалка. Её можно держать в голове как шпаргалку, пока не появится привычка.

Задача функции (по смыслу) Сигнатура параметра Что это значит «по-человечески»
Функция забирает владение и дальше сама решает судьбу ресурса
std::unique_ptr<T>
«Отдай мне объект, у тебя его больше не будет»
Функция может заменить объект у владельца (управляет владельцем)
std::unique_ptr<T>&
«Дай мне доступ к твоему владельцу, я могу поменять, чем он владеет»
Функция просто читает объект, объект обязан существовать
const T&
«Я посмотрю, но не изменю; объекта нет быть не может»
Функция читает объект, но объекта может не быть
const T*
«Я посмотрю, если он есть; иначе переживу»
Функция наблюдает именно владельца (проверить пустоту и т.п.)
const std::unique_ptr<T>&
«Я читаю состояние владельца, но не трогаю владение»

Заметьте, что в этой таблице «по значению или по ссылке» — это только часть решения. Часто правильный ответ вообще звучит как «никак: 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> по значению: это заставит вызывающего явно подтвердить передачу владения.

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