Главная / Курсы / C++ по спирали / Глава 15. Ссылки / Нюансы работы со ссылками
# Глава 15.2. Нюансы работы со ссылками Ссылки — это одна из важнейших концепций в C++. И сегодня мы копнем тему ссылок глубже. ## Возврат значения по ссылке Функция может не только [принимать параметры по ссылке,](/courses/cpp/chapters/cpp_chapter_0151/#block-func) но и возвращать по ссылке результат. Для этого тип возвращаемого значения помечается как ссылочный: ```cpp T & func_name(params) { // ... } ``` Возвращаемая ссылка может быть константной: ```cpp const T & func_name(params) { // ... } ``` Главное — помнить, что ссылка должна «смотреть» на живой, ещё не уничтоженный объект. Для этого ссылка должна указывать на: - Переменную со [статическим временем жизни.](/courses/cpp/chapters/cpp_chapter_0091/#block-static-lifetime) Память под такую переменную выделяется на старте программы, инициализируется переменная при первом обращении (явная инициализация — это тоже обращение), а уничтожается она при завершении программы. - Ту же переменную, что была передана в функцию по ссылке в качестве аргумента. - Поле класса. В этом случае ссылку на поле возвращает метод класса. Что выведется в консоль? {.task_text} ```cpp {.example_for_playground} import std; class Singleton { public: explicit Singleton(std::string msg) : m_msg(msg) { std::print("c"); } ~Singleton() { std::print("d"); } void say() const { std::print("{}", m_msg); } private: std::string m_msg; }; const Singleton & get_singleton() { static Singleton s{"m"}; return s; } int main() { std::print("1"); const Singleton & ref = get_singleton(); ref.say(); std::print("2"); } ``` ```consoleoutput {.task_source #cpp_chapter_0152_task_0080} ``` Память под переменную со статическим временем жизни `s` выделится сразу, но инициализируется переменная только при вызове `get_singleton()`. Поэтому вначале запустится `main()`. После этого при вызове `get_singleton()` выполнится конструктор `Singleton`. Затем вызовется метод `say()`. Произойдет выход из `main()`, а после этого — деструктор `Singleton`. {.task_hint} ```cpp {.task_answer} 1cm2d ``` Допустим, у нас есть класс для хранения ключей и значений. Чтобы при обращении по ключу не создавать копию значения, метод `get()` возвращает ссылку на значение: ```cpp {.example_for_playground .example_for_playground_013} class Storage { public: // ... std::string & get(int key) // Возвращаем ссылку на значение { return m_data[key]; } // ... }; ``` При вызове `get()` никакого копирования не происходит. А так как `get()` возвращает неконстантную ссылку, оригинальное значение можно изменять: ```cpp {.example_for_playground .example_for_playground_014} Storage storage; std::string & val = storage.get(9); val = "8f95e06"; ``` Это выглядит необычно, но вызов метода, возвращающего ссылку, может стоять по левую сторону от оператора `=`: ```cpp {.example_for_playground .example_for_playground_015} storage.get(9) = "8f95e06"; ``` Если вы возвращаете ссылку на объект, который разрушается при выходе из функции, то ссылка становится висячей. ## Висячие ссылки Ссылка называется висячей (dangling reference), если уничтожается или перемещается объект, на который она указывает. Обращение по такой ссылке — это UB. А в простых случаях, если компилятор понимает, что ссылка указывает на несуществующий объект, код не компилируется: ```cpp {.example_for_playground} import std; // Возвращаем ссылку, а не значение double & pyramid_volume(double base_area, double height) { double res = 1.0 / 3.0 * base_area * height; return res; // res уничтожается, ссылка на res будет висеть } int main() { double & v = pyramid_volume(14.3, 6.2); // висячая ссылка std::println("{}", v); // UB } ``` ``` main.cpp:6:12: error: non-const lvalue reference to type 'double' cannot bind to a temporary of type 'double' 6 | return res; ``` Здесь мы возвращаем ссылку на `res`. Но эта переменная разрушается при выходе из `pyramid_volume()`, ведь её [время жизни](/courses/cpp/chapters/cpp_chapter_0091/#block-lifetime) ограничено телом функции. Переменная `res` локальная, и у неё [автоматическое время жизни.](/courses/cpp/chapters/cpp_chapter_0091/#block-automatic-lifetime) Компилятор разрушает такие переменные, когда они покидают свою область видимости. Поэтому после вызова `pyramid_volume()` ссылка `v` указывает на несуществующий объект. В главе про область видимости мы [разбирали,](/courses/cpp/chapters/cpp_chapter_0091/#block-stack-frame) что такое [стек вызовов,](https://en.wikipedia.org/wiki/Call_stack) база и вершина стека, а также какую информацию содержат его фреймы. Вообразим, что компилятор все же скомпилировал этот пример кода. Нас интересует, как выглядит стек вызовов в три момента времени: - выполнение тела `pyramid_volume()`, - сохранение в `v` результата вызова, - обращение к переменной `v` внутри `std::println()`. ![Стек вызовов](https://raw.githubusercontent.com/senjun-team/senjun-courses/refs/heads/split-chapter-15/illustrations/cpp/call_stack_dangling_reference.jpg) {.illustration} Разберем по шагам, когда ссылка становится висячей и в какой момент в коде появляется UB. 1. Выполняется `pyramid_volume()`. Локальная переменная `res` живет внутри фрейма этой функции. Допустим, у нее адрес `SB - 35`: адрес базы стека (stack base) за вычетом 35 байт. Стек растет от больших адресов к меньшим. Поэтому адреса внутри фрейма `main()` больше, чем внутри `pyramid_volume()`. 2. Выполняется `main()`. Функция `pyramid_volume()` вернула ссылку на `res`, и ею инициализирована переменная `v`. Но указатель на вершину стека сместился с завершившейся `pyramid_volume()` на `main()`. Начиная с этого момента адрес `res` уже нельзя назвать корректным. Доступа к фрейму `pyramid_volume()` больше нет, эта область памяти считается свободной. 3. Выполняется `std::println()`. Фрейм этой функции перезаписывает память, ранее принадлежавшую `pyramid_volume()`. Ссылка `v` теперь указыват на адрес, по которому может находиться все что угодно. И при обращении по этому адресу мы получаем UB. Что выведется в консоль? {.task_text} Напишите `err`, если этот код не скомпилируется, или `ub`, если в нем есть неопределённое поведение. {.task_text} ```cpp {.example_for_playground} import std; int & next() { static int x = 0; return ++x; } int main() { next(); int & n = next(); std::println("{}", n); } ``` ```consoleoutput {.task_source #cpp_chapter_0152_task_0090} ``` При вызове `next()` инициализируется статическая переменная `x`. Она будет разрушена после выхода из `main()`. Поэтому ссылка на `x` не будет висячей. {.task_hint} ```cpp {.task_answer} 2 ``` Еще одна распространенная ошибка, приводящая к появлению висячих ссылок, — это заведение ссылок на элементы контейнеров, которые могут быть перемещены в памяти. [Вспомните,](/courses/cpp/chapters/cpp_chapter_0072/#block-vector-under-the-hood) как устроен `std::vector`. Его элементы хранятся в единой области памяти, и при добавлении нового элемента этой памяти [может не хватить.](/courses/cpp/chapters/cpp_chapter_0072/#block-invalidation) Тогда выделяется память большего объема, и все элементы переносятся в нее. В этот момент итераторы на элементы [инвалидируются,](/courses/cpp/chapters/cpp_chapter_0062/#block-invalidation) а ссылки становятся висячими. ```cpp std::vector<int> data = {5, 9, 8}; int & ref_front = data.front(); // 5 int & ref_middle = data[1]; // 9 data.push_back(10); // Добавляем еще элементы // Ссылки ref_front и ref_middle становятся висячими std::println("{} {}", ref_front, ref_middle); // UB ``` Кстати, многие методы контейнеров возвращают ссылки. У вектора это оператор `[]` и методы `at()`, `front()` и `back()`. ## Продление времени жизни временных объектов Временный объект (temporary object) — это неименованный объект, который создаётся компилятором для хранения некоего значения. Живет он недолго: как правило, до конца инструкции. Cемантически временный объект подразумевает read-only доступ. Попытка обратиться к нему на запись означает, что в логике программы не всё гладко. В этом примере функция `http_get()` принимает по константной ссылке параметр `url`. Если вместо именованной переменной передать в функцию литерал, то для его хранения будет создан временный объект. И параметр ссылочного типа `url` будет указывать на него. При выходе из функции временный объект разрушится: ```cpp {.example_for_playground .example_for_playground_017} import std; std::string http_get(const std::string & url) { // ... } int main() { // Временный объект "github.com" живёт до конца вызова http_get() std::string body = http_get("github.com"); std::println("{}", body); } ``` ``` <HTML> ... </HTML> ``` Есть способ продлить время жизни временного объекта: присвоить его _константной_ ссылке. Иными словами, константную ссылку можно инициализировать неименованным объектом. Тогда его время жизни будет совпадать во временем жизни ссылки. Это называется продлением времени жизни (lifetime extension) или [материализацией временного объекта.](https://en.cppreference.com/w/cpp/language/implicit_conversion.html#Temporary_materialization) (temporary materialization). В этом примере временный объект `"https://cppreference.com/"` будет разрушен не в конце инструкции, а тогда же, когда и ссылка на него, то есть при выходе из `main()`: ```cpp {.example_for_playground .example_for_playground_018} const auto & url = std::string("https://cppreference.com/"); std::println("{}", url); ``` ``` https://cppreference.com/ ``` Обратите внимание, что для продления жизни временного объекта нужна именно константная ссылка. Обычная ссылка не сработает: ```cpp {.example_for_playground .example_for_playground_019} int main() { auto & url = std::string("https://cppreference.com/"); std::println("{}", url); } ``` ``` main.cpp:5:12: error: non-const lvalue reference to type 'basic_string<...>' cannot bind to a temporary of type 'basic_string<...>' 5 | auto & url = std::string("https://cppreference.com/"); | ^ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``` ## Ссылки на ссылки У вас [не получится](https://timsong-cpp.github.io/cppwp/n4868/dcl.ref#5) завести ссылку на ссылку. Если вы попытаетесь, то произойдёт [склеивание](https://en.cppreference.com/w/cpp/language/reference.html#Reference_collapsing) или схлопывание ссылок (reference collapsing): ```cpp {.example_for_playground} import std; using IntRef = int &; int main() { int x = 10; IntRef r1 = x; // Тип int & IntRef & r2 = x; // Тип int & } ``` Таким образом, тип «ссылки на ссылку ... на ссылку типа `T`» сводится компилятором просто к ссылке на `T`. ## Когда нужно и не нужно использовать ссылки Основных сценариев применения ссылок всего три: - Передача _по ссылке_ для изменения объекта. - Создание _константной ссылки_ для продления жизни временного объекта. - Передача _по константной ссылке_ для предотвращения копирования тяжёлого объекта. _Тяжелыми_ как правило считаются объекты, размер которых превышает пару машинных слов. [Машинное слово](https://ru.wikipedia.org/wiki/%D0%9C%D0%B0%D1%88%D0%B8%D0%BD%D0%BD%D0%BE%D0%B5_%D1%81%D0%BB%D0%BE%D0%B2%D0%BE) — это единица данных, которую процессор обрабатывает как единое целое. Длина машинного слова (то есть количество бит, которое оно занимает) определяется архитектурой процессора. Например, в архитектуре x86-64 машинное слово занимает 64 бита. Это означает, что передавать по константной ссылке такие типы как `std::uint64_t`, `double` и `bool` бессмысленно. Зато `std::string`, `std::vector` и другие классы могут иметь большой размер, и их [следует](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#rf-in) передавать по константной ссылке. Как эффективнее передавать тип `EndOfLine` в функцию — по значению или по константной ссылке? Введите `val` или `const ref`. {.task_text} ```cpp enum class EndOfLine { CrLf = 0, Cr = 1, Lf = 2, }; ``` ```consoleoutput {.task_source #cpp_chapter_0152_task_0100} ``` Базовый тип перечисления — это целое число. {.task_hint} ```cpp {.task_answer} val ``` Как эффективнее передавать тип `Point` в функцию — по значению или по константной ссылке? Введите `val` или `const ref`. {.task_text} ```cpp struct Point { double x = 0.0; double y = 0.0; double z = 0.0; }; ``` ```consoleoutput {.task_source #cpp_chapter_0152_task_0110} ``` Размер структуры из 3-х `double` точно превышает пару машинных слов. {.task_hint} ```cpp {.task_answer} const ref ``` В современном C++ плохой практикой [считается](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#rf-out) применение ссылок для возврата из функции нескольких значений. Этот приём можно встретить в старом коде. В этом примере функция `read_text()` читает файл и возвращает его содержимое типа `std::string`. Но она также записывает в `err_code` код ошибки, которая может произойти при работе с файлом: ```cpp std::string read_text(const std::string & filename, int & err_code) { // ... } ``` Вызов функции выглядит примерно так: ```cpp {.example_for_playground .example_for_playground_016} int err = kOk; std::string contents = read_text("/home/Al/robo_spec.txt", err); if (err != kOk) { // handle error, exit } std::println("Successfully read {} bytes from file", contents.size()); ``` Работать с функциями, которые по смыслу должны вернуть несколько значений, но по факту возвращают одно, а остальные _изменяют_ через параметры — крайне неудобно. Такой код выглядит странно. Поэтому пользуйтесь альтернативами: - Кидайте исключение, чтобы сигнализировать об ошибке. - [Возвращайте](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#rf-out-multi) структуру из нескольких полей или пару `std::pair`. - Возвращайте опциональное значение, обёрнутое в тип `std::optional` или `std::expected`. Эти варианты мы обсудим в следующих главах. ## Домашнее задание Этот курс знакомит вас с концепцией ссылок уже после того, как вы научились решать на C++ довольно сложные задачи. Мы считаем, что лучше вначале набить руку на использовании контейнеров, итераторов и стандартных алгоритмов, а уже потом постигать тонкости предотвращения лишнего копирования. Поэтому в предыдущих главах _полно_ задач и примеров кода, где ссылки были бы как нельзя кстати. Откройте [главу про последовательные контейнеры.](/courses/cpp/chapters/cpp_chapter_0072/) Пройдитесь по задачам, в которых требовалось написать функцию. Везде, где считаете необходимым, сделайте параметры константными ссылками. ---------- ## Резюме - Время жизни ссылки не должно превышать время жизни объекта, на который она указывает. Иначе вы получите висячую ссылку. - Обращение по висячей ссылке — это UB. - Если функция возвращает значение по ссылке, нужно удостовериться, что ссылка не становится висячей. - Передавайте по константной ссылке объекты, размер которых превышает пару машинных слов. Это позволит избежать лишнего копирования. - Чтобы продлить жизнь временного объекта, его можно присвоить константной ссылке.
Отправка...
Наша группа в telegram. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!