C++

Функції: прототипи, область видимості та додаткові можливості

Прототипи функцій у C++. Локальні та глобальні змінні. Область видимості. Параметри за замовчуванням. Статичні локальні змінні. Рекурсія.

Проблема порядку визначення

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

int main()
{
    int result = add(3, 5);  // ❌ Помилка! add ще не визначена
    return 0;
}

int add(int a, int b)
{
    return a + b;
}

Компілятор читає файл зверху вниз. Коли він бачить виклик add(3, 5) у main, він ще не знає, що таке add — і видає помилку. Перенести add вище — здається простим рішенням, але що якщо функцій сотні? Або дві функції викликають одна одну (взаємна рекурсія)?

Рішення — прототип функції.

Прототипи функцій

Прототип (function prototype / forward declaration) — оголошення функції без тіла: лише підпис (сигнатура), що повідомляє компілятору ім'я функції, тип повернення та типи параметрів. Після прототипу компілятор знає, як правильно інтерпретувати виклики — навіть без повного визначення.

// Прототип: лише підпис, крапка з комою в кінці
int add(int a, int b);

int main()
{
    int result = add(3, 5);  // ✅ Компілятор знає: add приймає два int, повертає int
    cout << result << "\n";
    return 0;
}

// Визначення може бути нижче
int add(int a, int b)
{
    return a + b;
}

Прототип — це обіцянка компілятору: «десь у цьому файлі (або у підключеному заголовку) є функція з таким підписом». Якщо обіцянку не виконати (не написати визначення) — отримаємо помилку при компонуванні (linker error), а не при компіляції.

Імена параметрів у прототипі — необов'язкові

У прототипі імена параметрів можна опускати — компілятору важливі лише типи:

// Обидва варіанти коректні
double power(double base, int exponent);  // З іменами (читабельніше)
double power(double, int);                // Без імен

На практиці імена корисно залишати: вони документують призначення кожного параметра для читача коду.

Де розміщувати прототипи

Рекомендована структура файлу:

#include <iostream>

using namespace std;

// 1. Прототипи — на початку файлу
float applyDiscount(float price, float rate);
int   clamp(int value, int minVal, int maxVal);
void  printSeparator();

// 2. main
int main()
{
    // ...
}

// 3. Визначення функцій — після main,
//    в алфавітному порядку або за логічними групами
float applyDiscount(float price, float rate)
{
    return price - price * rate;
}
// ...

Прототипи вгорі разом дають читачу «зміст» файлу — список усіх функцій одним поглядом.

Область видимості

Область видимості (scope) — частина програми, де ідентифікатор (змінна, функція) є доступним. У C++ кожна пара фігурних дужок {} утворює новий блок видимості.

Локальні змінні

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

void functionA()
{
    int x = 10;  // Локальна для functionA
    cout << x;
    // x тут існує
}   // ← x знищується тут

void functionB()
{
    // cout << x;  // ❌ Помилка! x з functionA тут недоступна

    int x = 20;  // Абсолютно нова змінна, теж називається x
    cout << x;   // 20
}

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

Вкладені блоки

Видимість діє і для вкладених блоків всередині функції:

void example()
{
    int outer = 1;

    {
        int inner = 2;         // Видима тут
        cout << outer << "\n"; // ✅ outer доступна (оголошена зовні)
        cout << inner << "\n"; // ✅
    }  // ← inner знищується

    // cout << inner;  // ❌ inner більше не існує
    cout << outer << "\n";     // ✅ outer ще жива
}

Глобальні змінні

Змінна, оголошена поза будь-якою функцією, є глобальною (global): вона доступна з будь-якої функції файлу протягом усього часу виконання програми.

int globalCounter = 0;  // Глобальна змінна

void increment()
{
    globalCounter++;  // Доступ до глобальної
}

void printCounter()
{
    cout << globalCounter << "\n";
}

int main()
{
    increment();
    increment();
    printCounter();  // 2
    return 0;
}

Глобальні змінні ініціалізуються автоматично нулем (на відміну від локальних, де потрібна явна ініціалізація).

Небезпека глобальних змінних

Глобальні змінні — одна з головних причин складних і важко відтворюваних помилок. Уникайте їх у всіх випадках, де можна передати дані через параметри.Проблеми глобальних змінних:
  • Неявна залежність: будь-яка функція може змінити глобальну змінну — складно відстежити, що і коли її змінило.
  • Важко тестувати: функція, що читає глобальний стан, веде себе по-різному залежно від стану, а не лише від аргументів.
  • Конфлікти імен: у великих програмах глобальні імена легко конфліктують між різними модулями.
  • Паралелізм: у багатопотокових програмах глобальні змінні потребують синхронізації.

Коли глобальні змінні виправдані: глобальні константи (const). Константа не може змінитися — тому всі ризики знімаються:

const double PI = 3.14159265;        // ✅ Глобальна константа — норма
const int MAX_BUFFER_SIZE = 65536;   // ✅

int errorCount = 0;                  // ❌ Глобальна змінна — небезпечно

Конфлікт імен: локальна «перекриває» глобальну

Якщо локальна змінна має те саме ім'я, що й глобальна — локальна «приховує» глобальну в межах свого блоку:

int value = 100;  // Глобальна

void test()
{
    int value = 42;          // Локальна — «приховує» глобальну
    cout << value << "\n";   // 42 (локальна)
}

void check()
{
    cout << value << "\n";   // 100 (глобальна — локальної тут немає)
}

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

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

Іноді деякі параметри функції найчастіше приймають одне й те саме значення. Наприклад, функція виводу сепаратора здебільшого виводить 40 символів, але іноді — 20 або 60. Щоразу передавати 40 у виклику — зайвий шум.

Параметри за замовчуванням (default parameters) дозволяють вказати значення, яке використовується, якщо аргумент для цього параметра не переданий:

// Оголошення з параметром за замовчуванням
void printSeparator(int length = 40, char symbol = '=')
{
    for (int i = 0; i < length; i++)
    {
        cout << symbol;
    }
    cout << "\n";
}

int main()
{
    printSeparator();          // length=40, symbol='='  (обидва за замовчуванням)
    printSeparator(20);        // length=20, symbol='='  (тільки symbol — за замовчуванням)
    printSeparator(30, '-');   // length=30, symbol='-'  (жодного за замовчуванням)

    return 0;
}

Правила параметрів за замовчуванням

Правило 1: Тільки з кінця. Параметри зі значеннями за замовчуванням повинні стояти в кінці списку параметрів. Не можна «пропустити» параметр у середині:

// ✅ Правильно — за замовчуванням у кінці
void connect(const char host[], int port = 80, bool secure = false);

// ❌ Неправильно — параметр з default перед параметром без default
void connect(int port = 80, const char host[], bool secure = false);

Правило 2: Лише в прототипі. Якщо є прототип — значення за замовчуванням вказуються лише у прототипі, не у визначенні:

// Прототип — тут значення за замовчуванням
void greet(const char name[], int times = 1);

// Визначення — БЕЗ значень за замовчуванням
void greet(const char name[], int times)
{
    for (int i = 0; i < times; i++)
    {
        cout << "Hello, " << name << "!\n";
    }
}

Правило 3: Пропустити параметр «у середині» — неможливо. Якщо функція f(int a = 1, int b = 2, int c = 3), виклик f(,,5) не є коректним синтаксисом. Щоб передати значення для c, потрібно передати і a, і b.

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

Статичні локальні змінні

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

Ключове слово static перед локальною змінною змінює її поведінку: вона ініціалізується лише один раз (при першому виклику функції) та існує протягом усього часу виконання програми. При наступних викликах функції змінна вже існує — і зберігає значення від попереднього виклику.

StaticDemo.cpp
#include <iostream>

using namespace std;

void countCalls()
{
    static int callCount = 0;  // Ініціалізується лише при ПЕРШОМУ виклику!
    callCount++;
    cout << "Call #" << callCount << "\n";
}

int main()
{
    countCalls();  // Call #1
    countCalls();  // Call #2
    countCalls();  // Call #3
    return 0;
}

Результат:

Call #1
Call #2
Call #3

Якби callCount була звичайною локальною змінною — кожен виклик ініціалізував би її до 0, і програма завжди виводила б Call #1.

Loading diagram...
sequenceDiagram
    participant main
    participant countCalls

    main->>countCalls: виклик 1
    Note over countCalls: static callCount = 0 (ініціалізація)<br/>callCount++ → 1
    countCalls-->>main: виводить "Call #1"

    main->>countCalls: виклик 2
    Note over countCalls: callCount вже = 1 (збережено!)<br/>callCount++ → 2
    countCalls-->>main: виводить "Call #2"

    main->>countCalls: виклик 3
    Note over countCalls: callCount = 2<br/>callCount++ → 3
    countCalls-->>main: виводить "Call #3"

Практичне застосування: генератор унікальних ID

int generateId()
{
    static int nextId = 1000;  // Починаємо з 1000
    return nextId++;           // Постфіксний ++ — повертаємо ПОТОЧНЕ значення,
}                              // потім збільшуємо для наступного виклику

int main()
{
    cout << generateId() << "\n";  // 1000
    cout << generateId() << "\n";  // 1001
    cout << generateId() << "\n";  // 1002
    return 0;
}

Кожен виклик generateId() повертає нове унікальне число. Стан (nextId) зберігається між викликами — але прихований від зовнішнього коду. Це набагато краще, ніж глобальна змінна: інкапсуляція стану всередині функції.

Статичні локальні змінні зберігаються у тій же ділянці пам'яті, що й глобальні змінні (статичний сегмент даних), а не у стеку. Але на відміну від глобальних — вони видимі лише у своїй функції. Це об'єднує переваги обох підходів: постійне існування + обмежена видимість.

Рекурсія

Рекурсія (recursion) — виклик функцією самої себе. Це потужна техніка для задач, що природно розбиваються на менші версії тієї самої задачі.

Класичний приклад — факторіал: n! = n × (n-1)!, а 0! = 1.

int factorial(int n)
{
    if (n == 0)          // Базовий випадок: зупинка рекурсії
    {
        return 1;
    }

    return n * factorial(n - 1);  // Рекурсивний виклик
}

Кожна рекурсивна функція має дві частини:

  1. Базовий випадок (base case) — умова, при якій функція повертає результат без рекурсивного виклику. Без базового випадку рекурсія нескінченна → переповнення стеку.
  2. Рекурсивний крок — виклик функції з меншим (простішим) аргументом, що наближає нас до базового випадку.

Трасування factorial(4):

factorial(4)
  → 4 * factorial(3)
       → 3 * factorial(2)
            → 2 * factorial(1)
                 → 1 * factorial(0)
                      → 1  (базовий випадок)
                 → 1 * 1 = 1
            → 2 * 1 = 2
       → 3 * 2 = 6
  → 4 * 6 = 24
Рекурсія вимагає обережності. Нескінченна рекурсія (забутий базовий випадок або умова, що ніколи не досягається) призводить до переповнення стеку (stack overflow) і аварійного завершення програми. Завжди перевіряйте, що кожен рекурсивний виклик наближає до базового випадку.

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

Об'єднаємо прототипи, параметри за замовчуванням та статичні змінні в одній програмі:

InputSystem.cpp
#include <iostream>

using namespace std;

// Прототипи
int readInt(const char prompt[], int minVal = 0, int maxVal = 100);
void printAttemptStats();

int main()
{
    int age    = readInt("Enter age (1-120): ", 1, 120);
    int score  = readInt("Enter score (0-100): ");      // minVal=0, maxVal=100
    int rating = readInt("Enter rating (1-5): ", 1, 5);

    cout << "\nAge: "    << age    << "\n";
    cout << "Score: "   << score  << "\n";
    cout << "Rating: "  << rating << "\n";

    printAttemptStats();

    return 0;
}

// Читає int у діапазоні [minVal, maxVal] з повторним запитом
int readInt(const char prompt[], int minVal, int maxVal)
{
    int value;

    do
    {
        cout << prompt;
        cin >> value;

        if (value < minVal || value > maxVal)
        {
            cout << "  Please enter a value between "
                 << minVal << " and " << maxVal << ".\n";
        }

    } while (value < minVal || value > maxVal);

    return value;
}

// Показує загальну кількість спроб введення
void printAttemptStats()
{
    // Статична змінна — підраховує загальну кількість невдалих спроб
    // (у цьому прикладі — просто виводить). 
    // У реальному коді readInt() передавала б їй через static лічильник
    cout << "\n[Input session complete]\n";
}

Зверніть на рядки 11–12: опустивши аргументи для minVal і maxVal, ми отримуємо значення за замовчуванням 0 і 100 — без зайвого коду.

Практичні завдання

Рівень 1 — Базовий

Рівень 2 — Логічний

Рівень 3 — Творчий

Підсумок

📌 Прototipe

Підпис функції без тіла: тип ім'я(типи);. Дозволяє викликати функцію до її визначення. Розміщуйте вгорі файлу.

📌 Область видимості

Кожен {} — новий блок. Локальні змінні існують лише у своєму блоці. Глобальні — весь час виконання. Уникайте глобальних змінних (але не констант).

📌 Default parameters

void f(int a, int b = 10). Аргументи зліва направо; параметри зі значеннями — лише в кінці. Значення вказується у прototipe, не у визначенні.

📌 Static locals

static int x = 0 — ініціалізується один раз, зберігає значення між викликами. Зберігається у статичному сегменті, але видима лише у своїй функції.
Copyright © 2026