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

Л13. Функції, тести і винятки у Python

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

Перенесемо декомпозицію та контракти у Python. Додамо підказки типів, виконувані тести й явну обробку очікуваних помилок.

Функції, передумови й тести вже знайомі з C++. У Python змінюється запис, але причина декомпозиції лишається. Кожна частина програми повинна мати зрозумілий контракт.

Друга тема лекції — помилки під час виконання. У Л11 ми читали зворотне трасування стека. Тепер навчимося сигналізувати очікувану проблему та обробляти її на правильній межі.

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

1. def створює функцію

Функція у Python починається словом def. Далі записують ім’я, параметри у дужках і двокрапку. Відступ позначає тіло функції. Інструкція return завершує поточний виклик і повертає результат.

def імя(параметри):
    інструкції
    return результат
def trip_cost(distance_km, price_per_km):
    return distance_km * price_per_km

print(trip_cost(120, 2.5))

distance_km і price_per_km є параметрами. Значення 120 і 2.5 є аргументами конкретного виклику.

Контракт варто сформулювати до коду:

вхід: відстань і ціна за кілометр
вихід: повна вартість
ефект: зовнішній стан не змінюється
flowchart TD
    A["аргументи виклику"] --> B["параметри функції"]
    B --> C["локальні обчислення"]
    C --> D["return"]
    D --> E["значення у місці виклику"]

2. Локальні імена зменшують прихований стан

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

def total_cost(hours, rate):
    local_total = hours * rate
    return local_total

print(total_cost(3, 2.0))

Залежності hours і rate видно в параметрах. Тому тест може підготувати аргументи та порівняти повернений результат.

У зошиті це також корисно. Функція з явними параметрами менше залежить від випадкових зовнішніх імен. Отже, порядок комірок простіше контролювати.

2.1. Глобальне ім’я створює зовнішню залежність

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

tax_rate = 0.2

def total(price):
    return price * (1 + tax_rate)

Функція total залежить і від price, і від зовнішнього tax_rate. Для навчального коду прозоріший контракт передає обидва значення параметрами.

def total(price, tax_rate):
    return price * (1 + tax_rate)

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

Межа global

Зміна глобального стану приховує залежність і ускладнює тести. Для початкових програм віддавайте перевагу параметрам і return.

2.2. Python lambda — коротка функція з одним виразом

У Л9 ми бачили лямбда-вираз у C++. Python має споріднену ідею з іншим синтаксисом. Запис lambda score: score >= 60 створює функціональний об’єкт з одним параметром і одним виразом результату.

lambda score: score >= 60

Такий запис варто вміти читати, бо він часто трапляється як короткий аргумент іншої операції. Якщо поведінка має власне ім’я, кілька кроків або окремі тести, звичайний def читається краще.

3. Анотації типів документують очікувані типи

Анотації типів додають до параметрів і результату очікувані типи для читача та інструментів аналізу.

Межа анотацій типів

Анотації типів допомагають читачеві та статичним інструментам. Звичайний Python не перетворює їх на автоматичну перевірку типів під час кожного виклику.

Python дозволяє додати анотації типів. Вони описують очікувані типи параметрів і результату.

Термін: анотація типу

Анотація типу — запис очікуваного типу. Вона документує контракт для читача та інструментів, але сама не перевіряє значення під час звичайного виконання.

Запис

def average(total: float, count: int) -> float:

читаємо так: total очікується як float, count — як int. Стрілка -> описує очікуваний тип результату.

def average(total: float, count: int) -> float:
    return total / count

print(average(25.0, 4))

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

4. assert фіксує очікувану властивість

У C++ ми використовували assert для перевірки тверджень у коді. Python має однойменну інструкцію.

Форма проста:

assert умова

Якщо умова хибна, виникає AssertionError. У навчальних тестах це зручно для короткої перевірки результату.

def delivery_fee(subtotal):
    if subtotal >= 1000:
        return 0
    return 80

assert delivery_fee(999) == 80
assert delivery_fee(1000) == 0
print("тести пройдено")

Пара 999 і 1000 перевіряє межу правила. Один випадок розташований перед межею, другий — на ній.

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

Межа assert

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

5. Виняток сигналізує про неможливість нормального результату

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

Термін: виняток

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

Виняток — це об’єкт, який повідомляє про проблему під час виконання. Якщо виняток не обробити, Python покаже зворотне трасування стека.

Код може створити й підняти виняток сам. Для цього використовують raise.

ValueError доречний, коли значення має неприйнятний зміст для поточного контракту.

raise ValueError("quantity має бути додатною")
def require_positive(quantity):
    if quantity <= 0:
        raise ValueError("quantity має бути додатною")
    return quantity

print(require_positive(3))

Для 3 функція повертає значення. Для нуля нормальний результат не формується. Виняток передає проблему до коду виклику.

flowchart TD
    A["require_positive(quantity)"] --> B{"quantity > 0?"}
    B -- так --> C["return quantity"]
    B -- ні --> D["raise ValueError"]
    D --> E["пошук обробника вище"]

6. try і except задають очікувану реакцію

Блок try містить операцію, яка може завершитися певним винятком. Блок except описує реакцію на конкретний тип. У except зазначаємо лише очікуваний тип винятку.

try:
    ризикована_операція
except ValueError:
    реакція_на_некоректне_значення

Функція int(text) намагається перетворити текст на ціле число. Для рядка на кшталт "12x" вона піднімає ValueError.

text = "12x"
try:
    quantity = int(text)
    print(quantity)
except ValueError:
    print("некоректне ціле число")

6.1. Трасуємо ValueError крок за кроком

Для text = "12x" варто простежити весь шлях керування. Трасування показує, які інструкції пропускаються після ValueError.

Крок Поточне місце Стан або подія Що виконується далі
1 перед try text містить "12x" починається блок try
2 quantity = int(text) int("12x") піднімає ValueError присвоєння quantity не завершується
3 пошук обробника except ValueError відповідає типу керування переходить у except
4 except друкується некоректне ціле число обробник завершується
5 після try/except виняток уже оброблено виконання продовжується далі

Ім’я quantity у цьому фрагменті не отримує значення. Рядок print(quantity) також пропускається. Причина одна: ValueError виник під час обчислення правої частини присвоєння.

Ми ловимо очікуваний ValueError. Інша помилка лишається видимою та дає зворотне трасування стека.

Широкий except: приховує різні класи проблем. Він може замаскувати дефект програми разом із пошкодженими даними.

flowchart TD
    A["операція у try"] --> B{"виник ValueError?"}
    B -- ні --> C["продовжити нормальний шлях"]
    B -- так --> D["except ValueError"]
    B -- інший --> E["інша помилка лишається видимою"]

6.2. Інтерактивний ввід робить виняток видимим на реальному значенні

У Л11 int(text) працював лише для тексту, що представляє ціле число. Тепер можемо явно обробити ValueError і залишити програму в контрольованому стані.

Введіть абв, а потім повторіть запуск із 21. Перший запуск має перейти до except, а другий — надрукувати 42.

text = input("Ціле число: ")

try:
    value = int(text)
    print(value * 2)
except ValueError:
    print("некоректне ціле число")

Тут except ValueError описує конкретну очікувану помилку перетворення. Інші типи помилок цей блок не приховує.

7. Межа обробки визначає політику помилки

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

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

parse_record
→ виявити неправильний формат
→ raise ValueError

batch_process
→ except ValueError
→ порахувати відхилений запис
→ продовжити
flowchart TD
    A["зовнішній рядок"] --> B["розбір"]
    B --> C{"можна розібрати?"}
    C -- так --> D["готове значення"]
    C -- ні --> E["raise ValueError"]
    E --> F["межа пакетної обробки / інтерфейсу"]
    F --> G["явна політика"]

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

8. Тести перевіряють контракт і межі

Сильний тест перевіряє значущу властивість. Кількість рядків функції не визначає якість тесту.

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

Корисний набір випадків:

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

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

9. Правдоподібний код все одно треба перевіряти

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

Під час аналізу дійте так:

  1. сформулюйте контракт функції;
  2. знайдіть передумови;
  3. передбачте граничні випадки;
  4. складіть тести до запуску;
  5. простежте зміну стану;
  6. перевірте політику помилок;
  7. після цього приймайте рішення про використання.
def is_discount_age(age):
    return age < 12 or age >= 65

checks = [(11, True), (12, False), (64, False), (65, True)]
all_correct = True
for age, expected in checks:
    if is_discount_age(age) != expected:
        all_correct = False

print(all_correct)

Тут важливі числа навколо обох меж. Випадкові значення могли б не виявити помилку > замість >=.

Змінна all_correct починається зі значення True. Цикл змінює її на False, щойно знаходить контрприклад до очікуваної поведінки.

10. Контрольна схема функції з помилкою

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

flowchart TD
    A["який нормальний результат?"] --> B["які передумови?"]
    B --> C["яка помилка очікувана?"]
    C --> D["де приймається рішення про реакцію?"]
    D --> E["які тести доводять контракт?"]

Така схема не прив’язана до Python. Вона описує професійну дисципліну роботи з функціями та помилками.

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

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