Перейти до змісту

Л6. Функції й узагальнення: декомпозиція, параметри та тести

Коротко про лекцію

Навчимося будувати функції з явним контрактом. Розберемо параметри, перевантаження, значення за замовчуванням, перший шаблон функції та перевірки поведінки.

До цього моменту більшість прикладів жила всередині main(). Такий підхід зручний для першого знайомства з мовою. Програма швидко стає важкою для читання, коли в одному блоці з’являється кілька незалежних обчислень.

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

У Л1 ми вже бачили спеціальну функцію main(). Тепер навчимося створювати власні функції для окремих дій.

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

1. Спочатку описуємо контракт дії

Нехай треба обчислити вартість пального для поїздки.

Дія має три вхідні значення:

  1. відстань;
  2. витрату пального;
  3. ціну літра.

Результат один: вартість пального.

Цей опис є контрактом функції на рівні задачі.

назва: trip_cost
вхід: distance_km, fuel_per_100_km, fuel_price
результат: вартість пального

Контракт відповідає на питання «що отримуємо» і «що повертаємо». Деталі формули залишаються всередині функції.

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

Термін: передумова

Передумову формулюють до реалізації та тестів. Вона належить до контракту так само, як вхід і результат.

Для невеликої функції корисно зафіксувати чотири частини:

Частина контракту Питання
назва яку дію виконує функція?
вхід які дані потрібні?
результат яке значення отримує викликаючий код?
передумова що має бути істинним до виклику?

Не кожна функція потребує складної передумови. Якщо вона є, її треба сформулювати до написання тестів.

flowchart LR
    A["distance_km"] --> F["trip_cost(...)"]
    B["fuel_per_100_km"] --> F
    C["fuel_price"] --> F
    F --> R["вартість поїздки"]

2. Перша функція з параметрами та результатом

Перед повною програмою введемо чотири частини запису функції.

  1. Тип результату показує, яке значення функція повертає.
  2. Ім’я функції називає дію.
  3. Параметри — це локальні імена для вхідних значень функції.
  4. Інструкція return завершує поточний виклик і передає результат назад.

Наприклад, заголовок

double trip_cost(
    double distance_km,
    double fuel_per_100_km,
    double fuel_price
)

означає: функція називається trip_cost, отримує три значення типу double і повертає одне значення double.

Конкретні значення, які передаються під час виклику, називаються аргументами. Наприклад, у виклику trip_cost(120.0, 7.5, 58.0) числа 120.0, 7.5 і 58.0 є аргументами.

Тепер можна прочитати повний приклад.

#include <iostream>

// Функція обчислює результат лише зі своїх параметрів і не залежить від зовнішнього стану.
double trip_cost(
    double distance_km,
    double fuel_per_100_km,
    double fuel_price
) {
    const double fuel_used{
        distance_km * fuel_per_100_km / 100.0
    };
    return fuel_used * fuel_price;
}

int main() {
    const double total{trip_cost(120.0, 7.5, 58.0)};
    std::cout << total << "\n";
}

Розберемо заголовок:

double trip_cost(
    double distance_km,
    double fuel_per_100_km,
    double fuel_price
)

Перше double задає тип результату.

trip_cost є ім’ям функції.

Значення в дужках є параметрами. Кожний параметр має тип та ім’я.

2.1. return передає результат виклику

return fuel_used * fuel_price;

return завершує поточний виклик функції та передає обчислене значення назад у місце виклику.

У main() маємо:

trip_cost(120.0, 7.5, 58.0)

Значення 120.0, 7.5, 58.0 є аргументами цього виклику.

Параметри належать визначенню функції. Аргументи належать конкретному виклику.

Комбінація імені функції та типів її параметрів утворює сигнатуру. На цьому етапі сигнатура потрібна нам як короткий опис інтерфейсу виклику.

Наприклад:

double trip_cost(double, double, double)

показує, що функція приймає три числові аргументи типу double та повертає double. Імена параметрів у визначенні додають зміст кожній позиції.

2.2. Виклик тимчасово передає керування функції

sequenceDiagram
    participant M as main
    participant F as trip_cost
    M->>F: 120.0, 7.5, 58.0
    Note over F: обчислення fuel_used
    F-->>M: 522.0
    Note over M: виконання продовжується після виклику

Після return програма продовжує виконання там, де функцію було викликано.

3. Локальна область зменшує кількість видимого стану

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

#include <iostream>

int add_bonus(int points) {
    int result{points + 5};
    return result;
}

int main() {
    const int result{100};

    std::cout << add_bonus(10) << "\n";
    std::cout << result << "\n";
}

result усередині add_bonus() та result усередині main() належать різним областям.

Функція бачить:

  1. свої параметри;
  2. свої локальні змінні;
  3. доступні зовнішні імена.

Інша функція не отримує автоматичний доступ до її локальних змінних.

flowchart LR
    subgraph A["add_bonus"]
        P["points"]
        R1["result = points + 5"]
    end
    subgraph B["main"]
        R2["result = 100"]
    end
    P --> R1
    R1 -. "повернене значення" .-> B

Локальна область допомагає ізолювати деталі обчислення. Читач функції працює з меншим набором імен.

Кожний новий виклик створює власні параметри та локальні змінні. Після завершення виклику цей локальний стан більше не потрібний. Наступний виклик починає роботу зі своїми значеннями.

Ця властивість полегшує перевірку. Один тест функції не повинен випадково успадковувати локальні змінні попереднього виклику.

4. Маленькі значення передаємо за значенням

Параметр типу int або double у наших прикладах отримує власне значення для поточного виклику.

#include <iostream>

int doubled(int value) {
    value *= 2;
    return value;
}

int main() {
    const int original{7};
    const int result{doubled(original)};

    std::cout << "Оригінал: " << original << "\n";
    std::cout << "Результат: " << result << "\n";
}

value *= 2 змінює локальний параметр value, тому original у main() залишається 7. Для невеликих числових типів такий спосіб є природним початковим вибором.

5. Великі дані лише для читання передаємо через const&

У Л3 ми розрізнили копію та посилання. Тепер застосуємо цю модель до параметрів функції.

std::vector може містити багато елементів. Передавання за значенням створює окрему копію всієї послідовності. Для функції лише з читанням така копія часто зайва.

Параметр можна зробити const-посиланням:

const std::vector<int>& values

Читаємо запис по частинах:

  1. std::vector<int> задає тип об’єкта;
  2. & означає посилання на вже наявний std::vector;
  3. const забороняє зміну через параметр.

Тому функція читає переданий std::vector без створення повної копії.

#include <iostream>
#include <vector>

int sum(const std::vector<int>& values) {
    int total{0};
    for (int value : values) {
        total += value;
    }
    return total;
}

int main() {
    const std::vector<int> values{1, 2, 3, 4};
    std::cout << sum(values) << "\n";
}

У виклику sum(values) параметр звертається до std::vector з main(). const захищає елементи від змін через цей параметр.

Фіксований std::array передається так само. Його розмір входить до типу параметра, тому функція може вимагати точну кількість елементів.

#include <array>
#include <iostream>

// Розмір 4 входить до типу параметра, тому інший розмір не відповідає цьому контракту.
int sum_four(const std::array<int, 4>& values) {
    int total{0};
    for (int value : values) {
        total += value;
    }
    return total;
}

int main() {
    const std::array<int, 4> quarters{2, 3, 1, 4};
    std::cout << sum_four(quarters) << "\n";
}

Запис std::array<int, 4> у параметрі означає рівно чотири int. Наприклад, std::array<int, 5> уже має інший тип і не підходить цьому параметру.

5.1. Коли функція повинна змінити переданий об’єкт

У Л3 звичайне посилання дозволяло змінювати спільний об’єкт. Та сама властивість працює для параметра. Тип void означає, що функція не повертає окремого значення.

void add_bonus(int& score)

У цьому випадку функція змінює переданий об’єкт через посилання. Таку видиму зміну стану поза власним локальним результатом називають побічним ефектом.

Термін: побічний ефект

Побічний ефект — видима зміна стану поза поверненим значенням функції. Наприклад, функція може змінити переданий об’єкт або надрукувати текст.

Для початкового коду корисний такий орієнтир:

Дані й намір Спосіб передавання
малий int, double, bool, char за значенням
великий об’єкт лише для читання const&
функція має змінити переданий об’єкт &

Посилання робить зв’язок із зовнішнім об’єктом частиною контракту. Тому такий параметр треба помічати під час читання виклику.

5.2. Значення параметра за замовчуванням скорочує типовий виклик

Іноді одна функція має звичайний типовий режим. Тоді параметр може отримати значення за замовчуванням у заголовку функції.

double delivery_cost(double weight_kg, double price_per_kg = 12.0)

Якщо другий аргумент не переданий, функція використовує 12.0. Явно переданий аргумент замінює це значення для конкретного виклику.

#include <iostream>

double delivery_cost(double weight_kg, double price_per_kg = 12.0) {
    return weight_kg * price_per_kg;
}

int main() {
    std::cout << delivery_cost(3.0) << "\n";
    std::cout << delivery_cost(3.0, 20.0) << "\n";
}

Межа значення за замовчуванням

Значення за замовчуванням доречне для справді типового режиму. Воно не повинно приховувати важливу для задачі відмінність між двома операціями.

5.3. Перевантаження дає одному імені кілька сигнатур

Іноді одна дія природно працює з різними наборами параметрів. C++ дозволяє визначити кілька функцій з однаковим ім’ям, якщо їхні сигнатури відрізняються. Це називається перевантаженням функції.

Термін: перевантаження функції

Перевантажені функції мають однакове ім’я та різні списки параметрів. Компілятор обирає потрібну версію за аргументами конкретного виклику.

#include <iostream>

// Одна назва area підтримує дві форми задачі, які відрізняються списком параметрів.
int area(int side) {
    return side * side;
}

int area(int width, int height) {
    return width * height;
}

int main() {
    std::cout << area(5) << "\n";
    std::cout << area(5, 3) << "\n";
}

Перший виклик має один аргумент, тому обирається перша сигнатура. Другий виклик має два аргументи, тому виконується інша версія.

5.4. Шаблон функції узагальнює однаковий алгоритм для кількох типів

Ми вже бачили записи std::vector<int> і std::array<int, 4>. Стандартна бібліотека створює конкретні типи з шаблонів. Той самий принцип можна застосувати до власної функції.

Якщо алгоритм однаковий для кількох типів, дублювати функцію для int і double не обов’язково. Шаблон функції задає параметр типу.

template <typename T>
T larger(T left, T right)

T означає тип, який компілятор визначить із аргументів виклику. Для larger(4, 9) він буде int, а для larger(2.5, 1.7)double.

Термін: шаблон функції

Шаблон функції описує сімейство функцій з однаковим алгоритмом. Параметр типу замінюється конкретним типом під час компіляції.

#include <iostream>

// Один шаблон описує ту саму операцію для типів, що підтримують порівняння >.
template <typename T>
T larger(T left, T right) {
    if (left > right) {
        return left;
    }
    return right;
}

int main() {
    std::cout << larger(4, 9) << "\n";
    std::cout << larger(2.5, 1.7) << "\n";
}

На цьому етапі достатньо розуміти одну ідею: шаблон прибирає дублювання, коли операція однакова для різних типів. Складні шаблонні техніки належать до наступних дисциплін.

6. Декомпозиція розділяє різні запитання програми

Нехай треба знайти:

  1. суму оцінок;
  2. середнє;
  3. кількість оцінок від певної межі.

Один великий блок може виконати всі три дії. Читати його доведеться як одну довгу послідовність стану.

Інший підхід дає кожній дії ім’я:

sum(scores)
average(scores)
count_at_least(scores, 75)

Для average(scores) потрібна передумова: послідовність має містити хоча б один елемент. У наступному прикладі scores непорожній, тому ділення на values.size() має зміст.

#include <iostream>
#include <vector>

// Кожна функція нижче має одну окрему відповідальність, яку легко перевірити тестом.
int sum(const std::vector<int>& values) {
    int total{0};
    for (int value : values) {
        total += value;
    }
    return total;
}

double average(const std::vector<int>& values) {
    // Повторно використовуємо sum(), щоб не дублювати логіку накопичення.
    return static_cast<double>(sum(values)) / values.size();
}

int count_at_least(
    const std::vector<int>& values,
    int threshold
) {
    int count{0};
    for (int value : values) {
        if (value >= threshold) {
            ++count;
        }
    }
    return count;
}

int main() {
    const std::vector<int> scores{50, 70, 80, 80};

    std::cout << "Сума: " << sum(scores) << "\n";
    std::cout << "Середнє: " << average(scores) << "\n";
    std::cout << "Від 75: " << count_at_least(scores, 75) << "\n";
}

main() тепер координує роботу:

flowchart TD
    M["main"] --> S["sum(scores)"]
    M --> A["average(scores)"]
    M --> C["count_at_least(scores, 75)"]
    S --> O["виведення"]
    A --> O
    C --> O

Функція average() також викликає sum(). Уже перевірене обчислення суми повторно використовується через його контракт.

У такій програмі main() виконує роль оркестратора. Він задає загальний сценарій, а деталі окремих обчислень делегує функціям.

Це корисний орієнтир для читання. Верхній рівень відповідає на питання «які кроки виконує програма», а малі функції пояснюють «як виконується конкретний крок».

6.1. Кількість функцій сама по собі не є метою

Корисна функція має зрозумілу відповідальність.

Добрі сигнали для виділення функції:

  1. дію можна коротко назвати;
  2. дія має ясні вхідні дані;
  3. дія дає ясний результат;
  4. дію треба перевіряти окремо;
  5. деталі відволікають від основного сценарію.

Поділ кожного рядка на окрему функцію тільки збільшує кількість переходів між іменами.

7. Прихований зовнішній стан ускладнює контракт

Змінна, оголошена поза функціями, називається глобальною змінною. Її можуть бачити кілька функцій, тому вона створює спільний зовнішній стан.

Розглянемо функцію, результат якої залежить від такого стану.

#include <iostream>

int bonus{10};

int final_score(int base) {
    return base + bonus;
}

int main() {
    std::cout << final_score(80) << "\n";

    bonus = 20;
    std::cout << final_score(80) << "\n";
}

Два виклики мають однаковий аргумент 80, але повертають різні результати.

Причина прихована в глобальному bonus.

Контракт final_score(int base) не показує цю залежність. Щоб передбачити результат, треба знати зовнішній стан.

Прозоріша форма передає залежність явно:

int final_score(int base, int bonus) {
    return base + bonus;
}

Тепер усі дані, що визначають результат, видно у виклику.

7.1. Явні залежності полегшують тестування

Функцію

final_score(80, 10)

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

7.2. Побічна дія теж має бути помітною

Функція може щось надрукувати, змінити зовнішнє значення або модифікувати переданий об’єкт. Це приклади вже введеного поняття побічного ефекту.

Побічний ефект не є автоматично помилкою. Проблема виникає, коли він прихований від читача контракту.

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

Такий поділ дає тесту прямий доступ до результату без аналізу тексту в консолі.

8. Передумова входить до контракту

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

Деякі функції мають вимоги до вхідних даних.

Наприклад, середнє значення потребує хоча б одного елемента. Для порожнього std::vector ділення на values.size() не описує потрібного результату.

Контракт можна записати словами:

average(values)
передумова: values не порожній
результат: середнє арифметичне елементів

Передумова має бути відома тому коду, який викликає функцію.

Пізніше курс розгляне різні способи обробки недопустимих даних. На цьому етапі важливо вміти назвати передумову та перевірити її тестом.

9. assert перетворює очікування на виконувану перевірку

Ручний очікуваний результат корисний. Його можна записати так, щоб програма сама перевірила умову.

Для цього підключаємо:

#include <cassert>

Потім записуємо:

assert(delivery_fee(999) == 80);

Якщо вираз усередині assert(...) істинний, виконання продовжується.

Якщо вираз хибний, перевірка сигналізує про порушене очікування.

#include <cassert>
#include <iostream>

int delivery_fee(int order_total) {
    if (order_total >= 1000) {
        return 0;
    }
    return 80;
}

int main() {
    // Перевіряємо значення безпосередньо перед межею, на межі та одразу після неї.
    assert(delivery_fee(999) == 80);
    assert(delivery_fee(1000) == 0);
    assert(delivery_fee(1001) == 0);

    std::cout << "Тести пройдено\n";
}

Три перевірки покривають межу 1000:

999   → доставка 80
1000  → доставка 0
1001  → доставка 0

Такі перевірки є виконуваними тестами. Вони зберігають очікування поруч із кодом.

flowchart TD
    C["Контракт"] --> T["Конкретні тестові випадки"]
    T --> I["Реалізація функції"]
    I --> R{"assert істинні?"}
    R -- "так" --> P["Перевірки пройдено"]
    R -- "ні" --> F["Знайти першу розбіжність"]

9.1. Тест перевіряє поведінку через інтерфейс функції

Корисний тест викликає функцію та порівнює результат із конкретним очікуванням.

Наприклад:

assert(delivery_fee(1000) == 0);

Такий тест не залежить від того, скільки локальних змінних використано всередині.

Після зміни внутрішньої реалізації функції тест може залишитися без змін, якщо її контракт зберігся.

9.2. Старий тест стає захистом від повернення помилки

Припустімо, помилка вже була знайдена для значення 999. Після виправлення варто залишити відповідний assert.

Наступна зміна коду знову запускає той самий тест. Якщо дефект повернеться, перевірка одразу покаже розбіжність.

Такий тест називають регресійним. Він зберігає знання про вже перевірену поведінку програми.

10. Від контракту до функції: робочий порядок

Для маленької функції використовуйте послідовність:

  1. Дайте дії ім’я.
  2. Назвіть вхідні дані.
  3. Назвіть результат.
  4. Запишіть важливі передумови.
  5. Виберіть типи параметрів і результату.
  6. Доберіть кілька конкретних тестів.
  7. Реалізуйте тіло.
  8. Запустіть тести.
  9. Викликайте функцію з основного сценарію.

Такий порядок утримує зв’язок між задачею, кодом та перевіркою.

11. Що залишаємо для наступних тем

У цій лекції функція є інструментом декомпозиції та тестування.

У цій лекції ми не розгортаємо:

  1. рекурсію;
  2. складні правила вибору між багатьма перевантаженнями;
  3. складні шаблонні техніки;
  4. детальнішу модель посилань;
  5. складні способи повернення помилок.

Перевантаження і простий шаблон функції вже дали потрібну базову грамотність. Глибші механізми потребують окремої мотивації та власних прикладів.

12. Інтерактивна самоперевірка лекції

Самоперевірка вимагає читати виклики, відстежувати локальний стан, знаходити приховані залежності та добирати тести до контракту.