Ссылки и сложные объявления
От чтения вложенных типов к главной идее ссылки: это другое имя того же объекта. Передача по ссылке, категории значений, время жизни и допустимые комбинации типов.
Ссылка — второе имя
Ссылка (reference) — другое имя уже существующего объекта или функции. Запись T& r = x; связывает r с x без создания нового объекта типа T.
Для обычного объекта int y = x; создаёт отдельный объект с копией значения, а int& y = x; делает второе имя того же объекта. Изменение через y тогда меняет именно x.
Ссылка и исходное имя ведут к одному объекту, а не к двум объектам, которые «синхронизируются».
Это же работает со строкой, вектором, словарём: std::string s2 = s; создаёт отдельную строку, а std::string& s2 = s; — второе имя.
#include <iostream>
int main() {
int x = 5;
int copy = x; // отдельная ячейка
int& alias = x; // то же самое x
++alias;
copy = 100;
std::cout << x << ' ' << copy; // 6 100
}Присваивание при создании копии и связывание ссылки выглядят похоже, но результат разный. Копия получает собственный объект со значением, а ссылка даёт ещё один способ обратиться к исходному объекту. В визуализации меняйте обе стороны и проверяйте, сколько независимых значений получилось.
один объект int
#include <iostream>
int main() {
int original = 5;
int copy = original;
int& alias = original;
++alias;
std::cout << original << ' ' << copy << '\n';
}#include <iostream>
int main() {
int original = 5;
int& first = original;
int& second = first;
second = 11;
std::cout << std::boolalpha << (&original == &first) << ' '
<< (&first == &second) << '\n';
std::cout << original << '\n';
}Если после int& y = x; написать y = z;, изменится значение x. Ссылка y не начнёт обозначать z.
Разобранный пример и самостоятельная проверка
Сравним две похожие записи. Первая, int copy = original, создаёт новый объект и переносит в него текущее значение. После этого объекты независимы. Вторая, int& alias = original, связывает новое имя с уже существующим объектом. Дополнительного int не появляется. Поэтому изменение через alias сразу является изменением original. В правильном рисунке это одна ячейка с двумя подписями, а не две ячейки, которые обмениваются обновлениями. Если позже написать alias = other, ссылка не станет обозначать other: в исходный объект будет записано значение other. Ссылка должна получить объект при инициализации. Она не управляет его временем жизни и не удерживает локальную переменную после выхода из функции. Поэтому ссылку на обычную локальную переменную нельзя возвращать для дальнейшего использования. Для первого применения достаточно параметра int&, который изменяет живой объект вызывающего кода. Возврат значений остаётся обычным безопасным выбором.
#include <iostream>
// int& bad() { int local = 7; return local; } // Не возвращать local.
void increment(int& value) {
++value;
}
int main() {
int original = 5;
int copy = original;
int& alias = original;
increment(alias);
std::cout << original << ' ' << copy << '\n';
std::cout << std::boolalpha << (&original == &alias) << '\n';
}Ссылка и указатель
Указатель int* p — самостоятельный объект, хранящий адрес. Ссылка int& r — имя уже существующего объекта. Указатель можно направить на другой объект и можно сделать nullptr. Ссылка должна быть связана с объектом при инициализации.
&x — оператор взятия адреса, результат int*. int& r — объявление ссылочного типа. Это разные конструкции.
#include <iostream>
int main() {
int x = 5, z = 9;
int* p = &x;
int& r = x;
p = &z; // указатель сменил адрес
r = z; // x = z, не смена ссылки
std::cout << x << ' ' << *p; // 9 9
}Указателю можно присвоить другой адрес; уже связанную ссылку переназначить нельзя. Выражение r = z меняет значение объекта, который именуется r. Поэтому сначала проследите, какой объект обозначает каждое имя, и только потом выполняйте присваивание.
#include <iostream>
int main() {
int x = 5, z = 9;
int* pointer = &x;
int& reference = x;
pointer = &z;
reference = z;
++z;
std::cout << x << ' ' << z << ' ' << *pointer << '\n';
}#include <iostream>
int main() {
int x = 5, z = 9;
int* pointer = &x;
int*& alias = pointer;
alias = &z;
*alias = 12;
std::cout << x << ' ' << z << ' ' << *pointer << '\n';
}| Действие | Указатель int* | Ссылка int& |
|---|---|---|
Объявление | int* p = &x; | int& r = x; |
Использование | *p = 7; | r = 7; |
Можно перенаправить | Да · p = &z; | Нет · r = z; копирует значение в x |
Адрес объекта | p | &r |
sizeof | Размер указателя | Размер int через r |
Внутренняя реализация параметра-ссылки может использовать адрес, но программа опирается на правила ссылок, а не на устройство машинного кода. sizeof(r) измеряет тип объекта, а не возможный внутренний адрес.
Ссылки в функциях
Передача по значению создаёт отдельный параметр функции. Передача по ссылке позволяет функции обращаться к исходному объекту вызывающего кода без ручных & и *.
Функция swap(int& a, int& b) работает с исходными переменными. Внутри a и b — просто имена соответствующих объектов; можно использовать обычные присваивания.
Тип T& в списке параметров позволяет изменять объект, переданный аргументом. Для чтения крупных объектов обычно используют const T& (подробнее — в «Дополнительно»).
#include <iostream>
void swap_values(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
int main() {
int x = 3, y = 8;
swap_values(x, y);
std::cout << x << ' ' << y; // 8 3
}Параметр-ссылка позволяет функции работать с объектом вызывающего кода. Параметр по значению создаёт самостоятельный объект: его изменение само по себе не меняет исходную переменную. Ссылка не означает, что изменение обязательно произойдёт; const-ссылка часто используется только для чтения.
#include <iostream>
void exchange(int& left, int& right) {
int temporary = left;
left = right;
right = temporary;
}
int main() {
int x = 3, y = 8;
exchange(x, y);
std::cout << x << ' ' << y << '\n';
}#include <iostream>
int next_copy(int value) { return value + 1; }
void increment(int& value) { ++value; }
int main() {
int value = 5;
int result = next_copy(value);
std::cout << value << ' ' << result << '\n';
increment(value);
std::cout << value << '\n';
}Почему f(int) и f(int&) конфликтуют при вызове?
Это две разные допустимые перегрузки, но при вызове с изменяемым int-lvalue обе подходят одинаково хорошо. Поэтому компилятор сообщает о неоднозначности — не важно, передали исходное имя или ссылку на него.
Наличие ссылки у аргумента не даёт автоматического преимущества перегрузке с int&: само выражение всё равно обозначает int-lvalue. Пара f(int) и f(int&) делает обычный вызов неоднозначным. В примерах рассмотрены явный выбор через тип указателя и другая пара перегрузок, которая различает доступ для изменения и чтения.
#include <iostream>
void show(int) { std::cout << "value "; }
void show(int&) { std::cout << "reference\n"; }
int main() {
int value = 5;
void (*by_value)(int) = &show;
void (*by_reference)(int&) = &show;
by_value(value);
by_reference(value);
// show(value); // ошибка: обе версии подходят
}#include <iostream>
void inspect(int&) { std::cout << "mutable "; }
void inspect(const int&) { std::cout << "readonly "; }
int main() {
int value = 5;
const int constant = 7;
inspect(value);
inspect(constant);
inspect(9);
std::cout << '\n';
}Связывание и категории значений
Неконстантная lvalue-ссылка T& должна быть инициализирована при создании и обычно связывается с подходящим изменяемым lvalue. С временным результатом 5 или x++ напрямую её связать нельзя.
Запись int& r; запрещена: без объекта у ссылки нет смысла. Но int& r = x; корректна. ++x возвращает lvalue, в то время как x++ выдаёт старое значение как prvalue.
Lvalue обозначает существующий объект (например, x или *p). Prvalue — выражение-значение (например, 5 или x++). Более полная система категорий — в отдельной теме.
int main() {
int x = 5;
int* p = &x;
int& r = x; // OK
int& s = ++x; // OK: lvalue
int& t = *p; // OK: lvalue
// int& bad = 5; // CE: prvalue
// int& bad2 = x++; // CE: prvalue
const int& read = 5; // OK: временный int
(void)r; (void)s; (void)t; (void)read;
}При связывании важны тип и категория выражения. Для обычной int& нужен подходящий изменяемый lvalue; это может быть имя переменной, разыменование указателя или результат префиксного инкремента. Временное число связывается с const int&, но такая ссылка уже не даёт изменять число.
инкремент, разыменование и T&&
#include <iostream>
int main() {
int value = 5;
int& alias = ++value;
alias = 8;
std::cout << value << '\n';
// int& invalid = value++; // prvalue не подходит для int&
}#include <iostream>
int main() {
const int& read = 2 + 3;
int value = 10;
int* pointer = &value;
int& write = *pointer;
++write;
std::cout << read << ' ' << value << '\n';
}const int& r = 5; корректна: допускается привязка к временному объекту с продлением времени жизни в этом случае. А int&& — другой вид ссылки; он не означает «ссылку на ссылку». Подробности — в закрытом «Дополнительно».
Возврат по ссылке
По умолчанию возвращайте результат по значению: например, int sum(int a,int b) возвращает a+b. Возврат T& нужен, когда вызывающий должен получить доступ к уже существующему объекту, который переживёт вызов. Не возвращайте ссылку на локальную переменную.
Возврат по ссылке — функция с возвращаемым типом T&, результат которой обозначает уже существующий объект. Такой результат обычно является lvalue, поэтому ему можно присвоить значение.
Именно поэтому допустимы v[i] = 42; и *p = 42;: доступ возвращает сам элемент, а не отдельную копию его значения. Операция operator[] у изменяемого std::vector<int> возвращает int& Что с const vector?У const std::vector<int> операция индексации возвращает const int&, так что прочитать элемент можно, а изменить через такую ссылку — нет..
Когда требуется именно значение, C++ может неявно прочитать lvalue-объект. В int y = x; читается значение x и инициализируется новый объект y.
#include <iostream>
int& identity(int& x) { return x; }
int main() {
int x = 5;
identity(x) = 12; // изменили сам x
int& r = identity(x);
int y = identity(x); // создана копия
r = 8;
std::cout << x << ' ' << y; // 8 12
}Возврат int& позволяет продолжить работать с исходным объектом после завершения вызова. Но тип переменной, принимающей результат, всё ещё важен: int& сохраняет связь, а int создаёт копию прочитанного значения. Объект, на который возвращена ссылка, обязан оставаться живым.
#include <iostream>
int& identity(int& value) { return value; }
int main() {
int value = 5;
identity(value) = 12;
int& alias = identity(value);
int copy = identity(value);
alias = 8;
std::cout << value << ' ' << copy << '\n';
}#include <iostream>
#include <vector>
int& first(std::vector<int>& values) { return values.front(); }
int main() {
std::vector<int> values{1, 2, 3};
int& element = first(values);
element = 9;
std::cout << values.front() << '\n';
}Выражение *p имеет тип int и категорию lvalue. В частности, decltype(*p) даёт int&. Не нужно утверждать, что обычный тип каждого такого выражения буквально становится int&.
Висячие ссылки и время жизни
Висячая ссылка (dangling reference) — ссылка, объект назначения которой уже не существует. Попытка читать или изменять через неё уничтоженный объект приводит к неопределённому поведению (UB).
Функция может вернуть ссылку на объект, который переживёт её вызов. Но нельзя использовать ссылку на обычную локальную переменную после завершения функции: срок её жизни закончился.
Промежуток, в течение которого объект существует согласно правилам C++. У автоматических локальных объектов срок обычно заканчивается при выходе из соответствующего блока.
int& bad() {
int local = 7;
return local; // опасно: ссылка переживёт local
}
int& good() {
static int value = 7;
return value; // объект продолжает жить
}
int main() {
// int& r = bad(); // не используем висячую ссылку
int& r = good();
r = 42;
}Ссылка не владеет объектом и не делает обычную локальную переменную бессмертной. Возврат из функции уничтожает её локальные объекты, но не уничтожает статические объекты и не освобождает память, выделенную new. Важно отдельно определить, кто отвечает за время жизни и где ссылка перестаёт быть пригодной для обращения.
Дополнительный разбор плохого API
#include <iostream>
int& counter() {
static int value = 0;
return value;
}
int main() {
++counter();
int& alias = counter();
++alias;
std::cout << counter() << '\n';
}#include <iostream>
#include <memory>
int main() {
auto owner = std::make_unique<int>(7);
int& alias = *owner;
alias = 42;
std::cout << *owner << '\n';
owner.reset();
// alias больше нельзя читать: объект уже уничтожен
}Даже если компилятор выводит ожидаемое число, использование висячей ссылки всё равно некорректно. Не полагайтесь на случайное сохранение байтов в памяти.
Необычный, но допустимый пример: ссылка на динамический объект
В лекции рассмотрен случай, когда функция возвращает int& к объекту, созданному через new. После возврата объект остаётся жив, а по &r можно получить исходный адрес для delete. Однако такой API трудно сопровождать: ответственность за освобождение памяти неочевидна. Лучше использовать RAII.
int& create() { return *new int(7); }
int main() {
int& r = create();
r = 9;
delete &r; // допустимо, но плохой дизайн
}Ссылки на другие типы
Ссылки можно создавать на объекты и функции, включая указатели и массивы. Но не существуют типы «указатель на ссылку», «массив ссылок» и обычная «ссылка на ссылку».
int*& rp = p; — ссылка на сам указатель: при присваивании rp = &z; перенаправится p. А int (&r)[5] = a; — ссылка на целый массив: размер 5 сохраняется в типе.
Не преобразуется в указатель при связывании: int (&r)[5] = a; означает, что r — другое имя всего массива.
#include <iostream>
void print(int x) { std::cout << x; }
int main() {
int x = 5, z = 9;
int* p = &x;
int*& rp = p; // ссылка на указатель
rp = &z; // теперь p указывает на z
int a[5]{};
int (&ra)[5] = a; // ссылка на массив
void (&rf)(int) = print; // ссылка на функцию
ra[0] = *p;
rf(ra[0]);
}Ссылка может именовать указатель, массив или функцию — нужно лишь правильно сгруппировать объявление. При этом массивы ссылок и указатели на ссылки напрямую не объявляются. Для коллекций связанных объектов используют подходящие обёртки, а ссылку на массив удобно применять там, где нужно сохранить размер в типе.
#include <iostream>
#include <iterator>
int main() {
int values[3]{1, 2, 3};
int (&alias)[3] = values;
alias[1] = 9;
std::cout << std::size(alias) << ' ' << values[1] << '\n';
}#include <iostream>
void print(int value) { std::cout << value << '\n'; }
int main() {
int original = 1, other = 7;
int* pointer = &original;
int*& pointer_alias = pointer;
pointer_alias = &other;
void (&callback)(int) = print;
callback(*pointer);
}Контейнеры вроде std::vector не поддерживают элементы-ссылки. Для коллекции невладеющих ссылок возможен std::reference_wrapper<int>, но это дополнительная тема.
Сложные объявления
Декларатор (declarator) — часть объявления, которая описывает имя и форму типа: указатель, ссылка, массив или функция. Скобки определяют порядок связывания этих конструкций.
Объявление читают от имени, учитывая вложенность и приоритет: сначала ближайшие [] и (), затем * / &, после чего выходят из скобок. Тип результата находится слева.
void f(int) объявляет функцию; void (*pf)(int) — переменную-указатель на такую функцию.
#include <type_traits>
int a[5]{}; // массив из 5 int
int* ptrs[5]{}; // массив из 5 int*
int (*pa)[5] = &a; // указатель на int[5]
void f(int);
void (*pf)(int) = &f; // указатель на функцию
static_assert(std::is_same_v<decltype(pa), int (*)[5]>);
void f(int) {}Читать длинное объявление легче по шагам: найдите имя, рассмотрите ближайшие скобки и только затем переходите к окружающим конструкциям. Сравните исходную запись с псевдонимами типов. Псевдонимы не создают новые виды объектов, но позволяют обсуждать сложный тип небольшими понятными частями.
Углублённый разбор: фабрика указателей на функции
Упростите объявление псевдонимами: using Callback=void(*)(int); using Factory=Callback(*)(int); Factory factory[10]{};
#include <iostream>
#include <type_traits>
int main() {
int values[3]{4, 5, 6};
int* pointers[3]{&values[0], &values[1], &values[2]};
int (*whole)[3] = &values;
static_assert(std::is_same_v<decltype(pointers), int*[3]>);
static_assert(std::is_same_v<decltype(whole), int (*)[3]>);
std::cout << *pointers[1] << ' ' << (*whole)[1] << '\n';
}#include <iostream>
using Callback = void (*)(int);
void print(int value) { std::cout << value << '\n'; }
Callback make_callback(int) { return print; }
int main() {
Callback (*factory[2])(int){make_callback, make_callback};
Callback callback = factory[1](0);
callback(7);
}int* a[5] — массив указателей. int (*a)[5] — указатель на целый массив. Аналогично void (*fa[10])(int) — массив указателей на функции.
Ещё один сложный уровень — указатель на функцию, возвращающую указатель на функцию
В лекции автор курса исправляет пример с вложенными объявлениями. Это необязательно запоминать наизусть: важно научиться разбирать его слоями. В современном C++ аналогичную запись часто упрощают с помощью using.
#include <type_traits>
void target(int) {}
void (*make_target(int))(int) { return ⌖ }
void (*(*factory[10])(int))(int){};
// factory: массив из 10 указателей на функции,
// возвращающие указатели на функции
int main() {
factory[0] = &make_target;
factory[0](1)(2);
}Дополнительно
Для расширения кругозора · необязательно для этой лекции
const T& подходит для чтения объекта без копирования и может связываться с некоторыми временными значениями. Например, const std::string& s = "hello"; даёт время жизни созданной временной строке по правилам такой инициализации.
Rvalue-ссылки
T&& — отдельная категория ссылочного типа, активно используемая в перемещении и perfect forwarding. Это не «ссылка на ссылку».
Reference collapsing
При выводе типов и подстановке шаблонов формы ссылок могут «схлопываться»: например, T& & в соответствующем контексте приводит к T&. Непосредственно объявить обычную ссылку на ссылку нельзя.
#include <functional>
#include <vector>
int main() {
int a = 1, b = 2;
const int& read = 5;
int&& temporary = 9;
std::vector<std::reference_wrapper<int>> refs{a, b};
refs[0].get() = 10;
(void)read; (void)temporary;
}Если объект должен жить дольше функции — используйте ясный способ владения: возвращайте значение, используйте std::unique_ptr или документируйте, кто управляет временем жизни. Не возвращайте ссылку на локальный автоматический объект.
Справочник терминов
Главные определения расположены внутри глав. Здесь они собраны в алфавитном поиске для повторения.
Декларатор
Часть объявления, связывающая имя с конструкцией типа: *, &, [], ().
Примеры кода
Обмен исходных значений через ссылки
#include <iostream>
void swap_values(int& left, int& right) {
const int saved = left;
left = right;
right = saved;
}
int main() {
int a = 0, b = 0;
if (!(std::cin >> a >> b)) {
std::cerr << "Invalid numbers\n";
return 1;
}
swap_values(a, b);
std::cout << a << ' ' << b << '\n';
}Один объект с двумя именами; copy остаётся независимым.
#include <iostream>
// int& bad() { int local = 7; return local; } // Не возвращать local.
void increment(int& value) {
++value;
}
int main() {
int original = 5;
int copy = original;
int& alias = original;
increment(alias);
std::cout << original << ' ' << copy << '\n';
std::cout << std::boolalpha << (&original == &alias) << '\n';
}Обсудить лекцию с ИИ
Скопируйте промпт в свой ИИ-чат. В него уже включены материал этой лекции и примеры кода — можно сразу задавать вопросы и тренироваться.