Практика · Тема 29 · Типи (type hints)¶

Лекція: lecture.html · Тест: quiz.html · Домашнє: homework.md

Головна думка лекції була така: анотація адресована не інтерпретатору. Python її запамʼятовує й ігнорує, а перевіряє окремий інструмент, який ти запускаєш сам.

Тут ми доведемо це кодом, а не на слово. По черзі:

  1. напишемо анотовану функцію й переконаємось, що вона працює як звичайна;
  2. передамо в параметр int рядок — і покажемо assert-ом, що нічого не сталося;
  3. зазирнемо у словник __annotations__ функції;
  4. напишемо функцію з результатом Продаж | None і оброблятимемо обидві гілки;
  5. доведемо, що @dataclass бере перелік полів саме з __annotations__;
  6. дамо складному типу імʼя — псевдонім типу;
  7. побудуємо власний крихітний перевірювач на isinstance і знайдемо його межі;
  8. спробуємо запустити справжній mypy, якщо він є в системі.

Наскрізний приклад той самий, що в темах 25 і 28, — журнал продажів.

1 · Анотована функція — це звичайна функція¶

Почнімо з найпростішого. Двокрапка після імені параметра й стрілка перед двокрапкою в кінці рядка def не міняють ані виклику, ані результату.

In [1]:
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 тут не перевіряє нашу математику, а фіксує факт: помилки не сталося, і результат вийшов зовсім не того типу, який обіцяно в підписі.

In [2]:
# рядок у параметр 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'.

In [3]:
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 — «або продаж, або нічого». Друга анотація найкорисніша в усій темі: вона вголос називає гілку, про яку найлегше забути.

In [4]:
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 — це нагадування самому собі: перед зверненням до поля результат треба перевірити. Без перевірки програма впаде рівно тоді, коли товару в журналі не виявиться, — і жодним днем раніше.

In [5]:
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__ має точно збігтися з переліком полів, який згенерував декоратор.

In [6]:
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 — звичайні обʼєкти.

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

In [7]:
Позиція = 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 друкує список помилок.

In [8]:
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 · Де наш перевірювач здається¶

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

In [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 сказав би, і команду для встановлення.

In [10]:
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.