JavaRush /Курси /C++ SELF /Перевантаження та аргументи за замовчуванням

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

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

1. Вступ

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

Уявіть, що ви робите консольний застосунок TaskPad — невеликий список задач. У нас уже є утиліти друку, меню, робота з рядками та std::vector<std::string>. І раптом хочеться друкувати «розділювач» то з '-', то з '=', то завдовжки 20, то завдовжки 40. Можна, звісно, зробити чотири функції print_line_dash_20(), print_line_dash_40()… але ми ж не пишемо манускрипт, висічений у камені.

Перевантаження: один зміст — кілька форм виклику

Перевантаження (overload) — це ситуація, коли в одній області видимості є кілька функцій з одним і тим самим імʼям, але різними параметрами. Відмінність може полягати в кількості параметрів, їхніх типах або порядку. Сенс перевантаження — дати одній дії одне імʼя, а деталі «форми виклику» довірити компілятору.

Почнімо з найприземленішого прикладу: друку лінії-розділювача.

#include <iostream>

void print_line(char ch, int count) {
    for (int i = 0; i < count; ++i) std::cout << ch;
    std::cout << '\n';
}

void print_line(int count) {               // перевантаження: інший список параметрів
    print_line('-', count);
}

Тепер код читається значно приємніше:

print_line(20);        // друкуємо "звичайну" лінію з '-'
print_line('=', 20);   // друкуємо "жирну" лінію з '='

Зверніть увагу на важливу деталь: друга версія print_line(int) не дублює цикл, а просто використовує першу версію як «базову». Так ви зменшуєте дублювання коду й ризик випадково щось забути в одній із версій, наприклад '\n'. Інакше потім доведеться зʼясовувати, чому консоль раптом «злиплася».

Як компілятор вибирає потрібне перевантаження

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

Зручно тримати в голові дуже просту модель вибору:

Ситуація під час виклику Що зазвичай відбувається
Є версія з точним збігом типів аргументів Вона майже завжди перемагає
Точного збігу немає, але тип можна «перетворити» (наприклад, int double) Компілятор може вибрати таке перевантаження
Підходять два перевантаження, і обидва виглядають «майже однаково добрими» Помилка: виклик неоднозначний
Не підходить жодне Помилка: немає відповідної функції

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

#include <iostream>

void show(int x) {
    std::cout << "int: " << x << '\n';
}

void show(double x) {
    std::cout << "double: " << x << '\n';
}

int main() {
    show(10);      // int: 10
    show(3.14);    // double: 3.14
}

Тут усе прозоро: 10 — це int, а 3.14 — це double.

А тепер приклад, де вже можна натрапити на несподіванку. Якщо зробити перевантаження надто схожими, компілятор інколи чесно скаже: «Я не екстрасенс».

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

Обмеження: не можна перевантажити лише за типом повернення

Зазвичай у цей момент у когось зʼявляється ідея: «А можна я зроблю int parse(...) і bool parse(...), і компілятор сам вибере за тим, куди я присвоюю?» На жаль, ні. У C++ перевантаження не може відрізнятися лише типом повернення: мають відрізнятися параметри. І це логічно. Коли ви пишете parse("123"), компілятору треба вибрати функцію тут і зараз, не вгадуючи, що ви зробите з результатом далі.

Ось приклад того, що не компілюється:

#include <string>

int parse(std::string s) {
    return 0;
}

bool parse(std::string s) {   // помилка: відрізняється лише типом повернення
    return true;
}

Тому, якщо вам потрібні два різні результати, ви або змінюєте імʼя (parse_int, parse_bool), або змінюєте параметри, наприклад додаєте режим парсингу, або інакше проєктуєте повертане значення. Але це вже тема пізніших лекцій.

2. Аргументи за замовчуванням: робимо «типовий випадок» коротшим

Аргументи за замовчуванням (default arguments) розвʼязують іншу проблему: не «дві функції», а «одна функція, яку найчастіше викликають однаково». Тоді можна один раз указати стандартні значення для частини параметрів і під час виклику не писати ці аргументи.

Повернімося до лінії-розділювача. Замість перевантаження можна зробити одну функцію:

#include <iostream>

void print_line(char ch = '-', int count = 20) {
    for (int i = 0; i < count; ++i) std::cout << ch;
    std::cout << '\n';
}

int main() {
    print_line();          // друкує 20 символів '-'
    print_line('=');       // друкує 20 символів '='
    print_line('=', 8);    // друкує 8 символів '='
}

Тут зʼявляється відчуття «зручної кнопки»: звичайний випадок ('-' і 20) узагалі не потребує параметрів, а нестандартні варіанти задаються явно.

Важливе правило: значення за замовчуванням можна задавати лише для хвостових параметрів, тобто тих, що стоять праворуч. Із погляду здорового глузду це теж логічно: якщо ви пропустили аргумент посередині, то як компілятор зрозуміє, який саме параметр ви не вказали?

Ось приклад того, як не можна:

// так не можна: після параметра зі значенням за замовчуванням іде параметр без нього
void f(int a = 10, int b) { }

Де писати значення за замовчуванням

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

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

#include <iostream>
#include <string>

void greet(std::string name = "World"); // значення за замовчуванням в оголошенні

int main() {
    greet();          // Hello, World!
    greet("Alice");   // Hello, Alice!
}

void greet(std::string name) {          // без "= ..."
    std::cout << "Hello, " << name << "!\n";
}

Чому так зручніше? Бо якщо ви випадково вкажете різні значення за замовчуванням у різних місцях, то отримаєте або помилку, або — у складніших випадках — дуже дивну поведінку. А разом із нею і чимало клопоту. Тримати значення за замовчуванням в одному місці — це як тримати ключі від квартири в одній кишені: можна й інакше, але навіщо.

3. Що вибрати і де тут пастка

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

Порівняймо це на прикладі друку заголовка меню в TaskPad. Ми хочемо друкувати рамку й текст. Іноді ширина за замовчуванням — 30, іноді — 50.

Варіант зі значенням за замовчуванням:

#include <iostream>
#include <string>

void print_title(std::string title, int width = 30) {
    std::cout << '[' << title << "] width=" << width << '\n';
}

Варіант із перевантаженням:

#include <iostream>
#include <string>

void print_title(std::string title, int width) {
    std::cout << '[' << title << "] width=" << width << '\n';
}

void print_title(std::string title) {
    print_title(title, 30);
}

Обидва варіанти цілком нормальні. Але на практиці відчуття таке:

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

Пастка: перевантаження та аргументи за замовчуванням — неоднозначність

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

Класичний приклад:

#include <iostream>

int area(int side) {
    return side * side;
}

int area(int w, int h = 1) {
    return w * h;
}

int main() {
    // std::cout << area(5) << '\n'; // неоднозначно: підходять обидві функції
    std::cout << area(5, 2) << '\n'; // ок
}

Виклик area(5) підходить і до area(int) напряму, і до area(int, int), бо другий параметр можна «домовчати» як 1. Компілятор не зобовʼязаний вгадувати, що саме ви мали на увазі.

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

4. Приклад TaskPad: робимо друк і введення зручнішими

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

Для початку — друк «шапки» та лінії.

#include <iostream>
#include <string>

void print_line(char ch = '-', int count = 30) {
    for (int i = 0; i < count; ++i) std::cout << ch;
    std::cout << '\n';
}

void print_header(std::string title) {
    print_line('=');
    std::cout << title << '\n';
    print_line('=');
}

Тепер main можна зробити схожим на сценарій, а не на суцільне «простирадло» з виводом:

print_header("TaskPad");
print_line();          // звичайна лінія
print_line('.', 10);   // лінія коротша й іншим символом

Наступний практичний фрагмент — друк меню. Тут зручно показати, що аргументи за замовчуванням допомагають не перевантажувати виклики зайвими параметрами. Ми хочемо друкувати нумероване меню, а ширину оформлення робити за замовчуванням 30.

#include <iostream>

void print_menu(int width = 30) {
    print_line('-', width);
    std::cout << "1) Додати задачу\n";
    std::cout << "2) Показати задачі\n";
    std::cout << "0) Вихід\n";
    print_line('-', width);
}

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

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

#include <iostream>
#include <string>

std::string read_line(std::string prompt = "> ") {
    std::cout << prompt;
    std::string s;
    std::getline(std::cin, s);
    return s;
}

Використання:

std::string cmd = read_line();                  // друкує "> "
std::string name = read_line("Назва задачі: "); // друкує "Назва задачі: "

Тепер невеликий момент про перевантаження: інколи хочеться, щоб read_line() умів повертати порожній рядок, і це нормально, а інколи — щоб користувач обовʼязково ввів щось непорожнє. Можна зробити перевантаження, яке приймає прапорець і вимагає непорожнього введення. У гіршому разі воно просто повторить запит. Ми не будуємо нескінченну «супервалідацію», а лише показуємо ідею.

#include <iostream>
#include <string>

std::string read_line(std::string prompt) {
    std::cout << prompt;
    std::string s;
    std::getline(std::cin, s);
    return s;
}

std::string read_line(std::string prompt, bool non_empty) {
    while (true) {
        std::string s = read_line(prompt);
        if (!non_empty || !s.empty()) return s;
        std::cout << "Введіть непорожній текст.\n";
    }
}

І виклики виглядають змістовно:

std::string a = read_line("> ");                   // проста версія
std::string b = read_line("Назва задачі: ", true); // вимагаємо непорожнє

Тут перевантаження доречне, бо сценарії справді різні: «просто прочитати» і «прочитати, але не порожнє». Можна було б зробити аргумент за замовчуванням bool non_empty = false, але тоді в новачків часто виникає запитання: «А що означає true другим параметром?» Перевантаження інколи робить код зрозумілішим саме завдяки різним формам виклику.

Нарешті — приклад того, як можна невдало поєднати перевантаження і значення за замовчуванням, і чому ми так не робимо в TaskPad. Якщо ви вирішите написати так:

std::string read_line(std::string prompt = "> ");
std::string read_line();

то дуже легко дійти до ситуацій, коли read_line() можна викликати різними шляхами, а далі починаються помилки й сюрпризи. Тому для однієї «родини» функцій ми обираємо один стиль.

5. Мінісхема вибору

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

flowchart TD
    A["Хочу зробити виклик функції зручнішим"] --> B{"Це один сценарій із налаштуваннями?"}
    B -->|Так| C["Аргументи за замовчуванням"]
    B -->|Ні| D{"Це різні сценарії, і форми виклику теж різні?"}
    D -->|Так| E["Перевантаження"]
    D -->|Ні| F["Краще перейменувати функцію або спростити інтерфейс"]

І важлива приписка, яку варто тримати в голові: «Не змішуйте перевантаження та аргументи за замовчуванням так, щоб один і той самий виклик підходив одразу до двох варіантів».

6. Типові помилки

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

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

Помилка № 3: поєднання перевантаження та аргумента за замовчуванням, яке створює неоднозначний виклик.
Найнеприємніша помилка, бо спочатку все виглядає цілком логічно — аж до першого повідомлення компілятора. Типовий сценарій: є f(int) і f(int, int = 0), і раптом f(5) стає неоднозначним. Це не баг компілятора. Це ваш інтерфейс сказав дві різні речі однією й тією самою формою. У таких місцях обирайте або перевантаження без значень за замовчуванням, або одну функцію зі значеннями за замовчуванням, але не обидва шляхи одночасно.

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

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

1
Опитування
Функції, рівень 14, лекція 4
Недоступний
Функції
Функції: сигнатури та return
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ