JavaRush /Курси /C++ SELF /const T&: читання без копіювання та привʼязування до ...

const T&: читання без копіювання та привʼязування до тимчасових обʼєктів

C++ SELF
Рівень 40 , Лекція 0
Відкрита

1. Навіщо потрібен const T&

Коли ви лише починаєте писати функції, параметри часто хочеться обирати за принципом «аби компілювалося». Але доволі швидко стає зрозуміло, що копіювання великих обʼєктів — рядків, векторів, структур із багатьма полями — може коштувати дорого, а T& — це надто «сильний» контракт: він означає, що функція може змінювати аргумент. const T& — золота середина: без копіювання, але лише для читання. До того ж воно добре працює з тимчасовими значеннями.

Зручно думати про це так: const T& — це «я візьму ваш обʼєкт у користування на час виклику й зобовʼяжуся нічого в ньому не змінювати».

Що означає const у const T&

Почнімо з короткого пояснення: слово const у C++ часто лякає новачків, бо здається, ніби «все стає забороненим». Насправді const найчастіше не про заборони, а про контракт. Ви повідомляєте компілятору й людині, яка читає код: «через це імʼя я не змінюватиму обʼєкт». І якщо ви випадково спробуєте це зробити, компілятор вас зупинить — як охоронець у музеї: «руками не чіпати».

Ключова ідея:

const T& — це посилання на T, але через це посилання не можна змінювати обʼєкт.

При цьому сам обʼєкт може бути й не const.

Подивімося на короткий приклад:

#include <iostream>

int main() {
    int x = 10;
    const int& r = x;   // r — “читальний псевдонім” x

    std::cout << r << '\n'; // 10
    // r += 1; // помилка компіляції: не можна змінювати через const-посилання
}

Тут x — цілком звичайний int, і його можна змінювати. Але конкретно через імʼя r — ні. У цьому й полягає контракт.

До чого можна привʼязати const T&

Посилання в C++ привʼязуються за певними правилами, які часто називають binding rules. Саме тому const T& у реальному коді трапляється значно частіше, ніж просто T&.

Пояснення просте: T& — це посилання, через яке можна змінювати обʼєкт, а const T& дає змогу лише читати його. Отже, const T& можна привʼязати в більшій кількості випадків, бо це безпечніше: ви не зможете змінити те, чого змінювати не можна або не варто.

Мінітаблиця сумісності

Що маємо праворуч (вираз) Можна привʼязати до T& Можна привʼязати до const T&
Звичайна змінна T x Так Так
Константа const T cx Ні Так
Тимчасове значення (temporary), наприклад T() Ні Так

Це спрощена, інтуїтивна версія правил — на нашому рівні її цілком достатньо. У стандарті є формальні формулювання, а також окремі обговорення на кшталт «temporaries bound to references» і «lifetime extension», бо тема тут справді тонка.

Приклад: T& не привʼязується до const T

#include <string>

int main() {
    const std::string name = "Alice";

    // std::string& bad = name; // помилка: не можна дати “змінювальне” посилання на const-обʼєкт
    const std::string& ok = name; // норм: я обіцяю не змінювати
    (void)ok;
}

Логіка тут проста: якби std::string& можна було привʼязати до const std::string, ви змогли б змінити «константний» рядок. А це руйнує саму ідею const.

2. Тимчасові обʼєкти та продовження часу життя

Що таке тимчасові обʼєкти і чому const T& з ними добре поєднується

Тимчасові обʼєкти — це своєрідні «одноденки» C++: вони зʼявляються як результат виразу й зазвичай дуже швидко зникають. Новачки часто не помічають їхнього існування, бо вони не мають імені. Але компілятор їх створює, і правила часу життя тут справді важливі.

Приклади тимчасових значень:

  • результат a + b для чисел,
  • результат std::string("Hi"),
  • результат конкатенації рядків s1 + s2.

Приклад: const int& до тимчасового числа

#include <iostream>

int main() {
    const int& answer = 42;      // 42 — тимчасове значення (temporary)
    std::cout << answer << '\n'; // 42
}

Чому це дозволено? Тому що answer не може змінити тимчасовий обʼєкт — він const. Отже, це безпечно.

А от із int& так не можна:

int main() {
    // int& r = 42; // помилка: не можна привʼязати T& до тимчасового
}

Продовження часу життя тимчасового

Тут важливо не поспішати: тимчасові обʼєкти зазвичай живуть недовго. Часто — до кінця «повного виразу»; у розмовній мові можна сказати «до кінця цього рядка або виразу». Але є важливе правило: якщо тимчасовий обʼєкт привʼязано до змінної типу const T&, то його час життя продовжується до кінця області видимості цього посилання.

Саме тому const T& таке популярне. І так, навколо цього правила є окремі тонкощі й дискусії у стандарті — наприклад, про «lifetime extension of references» у різних ситуаціях.

Приклад: тимчасовий рядок «живе довше», ніж здається

#include <iostream>
#include <string>

int main() {
    const std::string& s = std::string("hello"); // тимчасовий std::string
    std::cout << s << '\n'; // hello
}

Якби не це правило, конструкція виглядала б як «посилання на рядок, що зникає». Але тут усе гаразд: тимчасовий обʼєкт живе стільки ж, скільки й s.

Схема часу життя

flowchart LR
    A["Створили тимчасовий обʼєкт: std::string('hello')"] --> B["Привʼязали до const std::string& s"]
    B --> C["Тимчасовий обʼєкт живе до кінця області видимості змінної s"]
    C --> D["Виходимо з області видимості: s знищується, і тимчасовий обʼєкт теж"]

Якщо mermaid у вашому середовищі не відображається — не страшно. Сенс простий: посилання s ніби «утримує» тимчасовий обʼєкт живим, але лише в межах своєї області видимості.

Тимчасові й параметри функцій: де межа безпеки

Тут важливо зробити чесне уточнення, щоб не створити небезпечний міф: «будь-яке const-посилання робить усе безсмертним». Якщо const T& — це параметр функції, то тимчасове значення, передане у виклик, живе достатньо довго, щоб функція спокійно виконалася. Але це не означає, що ви можете винести це посилання назовні й користуватися ним потім.

На практиці краще запамʼятати таку обережну модель: «параметр const T& безпечний для передавання тимчасових значень, бо тимчасовий обʼєкт переживає виклик функції». А далі, після виходу з функції, жодних гарантій уже немає.

Ось безпечний приклад:

#include <iostream>
#include <string>

void print_line(const std::string& text) {
    std::cout << text << '\n';
}

int main() {
    print_line("Hi!"); // рядковий літерал перетворюється на тимчасовий std::string
}

Тут усе добре: тимчасовий рядок існує протягом усього виклику print_line.

Але якщо спробувати всередині print_line «зберегти посилання десь глобально», ви дуже швидко опинитеся у світі висячих посилань. Цю тему ми докладно розберемо в лекціях про повернення посилань і час життя обʼєктів. Сьогодні — лише вступ, без занурення в найпідступніші пастки.

3. Практика: параметри функцій і auto

const T& як основний параметр лише для читання

Найпоширеніша реальна ситуація така: ви пишете функцію, яка має прочитати рядок, вектор або вашу структуру-модель, але не повинна нічого змінювати. Якщо прийняти параметр за значенням, ви створите копію. Якщо прийняти T&, то дозволите функції змінювати обʼєкт і водночас забороните передавати тимчасові значення. Тому типовий вибір тут — const T&.

Це настільки поширений підхід, що в C++ є неформальне правило: «якщо функція не має змінювати обʼєкт, а копіювати його дорого — приймайте const&». Для int це зазвичай не потрібно, а для std::string і std::vector — майже завжди доречно.

Приклад: довжина рядка без копіювання

#include <iostream>
#include <string>

std::size_t len(const std::string& s) {
    return s.size();
}

int main() {
    std::string name = "Alice";
    std::cout << len(name) << '\n';    // 5
    std::cout << len("Hello") << '\n'; // 5
}

Зверніть увагу: len("Hello") працює саме тому, що параметр має тип const std::string&. Із std::string& так не вийшло б.

Приклад із застосунку: друк Task

Уявімо, що на цьому етапі курсу в нас уже є маленький консольний застосунок «Список завдань». Не тому, що всі зобовʼязані писати TODO-список, а тому, що цей приклад простий і нікуди від нас не тікає.

Модель завдання може бути такою:

#include <string>

struct Task {
    int id = 0;
    std::string title;
    bool done = false;
};

Тепер ми хочемо виводити завдання на екран. Виведення — це читання полів, але не їх зміна. Отже, це прямий кандидат на const Task&.

#include <iostream>
#include <string>

struct Task {
    int id = 0;
    std::string title;
    bool done = false;
};

void print_task(const Task& t) {
    std::cout << "#" << t.id << " " << t.title;
    std::cout << (t.done ? " [done]\n" : " [todo]\n");
}

int main() {
    Task a{1, "Read about const references", false};
    print_task(a); // #1 Read about const references [todo]
}

Чому це хороший стиль?

  • По-перше, ми не копіюємо Task, а всередині неї є std::string, копіювання якого може бути відчутно дорожчим за копіювання int.
  • По-друге, за сигнатурою одразу видно: функція не має права змінювати завдання. Тобто якщо ви випадково додасте всередині t рядок на кшталт t.done = true;, компілятор вас зупинить.

Часта навчальна помилка: прийняти Task за значенням

void print_task(Task t) { // копія!
    // ...
}

Так писати не заборонено, і на маленьких прикладах це працює. Але це трохи як їздити в магазин по хліб на вантажівці: можна, але дивно.

Нюанс із auto: як не створити зайву копію

Іноді ви пишете ось так:

auto x = something();

І думаєте, що x — це посилання. Але auto без & створює копію, якщо вираз праворуч — посилання. Це не помилка мови: ви просто попросили саме копію.

У контексті const T& корисно виробити таку звичку: якщо ви хочете «просто почитати, без копій», то найчастіше вам потрібен const auto&.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30};

    const auto& first = v[0]; // читаємо без копії (хоча int і так дешевий)
    std::cout << first << '\n'; // 10
}

Так, для int це не критично. Але для std::string і ваших моделей це вже цілком може мати значення.

4. Типові помилки під час роботи з const T&

Помилка № 1: намагатися змінити обʼєкт через const T& і сердитися на компілятор.
Компілятор не шкідничає — він буквально виконує ваше прохання. Якщо параметр має тип const Task& t, то ви самі пообіцяли «не змінювати». Якщо за логікою програми обʼєкт усе ж треба змінювати, контракт має бути іншим: Task& (змінюю обовʼязково) або Task* (змінюю, якщо вказівник не nullptr). Але це вже окрема розмова про дизайн API.

Помилка № 2: думати, що const T& завжди продовжує час життя тимчасового «назавжди».
Продовження часу життя працює лише в конкретних сценаріях. Зокрема тоді, коли тимчасовий обʼєкт привʼязується до змінної const T& під час ініціалізації. Це правило справді існує, але воно не робить тимчасові обʼєкти безсмертними: межа життя в них усе одно є, просто інколи вона ширша, ніж «до кінця рядка».

Помилка № 3: зберігати const T& довше, ніж живе обʼєкт, на який воно посилається.
const не робить посилання «безпечним» щодо часу життя. Воно лише забороняє зміну. Якщо обʼєкт знищено, посилання стає висячим — і це вже не про «можна чи не можна змінювати», а про те, що самого обʼєкта більше немає. Цю думку особливо важливо тримати в голові, коли ви зберігаєте посилання на елементи контейнерів або на локальні змінні з інших областей видимості.

Помилка № 4: ставити T& усюди за звичкою, а потім дивуватися, що функція не приймає тимчасові значення.
Якщо функція не має змінювати аргумент, краще одразу писати const T&. Тоді ви зможете передавати і звичайні змінні, і const-обʼєкти, і тимчасові значення. Це робить API дружнішим і простішим у використанні, особливо для допоміжних функцій на кшталт друку, перевірки чи форматування.

Помилка № 5: «оптимізувати» все підряд через const&, навіть маленькі типи.
Для int, double, char передавання за значенням зазвичай простіше й не гірше. const& — не магічна кнопка «прискорити програму», а інструмент для випадків, коли копіювання справді відчутне або коли вам важливо приймати тимчасові значення без окремого перевантаження. І так, інколи const& використовують і для маленьких типів — заради єдності стилю. Але це вже питання домовленостей у команді.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ