Практика · Тема 29 · Типи (type hints)¶
Лекція: lecture.html · Тест: quiz.html · Домашнє: homework.md
Головна думка лекції була така: анотація адресована не інтерпретатору. Python її запамʼятовує й ігнорує, а перевіряє окремий інструмент, який ти запускаєш сам.
Тут ми доведемо це кодом, а не на слово. По черзі:
- напишемо анотовану функцію й переконаємось, що вона працює як звичайна;
- передамо в параметр
intрядок — і покажемоassert-ом, що нічого не сталося; - зазирнемо у словник
__annotations__функції; - напишемо функцію з результатом
Продаж | Noneі оброблятимемо обидві гілки; - доведемо, що
@dataclassбере перелік полів саме з__annotations__; - дамо складному типу імʼя — псевдонім типу;
- побудуємо власний крихітний перевірювач на
isinstanceі знайдемо його межі; - спробуємо запустити справжній
mypy, якщо він є в системі.
Наскрізний приклад той самий, що в темах 25 і 28, — журнал продажів.
1 · Анотована функція — це звичайна функція¶
Почнімо з найпростішого. Двокрапка після імені параметра й стрілка перед двокрапкою
в кінці рядка def не міняють ані виклику, ані результату.
def сума_позиції(кількість: int, ціна: float) -> float:
"""Скільки коштує позиція журналу: кількість помножити на ціну."""
return кількість * ціна
результат = сума_позиції(2, 85.0)
print("сума_позиції(2, 85.0) =", результат)
print("тип результату:", type(результат).__name__)
сума_позиції(2, 85.0) = 170.0 тип результату: float
2 · Доказ: Python анотацію не перевіряє¶
Тепер порушимо домовленість — передамо в параметр кількість: int рядок.
За правилами анотацій це помилка. За правилами Python — ні.
assert тут не перевіряє нашу математику, а фіксує факт: помилки не сталося,
і результат вийшов зовсім не того типу, який обіцяно в підписі.
# рядок у параметр int і ціле число в параметр float — обидва «не за домовленістю»
дивний_результат = сума_позиції("кава", 3)
print("сума_позиції('кава', 3) =", repr(дивний_результат))
print("тип результату:", type(дивний_результат).__name__, "— а обіцяли float")
# якби Python перевіряв анотації, наступний рядок ніколи б не виконався
assert дивний_результат == "кавакавакава", "Python раптом почав перевіряти типи?"
assert isinstance(дивний_результат, str), "підпис -> float ні до чого не зобовʼязує"
print("\n✅ анотація не завадила: рядок спокійно пройшов у параметр int")
сума_позиції('кава', 3) = 'кавакавакава'
тип результату: str — а обіцяли float
✅ анотація не завадила: рядок спокійно пройшов у параметр int
3 · Де ці анотації лежать¶
Інтерпретатор не викидає анотацію — він кладе її у звичайний словник
__annotations__. Ключ — імʼя параметра, значення — сам обʼєкт типу.
Тип результату зберігається під службовим ключем 'return'.
print("анотації функції сума_позиції:")
for назва, тип in сума_позиції.__annotations__.items():
print(f" {назва:<12} → {тип.__name__}")
# це справді звичайний словник із звичайними обʼєктами типів
assert сума_позиції.__annotations__["кількість"] is int
assert сума_позиції.__annotations__["return"] is float
print("\n✅ ключ 'return' зберігає тип результату — окремої структури в мові немає")
анотації функції сума_позиції: кількість → int ціна → float return → float ✅ ключ 'return' зберігає тип результату — окремої структури в мові немає
4 · Контейнери й тип, якого може не бути¶
list[Продаж] читається як «список продажів», Продаж | None — «або продаж, або
нічого». Друга анотація найкорисніша в усій темі: вона вголос називає гілку, про яку
найлегше забути.
from dataclasses import dataclass, field
@dataclass
class Продаж:
товар: str
кількість: int
ціна: float
теги: list[str] = field(default_factory=list)
журнал: list[Продаж] = [
Продаж("кава", 2, 85.0, ["акція"]),
Продаж("чай", 1, 60.0),
Продаж("мед", 3, 120.0),
]
def знайти(журнал: list[Продаж], товар: str) -> Продаж | None:
"""Перший продаж із таким товаром — або None, якщо такого товару немає."""
for продаж in журнал:
if продаж.товар == товар:
return продаж
return None
print("знайшли :", знайти(журнал, "чай"))
print("не знайшли:", знайти(журнал, "какао"))
знайшли : Продаж(товар='чай', кількість=1, ціна=60.0, теги=[]) не знайшли: None
5 · Обробляємо обидві гілки¶
Підпис -> Продаж | None — це нагадування самому собі: перед зверненням до поля
результат треба перевірити. Без перевірки програма впаде рівно тоді, коли товару
в журналі не виявиться, — і жодним днем раніше.
def ціна_товару(журнал: list[Продаж], товар: str) -> float:
продаж = знайти(журнал, товар)
if продаж is None: # гілка, про яку легко забути
return 0.0
return продаж.ціна # сюди ми потрапляємо, лише коли продаж точно є
print("ціна кави :", ціна_товару(журнал, "кава"))
print("ціна какао (немає в журналі):", ціна_товару(журнал, "какао"))
assert ціна_товару(журнал, "кава") == 85.0
assert ціна_товару(журнал, "какао") == 0.0
# а ось як виглядає та сама функція без перевірки — і що з неї виходить
try:
порожньо = знайти(журнал, "какао")
print(порожньо.ціна)
except AttributeError as помилка:
print("\nбез перевірки:", type(помилка).__name__, "—", помилка)
print("\n✅ обидві гілки оброблено, помилка спіймана навмисно")
ціна кави : 85.0 ціна какао (немає в журналі): 0.0 без перевірки: AttributeError — 'NoneType' object has no attribute 'ціна' ✅ обидві гілки оброблено, помилка спіймана навмисно
6 · Звідки @dataclass знає про поля¶
У темі 28 ми казали: декоратор читає анотації. Тепер це можна перевірити руками —
перелік ключів __annotations__ має точно збігтися з переліком полів, який
згенерував декоратор.
print("Продаж.__annotations__ :", list(Продаж.__annotations__))
print("Продаж.__dataclass_fields__:", list(Продаж.__dataclass_fields__))
assert list(Продаж.__annotations__) == list(Продаж.__dataclass_fields__), \
"декоратор бере поля саме з анотацій — переліки мали б збігтися"
print("\n✅ жодної магії: @dataclass читає той самий словник, що доступний і нам")
Продаж.__annotations__ : ['товар', 'кількість', 'ціна', 'теги'] Продаж.__dataclass_fields__: ['товар', 'кількість', 'ціна', 'теги'] ✅ жодної магії: @dataclass читає той самий словник, що доступний і нам
7 · Складному типу — імʼя¶
Псевдонім типу (type alias) — це звичайне присвоєння: ліворуч імʼя, праворуч тип. Ніякого спеціального синтаксису не треба, бо типи в Python — звичайні обʼєкти.
Заразом подивимось на анотації змінних: вони теж збираються у словник, тільки вже не функції, а модуля (у зошиті — простору імен клітинок).
Позиція = tuple[str, int, float] # товар, кількість, ціна
ЖурналПоПродавцях = dict[str, list[Позиція]] # продавець → його позиції
продажі_по_продавцях: ЖурналПоПродавцях = {
"Олена": [("кава", 2, 85.0), ("чай", 1, 60.0)],
"Іван": [("мед", 3, 120.0)],
}
print("псевдонім — це просто обʼєкт типу:", ЖурналПоПродавцях)
print("продавців у журналі:", len(продажі_по_продавцях))
print("\nанотовані змінні цього зошита:", sorted(__annotations__))
assert Позиція == tuple[str, int, float], "псевдонім не створює нового типу"
print("\n✅ псевдонім не породжує окремий тип — це те саме, лише коротше записане")
псевдонім — це просто обʼєкт типу: dict[str, list[tuple[str, int, float]]] продавців у журналі: 2 анотовані змінні цього зошита: ['журнал', 'продажі_по_продавцях'] ✅ псевдонім не породжує окремий тип — це те саме, лише коротше записане
8 · Власний крихітний перевірювач¶
Щоб зрозуміти, що робить mypy, напишімо його наївного молодшого брата: функцію, яка
бере __annotations__ і звіряє з ними фактичні аргументи виклику. Вона повертає
список скарг — так само, як mypy друкує список помилок.
def перевірити_виклик(функція, *аргументи):
"""Звіряє фактичні аргументи з анотаціями функції й повертає список скарг."""
кількість_параметрів = функція.__code__.co_argcount
імена = функція.__code__.co_varnames[:кількість_параметрів]
анотації = функція.__annotations__
скарги = []
for назва, значення in zip(імена, аргументи):
очікуваний = анотації.get(назва)
if очікуваний is None:
continue # параметр без анотації не перевіряємо
if not isinstance(значення, очікуваний):
скарги.append(
f'аргумент «{назва}» має тип "{type(значення).__name__}"; '
f'очікувано "{очікуваний.__name__}"')
return скарги
print("сума_позиції(2, 85.0) →", перевірити_виклик(сума_позиції, 2, 85.0))
print('сума_позиції("кава", 85.0) →', перевірити_виклик(сума_позиції, "кава", 85.0))
assert перевірити_виклик(сума_позиції, 2, 85.0) == []
assert len(перевірити_виклик(сума_позиції, "кава", 85.0)) == 1
print("\n✅ на цих двох викликах наш перевірювач каже те саме, що mypy у лекції")
сума_позиції(2, 85.0) → []
сума_позиції("кава", 85.0) → ['аргумент «кількість» має тип "str"; очікувано "int"']
✅ на цих двох викликах наш перевірювач каже те саме, що mypy у лекції
9 · Де наш перевірювач здається¶
Два випадки, на яких видно різницю між перевіркою під час виконання й статичним аналізом. Обидва — не дрібниці, а причина, з якої справжня перевірка типів робиться до запуску окремим інструментом.
# Межа 1. mypy навмисно пускає int туди, де очікується float.
# isinstance про цю поступку не знає й скаржиться на бездоганний виклик.
print("сума_позиції(2, 85) — mypy мовчить, а наш перевірювач каже:")
print(" ", перевірити_виклик(сума_позиції, 2, 85))
# Межа 2. Параметризований тип у isinstance передати не можна взагалі.
try:
isinstance([1, 2, 3], list[int])
except TypeError as помилка:
print("\nisinstance([1, 2, 3], list[int]) →", type(помилка).__name__)
print(" ", помилка)
print("\nПід час виконання видно лише «список», а не «список чого»:")
print(" isinstance([1, 2, 3], list) =", isinstance([1, 2, 3], list))
print(" isinstance(['а', 'б'], list) =", isinstance(["а", "б"], list))
print("\nОбидва рази True — а типи всередині різні. Саме цю різницю бачить mypy.")
сума_позиції(2, 85) — mypy мовчить, а наш перевірювач каже:
['аргумент «ціна» має тип "int"; очікувано "float"']
isinstance([1, 2, 3], list[int]) → TypeError
isinstance() argument 2 cannot be a parameterized generic
Під час виконання видно лише «список», а не «список чого»:
isinstance([1, 2, 3], list) = True
isinstance(['а', 'б'], list) = True
Обидва рази True — а типи всередині різні. Саме цю різницю бачить mypy.
10 · Справжній перевірювач¶
Спробуємо запустити mypy на файлі з тією самою вадою, що в другому інтерактиві
лекції: функція обіцяє повернути рядок, а в одній гілці повертає None.
Якщо mypy у системі немає, зошит не падає — він друкує те, що mypy сказав би, і команду для встановлення.
import pathlib
import subprocess
import sys
import tempfile
from importlib.util import find_spec
код_із_вадою = '''def знайти(журнал: list[str], товар: str) -> str:
for назва in журнал:
if назва == товар:
return назва
return None
'''
if find_spec("mypy") is None:
print("mypy у цьому середовищі не встановлено.")
print("Встановити: pip install mypy")
print("Запустити : mypy журнал.py")
print("\nОсь що він надрукував би на коді вище:\n")
print('журнал.py:5: error: Incompatible return value type '
'(got "None", expected "str") [return-value]')
print("Found 1 error in 1 file (checked 1 source file)")
else:
тека = pathlib.Path(tempfile.mkdtemp())
файл = тека / "журнал.py"
файл.write_text(код_із_вадою, encoding="utf-8")
запуск = subprocess.run(
[sys.executable, "-m", "mypy", "--no-color-output", str(файл)],
capture_output=True, text=True)
print(запуск.stdout.strip() or запуск.stderr.strip())
mypy у цьому середовищі не встановлено. Встановити: pip install mypy Запустити : mypy журнал.py Ось що він надрукував би на коді вище: журнал.py:5: error: Incompatible return value type (got "None", expected "str") [return-value] Found 1 error in 1 file (checked 1 source file)
11 · Що спробувати самому¶
🟢 Рівень 1. Додай у клас Продаж поле знижка: float = 0.0 і напиши функцію
підсумок(журнал: list[Продаж]) -> float, яка рахує суму з урахуванням знижки.
Зроблено, якщо: assert порівнює твій результат із порахованим руками числом,
а __annotations__ функції друкуються й містять ключ 'return'.
🟡 Рівень 2. Напиши найдорожчий(журнал: list[Продаж]) -> Продаж | None, яка
повертає None на порожньому журналі. Перевір assert-ами обидві гілки.
Зроблено, якщо: виклик на порожньому списку повертає саме None, а не падає.
🔴 Рівень 3. Навчи перевірити_виклик розуміти X | None: якщо анотація —
обʼєднання, значення має підійти хоч під один із варіантів. Підказка: у таких
анотацій є typing.get_args().
Зроблено, якщо: виклик із None у параметр int | None не дає скарг, а виклик
із рядком — дає.
Повні умови домашнього — у homework.md.