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 (реальний обʼєкт)"] -->|аліас| R["Посилання r (друге імʼя)"]
A -->|аліас| 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&, це має читатися за сигнатурою (там це вже видно), але бажано й за назвою. Інакше наступний читач коду — зокрема й ви в майбутньому — запускатиме програму «про всяк випадок» і перевірятиме, чи не ховається там черговий сюрприз.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ