1. Чому компілятор «не бачить» функцію
Коли ви починаєте виносити логіку з main у функції, виникає природне бажання: спершу написати main як зрозумілий сценарій, а реалізацію функцій залишити нижче — «бо деталі потім». І саме тут компілятор раптом вмикає режим суворого вчителя: «Я не розумію, що таке print_menu()». Програма виглядає логічно, але компілятор — не телепат.
Уявіть такий код:
#include <iostream>
int main() {
print_menu(); // помилка: функція ще "не відома"
std::cout << "Done\n"; // Done
}
Якщо ви спробуєте це зібрати, компілятор скаже щось приблизно таке: print_menu was not declared in this scope (або покаже схоже повідомлення у вашому середовищі). Сенс один: у місці виклику функції компілятор ще не бачив її оголошення.
І тут важливо зафіксувати головну думку: компілятор не «запускає програму й дивиться, що буде». Спочатку він читає файл і будує модель програми, а вже потім перетворює її на виконуваний код.
Компілятор читає файл зверху вниз
Звучить банально. Але саме тут на практиці виникає половина типових помилок новачків, повʼязаних із функціями. Компілятор обробляє один .cpp-файл як текст: від першого рядка до останнього. Коли він натрапляє на виклик print_menu(), йому вже потрібно розуміти, що це за сутність: чи це функція, які параметри вона має та що повертає.
Можна уявити це як читання книжки. Якщо в першому розділі зʼявляється персонаж «Ґендальф», а автор лише в пʼятому пояснює, хто це взагалі такий, читачеві буде дивно. Компілятор, на відміну від читача, не любить принципу «потім поясню». Йому подобається, коли «паспорт героя» показують заздалегідь.
Невелика блок-схема того, як це працює:
flowchart TD
A[Компілятор читає файл зверху вниз] --> B{"Натрапив на виклик name(...)?"}
B -->|Так| C{Знає сигнатуру name?}
C -->|Так| D[Перевіряє аргументи й компілює виклик]
C -->|Ні| E[Помилка: функцію не оголошено]
B -->|Ні| A
Звідси випливає правило сьогоднішньої лекції:
Функція має бути оголошена до місця, де ви її викликаєте, у межах файлу.
2. Оголошення, визначення та прототип
«Оголошення» і «визначення» — одне імʼя, дві ролі
Щоб розвʼязати проблему «не видно функцію», потрібно розрізняти два дуже схожі терміни: оголошення і визначення. Новачкам часто здається, що це одне й те саме, але насправді вони виконують різні ролі.
Проста життєва аналогія: оголошення — це як табличка на дверях «Кабінет 305, терапевт». Визначення — це вже сам лікар у кабінеті, який справді приймає пацієнтів. Якщо таблички немає, ви можете навіть не здогадатися, що тут можна лікуватися, доки не зайдете випадково.
Такою табличкою для функції є прототип (тобто оголошення функції без тіла):
int sum(int a, int b); // оголошення (прототип)
А «лікар у кабінеті» — це визначення (оголошення + тіло):
int sum(int a, int b) { // визначення
return a + b;
}
Зберімо це в компактну таблицю:
| Що це | Як виглядає | Навіщо потрібно |
|---|---|---|
| Оголошення (прототип) | |
Повідомити компілятору: «така функція існує, ось її сигнатура» |
| Визначення (реалізація) | |
Дати реальний код, який буде виконано |
Невелика термінологічна ремарка: у стандартах і технічних текстах частіше кажуть declaration («оголошення»), а слово prototype зазвичай сприймають як розмовне спрощення.
Прототип функції: «покажіть паспорт заздалегідь»
Коли ви пишете прототип, то буквально даєте компілятору «паспорт» функції: імʼя, тип результату та список параметрів. Після цього main може стояти вище, а визначення — нижче, і все одно все працюватиме.
Виправімо приклад із print_menu() правильно: додамо прототип вище за main.
#include <iostream>
void print_menu(); // прототип
int main() {
print_menu();
std::cout << "Done\n"; // Done
}
Тепер компілятор уже знає, що print_menu — це функція, яка нічого не повертає (void) і не приймає аргументів (()).
А де ж тіло? Нижче:
#include <iostream>
void print_menu(); // прототип
int main() {
print_menu();
}
void print_menu() { // визначення
std::cout << "1) Start\n"; // 1) Start
}
Зверніть увагу на одну дрібницю, яка дуже часто ламає компіляцію: прототип закінчується крапкою з комою. Так само, як оголошення змінної. Якщо ви забудете ;, компілятор вирішить, що ви почали визначення, і далі піде каскад дивних помилок.
У прототипі можна не писати імена параметрів
Так, можна. Компілятору важливі типи та порядок параметрів, а не імена. Імена потрібні людям — тобто вам самим за тиждень — для читабельності.
Ось так можна:
int sum(int, int); // імена параметрів опущено
А ось так читабельніше:
int sum(int a, int b); // імена допомагають зрозуміти сенс
У навчальному коді зазвичай краще писати імена, тому що прототип — це не лише «інструкція для компілятора», а й коротка документація: що саме функція хоче отримати на вхід.
Прототип і визначення мають збігатися
Є й підступна ситуація: ви додали прототип, програма почала «бачити функцію», але потім змінили визначення й забули оновити прототип. Або навпаки. У результаті компілятор може видати зовсім неочікувану помилку, бо він перевіряє виклики за прототипом, а потім бачить визначення, яке з ним не збігається.
Наприклад, ось прототип каже, що функція приймає int:
int square(int x); // прототип
А визначення раптом приймає double:
int square(double x) { // не збігається з прототипом!
return static_cast<int>(x * x);
}
По суті це вже різні функції, і компілятор буде незадоволений. У кращому разі ви отримаєте помилку компіляції, у гіршому — почнете «лагодити не той рядок», бо повідомлення про помилку стосуватиметься невідповідності оголошень.
Правило тут просте й корисне: прототип — це контракт, а визначення — реалізація цього контракту. Контракт не можна змінювати «тихо» в одному місці.
3. Як організувати код у файлі
Чи можна просто переставити функції місцями
Можна — і часом це навіть простіше. Якщо функція маленька й логічно розташовується вище за main, можна просто написати її реалізацію до main, і жодні прототипи не знадобляться.
Так теж правильно:
#include <iostream>
void print_menu() {
std::cout << "1) Start\n"; // 1) Start
}
int main() {
print_menu();
}
Але щойно функцій стає багато, файл перетворюється на «простирадло», де main зʼїжджає кудись униз, і ви втрачаєте ідею «main як сценарій». Тому в реальних проєктах — і навіть у навчальних, коли ви пишете вже понад 50 рядків, — дуже часто роблять так: зверху прототипи, потім main, а далі реалізації.
Це не «єдиний правильний стиль», але це хороший компроміс для читабельності.
Міні-структура файлу для навчальних проєктів
Зараз ми все ще працюємо в одному файлі, без .hpp/.cpp і без розбиття на модулі. Але вже хочеться порядку. Хороша міні-структура для навчального проєкту виглядає так: угорі — підключення бібліотек, потім прототипи, далі main, а після нього — визначення функцій.
Скелет виглядає приблизно так:
#include <iostream>
#include <string>
void print_title();
int read_int(std::string prompt);
int main() {
// сценарій програми
}
void print_title() {
// деталі реалізації
}
int read_int(std::string prompt) {
// деталі реалізації
return 0;
}
Сенс цього підходу дуже практичний: ви відкриваєте файл і відразу бачите «публічний інтерфейс» (прототипи) та основний сценарій (main). А якщо потрібно розібратися в деталях, просто прокручуєте сторінку нижче.
4. Практичний приклад: міні-застосунок NumberBuddy
Продовжімо лінію «робимо маленький, але цілісний консольний застосунок». Нехай сьогодні це буде NumberBuddy: програма друкує заголовок, запитує число й виводить суму цифр. Логіка проста, зате ми добре потренуємося в порядку коду та роботі з прототипами.
Почнімо з прототипів
Спочатку опишемо, які функції в нас узагалі є. Це як зміст: за ним одразу видно, що вміє програма, навіть без читання реалізації.
#include <iostream>
#include <string>
void print_title();
int read_int(std::string prompt);
int sum_digits(int n);
Тут уже видно ідею: одна функція друкує заголовок, друга читає число, а третя рахує суму цифр.
main як сценарій
main буде коротким і «розмовним»: заголовок → введення → обчислення → виведення. Зауважте: деталі введення й обчислення ми ховаємо, а main читається майже як інструкція.
int main() {
print_title();
int n = read_int("Enter a number: ");
int s = sum_digits(n);
std::cout << "Sum of digits = " << s << '\n'; // наприклад: Sum of digits = 15
}
Навіть якщо ви забули, як саме рахувати суму цифр, main усе одно лишається зрозумілим.
Реалізація print_title()
Далі, нижче у файлі, розміщуємо реалізації функцій. Почнімо з найпростішої — виведення заголовка.
void print_title() {
std::cout << "=== NumberBuddy ===\n"; // === NumberBuddy ===
}
Реалізація read_int(...)
Зробімо «ввічливу» функцію, яка друкує підказку й читає число. Ми поки що не заглиблюємося у складне опрацювання некоректного введення (це тема окремих занять), тому беремо простий варіант.
int read_int(std::string prompt) {
std::cout << prompt; // Enter a number:
int x = 0;
std::cin >> x;
return x;
}
Реалізація sum_digits(n)
Функція підсумовування цифр — класика. Беремо останню цифру через % 10, прибираємо її через / 10 і повторюємо це, доки число не стане 0.
int sum_digits(int n) {
if (n < 0) n = -n; // трохи доброти до відʼємних
int sum = 0;
while (n > 0) {
sum += (n % 10);
n /= 10;
}
return sum;
}
Без прототипів цей самий код не скомпілюється
Якщо ви залишите main зверху, а визначення функцій — нижче, але приберете прототипи, компілятор дійде до print_title() і скаже: «Не знаю, що це». І матиме рацію: до визначення він іще не дійшов.
Саме тому прототипи — не «формальність заради формальності», а інструмент, який дає змогу писати програму в зручному для людини порядку: спочатку сценарій, потім деталі.
5. Типові помилки під час роботи з прототипами та порядком коду
Помилка №1: забули крапку з комою після прототипу.
Це одна з найприкріших помилок, бо виглядає дрібницею, а ламає багато. Прототип — це оголошення, а оголошення в C++ зазвичай закінчуються ;. Якщо ; немає, компілятор думає, що далі має бути тіло {...}, і починає «здогадуватися» про ваш код — часто дуже творчо.
Помилка №2: прототип і визначення не збігаються.
Іноді ви змінюєте тип параметра або додаєте ще один параметр у визначенні, а прототип не оновлюєте. Тоді main перевіряється за старим контрактом, а нижче у файлі виявляється інша функція. Підсумок — помилки невідповідності оголошень або загадкові повідомлення про те, що «визначення не відповідає попередньому оголошенню».
Помилка №3: переплутали оголошення функції й оголошення змінної.
Новачки інколи пишуть щось на кшталт int sum;, думаючи, що «оголосили функцію sum». Але функція завжди має круглі дужки (...). Навіть якщо параметрів немає, однаково потрібні (): void hello();. Без дужок це буде змінна, і далі під час виклику hello() ви отримаєте доволі дивні помилки.
Помилка №4: два визначення однієї й тієї самої функції в одному файлі.
Із прототипами можна переборщити, але вже в інший спосіб: ви копіюєте реалізацію, вставляєте «ще одну таку саму» нижче й забуваєте видалити стару. Однакових оголошень може бути кілька, а визначення має бути одне — інакше компілятор або лінкер почне скаржитися на повтор.
Помилка №5: спроба «сховати все в прототипах» і втратити читабельність.
Іноді хочеться написати прототипи без імен параметрів, щоб було «коротше». Компілятору байдуже, а от вам — ні. У навчальному коді імена в прототипах дуже допомагають: int read_int(std::string prompt) читається як документація. Якщо написати read_int(std::string), за кілька днів ви вже згадуватимете, що це за рядок і навіщо він потрібен.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ