1. Введение
Когда новичок видит & в параметре, первая реакция часто такая: «О нет, это что-то опасное, наверное, из тех страшных тем, где всё падает». На самом деле ссылки в параметрах — это один из самых дружелюбных инструментов C++… если относиться к ним как к контракту, а не как к «трюку».
Представьте, что функция — это маленький цех. Аргументы — это то, что вы заносите внутрь. Есть два способа работать: либо вы приносите копию детали (передача по значению), либо вы приносите саму деталь и говорите «вот она, работай прямо с ней» (передача по ссылке). Ссылка — это как бейджик «я официальный представитель этого объекта».
T& как «второе имя»
Важный мягкий старт: ссылка — это не новый объект. Ссылка — это другое имя для уже существующего объекта.
То есть если у вас есть переменная a, и вы где-то получили int& r = a;, то r и a — это «две таблички на одной двери». Внутри комнаты один и тот же объект.
Мини-пример, чтобы почувствовать руками:
#include <iostream>
int main() {
int a = 10;
int& r = a; // r — второе имя a
r += 5;
std::cout << a << '\n'; // 15
}
Здесь нет копирования. Нет «второго int». Есть один int, но к нему два пути обращения.
Параметр T&: функция может изменить аргумент
Теперь переносим идею в функции. Когда параметр записан как T&, это читается так: «Функция работает с реальным объектом вызывающей стороны». А значит, изменения внутри функции будут видны снаружи. Это не «побочный эффект», это и есть смысл такого параметра: вы явно разрешили функции менять ваш объект.
Самый короткий пример — инкремент:
#include <iostream>
void inc(int& x) {
x += 1;
}
int main() {
int a = 10;
inc(a);
std::cout << a << '\n'; // 11
}
Здесь x — это a. Поэтому x += 1 меняет a.
И вот тут появляется очень взрослое правило читаемости: если вы используете T&, то по названию функции обычно должно быть понятно, что она меняет аргумент. inc, append, normalize, sort_inplace — хорошие названия. calc, get, print — подозрительные, потому что «calc» обычно считают, а не переписывают вам переменную.
2. T& не создаёт копию
Ещё одна причина любить ссылки — они часто убирают лишние копии больших объектов. Но сегодня мы держим фокус не на производительности ради производительности, а на смысле: если функция должна менять строку — логично, что она принимает ссылку.
Например, добавим в конец строки восклицательный знак:
#include <iostream>
#include <string>
void add_exclamation(std::string& s) {
s += "!";
}
int main() {
std::string name = "Bob";
add_exclamation(name);
std::cout << name << '\n'; // Bob!
}
Важная деталь: если бы параметр был std::string s (по значению), вы бы меняли копию, и снаружи ничего бы не произошло. То есть тип параметра — это не «как удобнее», а что именно обещает функция.
3. Ограничение T&: нельзя привязать к временному значению
Сейчас будет момент, где многие впервые получают ошибку компиляции и думают: «Компилятор сломался». Нет, компилятор вас спас.
Нельзя привязать T& к временному значению (к объекту, который живёт очень недолго).
Пример:
#include <string>
void add_exclamation(std::string& s) {
s += "!";
}
int main() {
add_exclamation(std::string("Bob")); // ошибка компиляции
}
Почему? Потому что std::string("Bob") — временный объект. Он появляется «на время выражения», а потом исчезает. Если бы C++ разрешил вам сделать на него «долгоиграющую» ссылку T&, вы могли бы начать менять объект, который вот-вот испарится. Это как пытаться прикрутить полку к воздуху: пока вы держите дрель — вроде есть куда, но потом окажется, что стены не было.
Запомните прикладное правило: T& требует «настоящую переменную» (именованный объект), а не результат выражения «на лету».
4. const T&: читаю без копии и обещаю «не изменяю»
Теперь второй герой лекции: const T&.
Когда вы пишете const T&, вы делаете сразу две вещи:
- говорите «не хочу копировать, дай мне доступ к исходному объекту»
- говорите «и не дам себе его менять — честное программистское»
Это очень частый вид параметра для функций, которые только читают объект, особенно если объект потенциально большой (строка, вектор).
Пример: функция, которая печатает строку:
#include <iostream>
#include <string>
void print_line(const std::string& s) {
std::cout << s << '\n';
}
int main() {
std::string msg = "hello";
print_line(msg); // печатает: hello
}
Ключевая мысль: print_line не имеет права менять s. Если вы внутри напишете s += "!", компилятор вас остановит. Это прямой, железобетонный способ защитить себя от случайной порчи данных.
Кстати, в стандартной библиотеке идея “reference/const_reference” встречается как устойчивый способ назвать «ссылку на элемент» и «константную ссылку на элемент» (например, в контейнерах), и даже в редакторских заметках к стандарту можно встретить формулировки про (const_)reference как про стандартный паттерн именования.
const T& можно привязать к временному значению
Вот где const T& выигрывает у T& по удобству. const T& можно привязать к временному объекту. И это логично: если вы обещаете «не менять», то ссылаться на временное значение безопаснее, потому что вы его не модифицируете и обычно используете «прямо сейчас».
Пример:
#include <iostream>
#include <string>
std::size_t len(const std::string& s) {
return s.size();
}
int main() {
std::cout << len(std::string("abc")) << '\n'; // 3
}
Это очень приятная возможность: вы можете передать результат выражения без лишних переменных, и при этом не копировать строку.
Важно не превращать это в религию. Для маленьких типов (int, double) const T& часто избыточен, и в лекции 71 мы как раз обсуждали, почему «везде const int&» — это шум в коде. Но для строк и векторов это обычно хороший стиль.
5. Мини-таблица: T, T& или const T&
Сейчас полезно собрать картинку в одну компактную таблицу, чтобы перестать «угадывать» и начать выбирать осознанно.
| Что вы хотите по смыслу | Что писать в параметре | Что это означает |
|---|---|---|
| «Мне нужна копия, я работаю с независимым значением» | |
создаётся копия аргумента |
| «Я изменю ваш объект» | |
работаю с исходным объектом, изменения видны снаружи |
| «Я только читаю, копию не хочу» | |
работаю с исходным объектом, но менять запрещено |
Эта таблица — не про «оптимизацию». Она про то, чтобы сигнатуры не превращались в сюрприз.
Схема: как работает «второе имя»
Когда вы только привыкаете к ссылкам, помогает держать визуальную модель: T& — это не коробка, это стрелочка «к тому же объекту».
flowchart LR
A["Переменная a (реальный объект)"] -->|alias| R["Ссылка r (второе имя)"]
A -->|alias| P["Параметр x типа T& в функции"]
Смысл: объект один (a), а имён/путей доступа может быть несколько (r, x).
6. Практика: ссылки в консольном TodoList
Сейчас сделаем прикладной шаг: добавим в наше приложение (пусть это будет простейший TodoList в консоли) функции так, чтобы по сигнатурам было видно, где мы меняем список задач, а где только читаем.
Представим, что задачи — это просто std::vector<std::string>. Мы пока не моделируем «задачу» как struct (это будет позже по курсу), поэтому держим всё максимально просто.
Печать задач: const std::vector<std::string>&
Начнём с печати. Печать не должна менять список. Значит — const&.
#include <iostream>
#include <string>
#include <vector>
void print_tasks(const std::vector<std::string>& tasks) {
std::cout << "Tasks: " << tasks.size() << '\n';
for (std::size_t i = 0; i < tasks.size(); ++i) {
std::cout << i << ": " << tasks[i] << '\n';
}
}
Обратите внимание: мы не копируем tasks, а читаем. Если вы случайно попытаетесь сделать tasks.push_back("..."), компилятор вас остановит — и это хорошо.
Добавление задачи: меняем список (T&), читаем текст (const T&)
Добавление задачи меняет вектор, значит вектор — по ссылке &. А текст задачи мы только читаем, значит строка — по const&.
#include <string>
#include <vector>
void add_task(std::vector<std::string>& tasks, const std::string& text) {
if (!text.empty()) {
tasks.push_back(text);
}
}
Сигнатура читается почти как фраза: «я изменяю tasks, но text не трогаю».
Нормализация строки «на месте»: пример честного std::string&
Частая задача: поправить строку «на месте», например, удалить лишние пробелы по краям. Мы не будем делать идеальный trim на все случаи жизни (это отдельная большая тема), сделаем учебную версию: если строка начинается с пробела — убрать один пробел, если заканчивается — убрать один.
#include <string>
void trim_one_space(std::string& s) {
if (!s.empty() && s.front() == ' ') {
s.erase(0, 1);
}
if (!s.empty() && s.back() == ' ') {
s.pop_back();
}
}
И использование:
#include <iostream>
#include <string>
int main() {
std::string t = " hello ";
trim_one_space(t);
std::cout << t << '\n'; // hello
}
Здесь std::string& абсолютно оправдан: функция именно редактирует строку.
«Выходной параметр» через T&
Иногда вам хочется, чтобы функция не просто «посчитала и вернула», а записала результат в уже существующую переменную. Это называют выходным параметром. Мы сегодня не углубляемся в дизайн «как лучше возвращать несколько значений», но сам приём на уровне ссылки полезно увидеть.
Пример: функция пытается найти первую задачу, содержащую подстроку. Если нашла — записывает индекс в out_index и возвращает true, иначе возвращает false.
#include <string>
#include <vector>
bool find_task(const std::vector<std::string>& tasks,
const std::string& query,
std::size_t& out_index) {
for (std::size_t i = 0; i < tasks.size(); ++i) {
if (tasks[i].find(query) != std::string::npos) {
out_index = i;
return true;
}
}
return false;
}
Использование:
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> tasks{"buy milk", "learn c++", "sleep"};
std::size_t idx = 0;
if (find_task(tasks, "c++", idx)) {
std::cout << idx << '\n'; // 1
}
}
Смысл std::size_t& out_index здесь простой: «если нашёл — запишу сюда ответ». И снова контракт читается по сигнатуре.
Если перепутать T& и const T&
Очень практичный момент: начинающие часто ставят T& «на всякий случай», а потом неожиданно меняют данные «снаружи» и ловят странные эффекты.
Представьте, что вы написали функцию «печать задач», но случайно забыли const:
#include <iostream>
#include <string>
#include <vector>
void print_tasks_wrong(std::vector<std::string>& tasks) {
tasks.push_back("oops"); // внезапно изменили список
std::cout << tasks.size() << '\n';
}
Это пример плохого контракта: функция по названию «печатает», а по факту меняет данные. Такие вещи превращают программу в детектив, где преступник — вы сами две недели назад.
Обратная ошибка тоже бывает: вы хотели изменить объект, но случайно написали const T& — и компилятор вам запретил. Это как раз приятный случай: лучше пусть компилятор ругается сразу, чем программа «молча делает не то».
Мини-скелет main.cpp
Чтобы было легче связать примеры в единое приложение, вот маленький скелет, куда вы можете вставлять функции. Он не «финальная архитектура», но уже похож на аккуратный учебный проект с тонким main.
#include <iostream>
#include <string>
#include <vector>
void print_tasks(const std::vector<std::string>& tasks);
void add_task(std::vector<std::string>& tasks, const std::string& text);
int main() {
std::vector<std::string> tasks;
add_task(tasks, "learn references");
add_task(tasks, "drink tea");
print_tasks(tasks);
return 0;
}
И ожидаемый вывод может быть таким:
// Tasks: 2
// 0: learn references
// 1: drink tea
7. Типичные ошибки при работе с T& и const T&
Ошибка №1: ставить T& «на всякий случай», хотя функция не должна менять аргумент.
Это самая распространённая проблема, потому что кажется, что «ссылка быстрее» и «так правильнее». В итоге вы получаете функцию, которая по контракту может менять аргумент, и читатель кода вынужден ожидать подвоха. Если вы не меняете объект — выбирайте const T& (для больших типов) или передачу по значению (для маленьких).
Ошибка №2: пытаться привязать T& к временному значению и удивляться ошибке компиляции.
Например, f(std::string("hi")) при f(std::string&) не скомпилируется. Это не каприз языка, а защита от привязки к объекту, который исчезнет «через мгновение». Если вы хотите принимать временные значения и только читать — используйте const T&.
Ошибка №3: думать, что const — это «для красоты», и снимать его без причины.
const в const T& — это контракт. Он не только для человека, но и для компилятора: он запрещает случайно менять аргумент. Если вы убираете const, вы буквально выключаете ремень безопасности, потому что «так удобнее дотянуться до бардачка».
Ошибка №4: делать const T x вместо const T& x, думая, что так «не будет копирования».
Параметр const T x всё равно создаёт копию — просто «неизменяемую копию». Это иногда полезно, но если цель была не копировать std::string/std::vector, то нужно именно const T&.
Ошибка №5: писать функции с неожиданной модификацией через ссылку и без намёка в названии.
Если функция меняет аргумент через T&, это должно читаться по сигнатуре (там уже видно), но желательно и по названию. Иначе следующий читатель кода (включая вас будущего) будет запускать программу «на всякий случай» и проверять, не прячется ли там очередной сюрприз.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ