JavaRush /Курси /C++ SELF /Передавання за значенням: копія та ціна копіювання

Передавання за значенням: копія та ціна копіювання

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

1. Вступ

Коли ви починаєте писати функції, дуже легко думати так: «Я передав змінну у функцію — отже, функція працює з моєю змінною». Це цілком природне очікування, особливо якщо ви ще не звикли до правил мови. Проте проблема в тому, що C++ (як і багато інших мов) уміє передавати аргументи різними способами, і найпростіший із них — за значенням — насправді створює копію. А копія може бути як майже безплатною (int), так і відчутно дорогою (std::string, std::vector).

Сьогоднішня мета проста: сформувати у вас звичку читати сигнатуру функції як контракт. Побачили T x — подумки сказали: «Окей, буде копія». А далі вже вирішуєте: «Мене це влаштовує чи я випадково запускаю копіювальний цех?».

Передавання за значенням: у функцію потрапляє копія

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

На рівні відчуттів це схоже на ситуацію, коли ви дали другові не свій паспорт, а ксерокопію. Друг може малювати на ній вуса маркером скільки завгодно — ваш оригінал залишиться цілим. Із даними так само: якщо змінити параметр усередині функції, початкова змінна в main() не зміниться.

Подивімося на схему на «побутовому» рівні, без заглиблення в деталі памʼяті:

flowchart LR
    A["main(): змінна a"] -->|копіювання| B["func(x): параметр x"]
    B --> C[змінюємо x]
    C --> D[виходимо з func, x зникає]
    A --> E[а в main змінна a лишається незмінною]

Приклад 1: int за значенням — змінили всередині, зовні нічого не змінилося

#include <iostream>

void set_zero(int x) {
    x = 0;
}

int main() {
    int a = 5;
    set_zero(a);
    std::cout << a << '\n'; // 5
}

Тут x — копія a, тому a і далі дорівнює 5.

Чому з int усе просто, а зі string/vector — уже складніше

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

Умовно можна тримати в голові таку таблицю:

Тип параметра Що приблизно копіюється при T x Зазвичай відчувається як
int, double, bool, char кілька байтів майже безплатно
std::string символи рядка (у загальному випадку) може бути помітно
std::vector<int> усі елементи вектора часто помітно
std::vector<std::string> багато рядків, і кожен із них теж копіюється помітно і за часом, і за памʼяттю

Фраза «у загальному випадку» тут дуже важлива: у стандартній бібліотеці є оптимізації, але міркувати варто як інженер. Що більший обсяг даних, то дорожче копіювання. І якщо ви випадково копіюєте дані в кожній другій функції, застосунок починає гальмувати без видимої причини.

2. std::string за значенням: копія тексту та «невидимі витрати»

std::string виглядає як один обʼєкт, але по суті це контейнер символів. Коли ви копіюєте рядок, то ніби переписуєте його повністю в новий зошит. Іноді рядок короткий, і ви цього навіть не помітите. Але якщо в ньому тисяча символів і ви робите так багато разів, копіювання перетворюється на постійну фонову роботу.

Приклад 2: функція змінює рядок, але початковий рядок не змінюється

#include <iostream>
#include <string>

void add_exclamation(std::string s) {
    s += "!";
    std::cout << s << '\n'; // Hello!
}

int main() {
    std::string msg = "Hello";
    add_exclamation(msg);
    std::cout << msg << '\n'; // Hello
}

Тут s — копія msg. Це зручно, коли ви справді хочете працювати з копією. І незручно, коли ви помилково думаєте, що змінюєте оригінал.

Приклад 3: «функція-перетворювач» — передавання за значенням доречне

Дуже поширений і зручний стиль: функція бере значення, створює змінену версію й повертає її. У такому разі передавання за значенням виглядає цілком логічно: «Я отримав власний екземпляр, змінив його й повернув результат».

#include <iostream>
#include <string>

std::string make_tag(std::string s) {
    s = "[" + s + "]";
    return s;
}

int main() {
    std::string x = "TODO";
    std::cout << make_tag(x) << '\n'; // [TODO]
    std::cout << x << '\n';           // TODO
}

Такий код читається як чисте перетворення. Так, копія є, але тут вона відповідає змісту: ви не хочете «побічного ефекту» у вигляді зміни x.

3. std::vector за значенням: копія елементів і O(N)

Із std::vector це видно ще наочніше. Вектор — контейнер, у якому може бути багато елементів. Передати вектор за значенням — означає створити новий вектор і скопіювати до нього всі елементи. Якщо елементів N, то копіювання потребує роботи, приблизно пропорційної N. Тобто інтуїтивно це O(N): що більший вектор, то довше триває копіювання.

Саме тому новачки часто дивуються: «Чому мій код із малими даними швидкий, а з великими — раптом повільний?» Дуже часто відповідь проста: «Тому що ви копіюєте вектор у кожній функції».

Приклад 4: зміна копії вектора не впливає на початковий вектор

#include <iostream>
#include <vector>

void push_demo(std::vector<int> v) {
    v.push_back(99);
    std::cout << v.size() << '\n'; // 4
}

int main() {
    std::vector<int> data{1, 2, 3};
    push_demo(data);
    std::cout << data.size() << '\n'; // 3
}

Ззовні data залишився незмінним. Усередині push_demo() ми змінили лише копію v.

Приклад 5: копіювання вектора — це копіювання елементів

Покажемо це не через вимірювання часу, бо поки що ми не вміємо робити цього як слід, а через сам зміст:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> a{1, 2, 3};
    std::vector<int> b = a; // копія всіх елементів

    b[0] = 100;
    std::cout << a[0] << '\n'; // 1
    std::cout << b[0] << '\n'; // 100
}

Якби b був просто «іншим імʼям для a», то a[0] теж став би 100. Але цього не сталося. Отже, b — окремий контейнер зі своїми елементами.

4. Корисні нюанси: const, перетворювачі та «приховані копії»

const T x — це все одно копія

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

const забороняє змінювати локальну змінну всередині функції, але не скасовує самого факту створення локальної змінної. Тобто копія як створювалася, так і створюється.

Приклад 6: const std::string s не рятує від копіювання

#include <iostream>
#include <string>

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

int main() {
    std::string text = "abcdef";
    std::cout << len(text) << '\n'; // 6
}

Тут рядок однаково копіюється, просто всередині len() ви не зможете написати s += "!".

Якщо коротко: const — це про заборону змін, а не про швидкість.

Коли передавання за значенням — хороший вибір

Важливо не впасти в іншу крайність: «за значенням — значить погано, я так більше ніколи не робитиму». Це приблизно як вирішити: «раз вогонь обпікає, я більше ніколи не готуватиму їжу». Вогонь корисний, якщо розуміти, де плита, а де штори.

Передавання за значенням доречне в тих ситуаціях, коли за змістом ви хочете, щоб функція працювала з незалежним значенням. Зазвичай це один із двох випадків.

Перший випадок — маленькі прості типи. Якщо параметр має тип int, то передавання за значенням виглядає цілком нормально, читається простіше й не ускладнює контракт там, де це не потрібно.

Другий випадок — функції-перетворювачі, де ви навмисно отримуєте копію, змінюєте її та повертаєте нову версію. Це робить код передбачуваним: немає несподіваних змін «десь у глибині».

Приклад 7: перетворюємо список справ і повертаємо новий

Ми й далі розвиваємо наш консольний застосунок «Список справ»: поки що він зберігає справи як рядки у std::vector<std::string>. Тепер зробимо функцію, яка додає префікс "[TODO] " до всіх справ і повертає новий список. Так, копіювання тут є — але це частина самої ідеї: ми хочемо отримати саме новий список.

#include <string>
#include <vector>

std::vector<std::string> add_todo_prefix(std::vector<std::string> tasks) {
    for (std::string& t : tasks) {
        t = "[TODO] " + t;
    }
    return tasks;
}

Зверніть увагу: ми не змінюємо початковий список ззовні, а будуємо новий результат. Це стиль «отримав → перетворив → повернув».

Приклад 8: використовуємо перетворення в main()

#include <iostream>
#include <string>
#include <vector>

std::vector<std::string> add_todo_prefix(std::vector<std::string> tasks) {
    for (std::string& t : tasks) t = "[TODO] " + t;
    return tasks;
}

int main() {
    std::vector<std::string> tasks{"buy milk", "learn C++"};
    auto tagged = add_todo_prefix(tasks);

    std::cout << tasks[0] << '\n';  // buy milk
    std::cout << tagged[0] << '\n'; // [TODO] buy milk
}

Це дуже читабельний контракт: вхід не змінюємо, а на виході отримуємо нове значення.

«Прихована копія»: як випадково сповільнити застосунок

Зараз розглянемо дуже життєвий фрагмент: функція друкує список справ. Інтуїтивно хочеться написати так: «Ну, передам вектор і надрукую». І зробити параметр за значенням. Код працюватиме, але при цьому щоразу копіюватиме весь список. Якщо список із двох справ — байдуже. Якщо список із десяти тисяч справ — уже ні, і ви почнете підозрювати, що компʼютер образився особисто на вас.

Приклад 9: друк списку, але із зайвою копією

#include <iostream>
#include <string>
#include <vector>

void print_tasks(std::vector<std::string> tasks) {
    std::cout << "Tasks: " << tasks.size() << '\n';
}

int main() {
    std::vector<std::string> tasks{"buy milk", "learn C++"};
    print_tasks(tasks); // вивід: Tasks: 2
}

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

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

5. Типові помилки під час передавання за значенням

Помилка № 1: очікувати, що T x змінить змінну ззовні.
Це класика. Людина пише void clear(std::string s) { s = ""; }, викликає clear(name), а рядок не очищується. Причина проста: s — копія. У таких ситуаціях корисно прямо проговорювати: «Параметр — це локальна змінна». Якщо вам потрібно змінити початковий обʼєкт, це має бути виражено в контракті функції (але деталі такого контракту ми розберемо в наступній лекції).

Помилка № 2: «випадково» копіювати std::vector і std::string у функціях, які лише читають.
Такі функції виглядають нешкідливо: print, count, sum, has_word. Але якщо вони приймають великий обʼєкт за значенням, то кожна перевірка перетворюється на «спочатку скопіюй дані, а вже потім подивися». Це особливо неприємно в циклах: ви можете, самі того не помітивши, зробити «копіювання N разів», і застосунок стане повільним без очевидної причини.

Помилка № 3: намагатися виправити копіювання за допомогою const.
const справді корисний, але він розвʼязує іншу задачу: захищає від випадкової зміни. Він не скасовує копію. Тому const std::vector<int> v — це «копія, яку не можна змінювати», а не «без копії».

Помилка № 4: змішувати два різні стилі в одній функції: і «змінюю», і «повертаю».
Наприклад, функція отримує std::string s (копію), змінює її і ще намагається «змінити початковий рядок» у коді виклику, що неможливо при передаванні за значенням. Такий код породжує плутанину: де ж насправді живе результат? Звикайте обирати один стиль: або функція просто повертає нове значення, або явно змінює переданий обʼєкт (але це вже наступний крок у навчанні).

Помилка № 5: робити дорогу копію «заради однієї перевірки».
Іноді пишуть щось на кшталт: «я хотів просто дізнатися розмір вектора, але передав його у функцію за значенням і викликав size()». Виходить так, ніби ви купуєте холодильник заради одного кубика льоду. Якщо операція читання маленька, а копіювання велике, ви платите не за те, що вам насправді потрібно.

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