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», а радше «ребус». У такому разі краще або перейменувати функції, або зробити одну функцію з параметрами, які явно відображають налаштування.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ