Python з нуля · Блок 7 · Тема 29

Типи, яких Python не перевіряє

Ти пишеш кількість: int, передаєш туди рядок — і нічого не відбувається. Це не поламано: анотація адресована не інтерпретатору. Розберімось, хто її насправді читає й що з неї видобуває.

У темі 28 ми писали товар: str і чесно казали: Python цього не перевіряє, а потрібне воно декоратору як розмітка. Це була не відмовка, а відкладена обіцянка — і час її виконати. Двокрапка після імені означає значно більше, ніж «поле для @dataclass». Це повноцінна мова опису типів, вбудована в синтаксис Python, яку читають три різні співрозмовники — і жоден із них не той, на кого ти подумав.

Наскрізний приклад лишається знайомий: журнал продажів із тем 25 і 28. Продаж має товар, кількість, ціну й теги; над журналом ми рахуємо суми й шукаємо записи. Це достатньо простий код, щоб кожна помилка була видна одразу, і достатньо реальний, щоб помилки в ньому справді траплялись.

01 / БоргОбіцянка з теми 28

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

def сума(кількість: int, ціна: float) -> float: # параметри й результат знижка: float = 0.0 # звичайна змінна return кількість * ціна - знижка @dataclass class Продаж: товар: str # поле класу кількість: int

Три однакові конструкції «імʼя двокрапка тип». Стрілка -> перед двокрапкою в кінці рядка def — це те саме, тільки для результату функції: «ця функція повертає float».

Питання, на яке ця тема відповідає, звучить прямо: навіщо писати те, що нікого ні до чого не зобовʼязує? Відповідь коротка й дещо несподівана: анотація — це документація, яку вміє перевіряти інструмент. Не Python, а окрема програма, яку ти запускаєш сам. І саме тому Python може дозволити собі її ігнорувати.

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

02 / МаршрутКуди йде анотація

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

Схема 1 · Три читачі однієї анотації

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

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

Розкладемо це словами, бо схема стисла.

CPython · запамʼятовує й іде далі

Інтерпретатор обчислює анотацію, кладе її у словник __annotations__ відповідного обʼєкта — і на цьому все. У жодну операцію він її не підставляє. Функція з анотацією int прийме що завгодно.

Перевірювач типів · читає й сперечається

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

mypy журнал.py
Редактор коду · підказує

Усередині редактора живе той самий аналіз. Саме завдяки анотації редактор знає, що продаж — це Продаж, і після крапки пропонує товар, кількість, ціна, а не порожній список. Це найпомітніша щоденна вигода, і вона не коштує жодної окремої команди.

03 / ДоказДоказ, що Python мовчить

Твердження «Python не перевіряє анотації» варте того, щоб побачити його на власні очі, а не повірити на слово. Ось функція з максимально суворим підписом:

>>> def сума(кількість: int, ціна: float) -> float: ... return кількість * ціна ... >>> сума(3, 85.0) 255.0 >>> сума("кава", 3) # обидва аргументи не того типу 'кавакавакава'

Другий виклик мав би бути помилкою за будь-якою логікою: у параметр кількість: int поїхав рядок, у ціна: float — ціле число, а функція, яка обіцяла повернути float, повернула рядок. Python не сказав ані слова. Він узяв "кава" * 3, чесно повторив рядок тричі й повернув результат.

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

Найчастіше непорозуміння теми. Анотація — не оголошення змінної, як у C чи Java. Вона нічого не резервує, нічого не звужує й не змінює жодної операції. Якщо ти чекаєш, що кількість: int захистить від рядка під час роботи програми — не захистить. Захистить перевірювач типів, запущений до того, як програма взагалі стартувала.

04 / ІнструментПеревірювач типів у роботі

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

Інтерактив 1 · Що скаже mypy

Обери анотацію параметра й аргумент виклику. Внизу — два вироки: інтерпретатора й перевірювача.

запуск
помилок mypy0
Найцікавіша пара — Any з аргументом None. Перевірювач каже «зауважень немає», а програма падає. Це не збій інструмента: Any означає «не дивись сюди», і він слухняно не дивиться. Друга пара вартої уваги — int | None із правильним аргументом: виклик бездоганний, а скарга все одно є, бо тіло функції не готове до None.

Зверни увагу на будову повідомлення. Воно завжди називає файл і рядок, далі — що саме не збіглося: «отримано float, очікувано int». Читати їх легко, і це важливо: інструмент, повідомлення якого незрозумілі, швидко вимикають.

Ще одна деталь, помітна в інтерактиві: анотація float спокійно приймає ціле число. Це спеціальна поступка в правилах перевірки — там, де очікується float, дозволено int, бо в Python ціле майже скрізь працює замість дробового. Зворотний напрямок заборонений: 2.5 у параметр int не пройде.

05 / СловникСловник базових типів

Мова анотацій маленька. Девʼяносто відсотків реального коду обходиться десятком записів, і всі вони будуються з двох правил: імʼя типу пишеться як є, а вміст контейнера — у квадратних дужках.

Схема 2 · Записи, яких вистачає майже завжди

Ліворуч — як це пишеться. Праворуч — що воно означає й що туди кладуть.

записщо це словамиприклад значення
intціле число7, -3, 0
floatдробове число85.0, 0.5
strрядок"кава"
boolтак або ніTrue, False
list[int]список цілих[2, 5, 1]
list[str]список рядків["акція", "нове"]
dict[str, int]словник «рядок → число»{"кава": 12}
tuple[int, str]пара з двох різних значень(2, "кава")
tuple[int, ...]кортеж будь-якої довжини(2, 5, 1, 9)
set[str]множина рядків{"кава", "чай"}
list[Продаж]список продажівтвій власний клас усередині
dict[str, list[Продаж]]журнал по продавцяхвкладеність без обмежень
Два останні рядки — уся глибина мови типів. У квадратні дужки ставиться будь-який інший тип, зокрема твій власний клас або ще один контейнер. Три крапки в tuple[int, ...] — не пропуск і не скорочення, а частина запису: «елементів рівно такого типу, кількість довільна». Без крапок tuple[int] означає кортеж рівно з одного числа.

Різниця між list[int] і tuple[int, str] варта окремої фрази, бо вона не випадкова. Список — це послідовність однорідних елементів невідомої довжини, тому в дужках стоїть один тип. Кортеж із теми 07 — фіксований набір, де кожна позиція має власне значення, тому типи перелічуються по позиціях. Запис просто повторює те, як цими структурами користуються насправді.

06 / ПорожнечаЗначення може не бути

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

def знайти(журнал: list[Продаж], товар: str) -> Продаж | None: for продаж in журнал: if продаж.товар == товар: return продаж return None

Вертикальна риска читається як «або»: або Продаж, або None. Такий тип називають обʼєднанням (union). Він не обмежується двома варіантами — int | str | None теж законний, — але понад три варіанти зазвичай означають, що функція робить забагато різного.

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

продаж = знайти(журнал, "чай") print(продаж.ціна) # error: Item "None" of "Продаж | None" has no attribute "ціна" # правильно: if продаж is not None: print(продаж.ціна) # тут перевірювач уже знає: це Продаж

Це називають звуженням типу (narrowing): після if продаж is not None інструмент розуміє, що всередині гілки варіант None уже неможливий, і претензію знімає. Звуження працює і з isinstance, і з перевіркою на порожнечу — тобто збігається з тим, як ти й так пишеш умови.

Стара форма: Optional. У коді, написаному до Python 3.10, те саме записували як Optional[Продаж] з імпортом from typing import Optional. Це рівно той самий тип, ані на крихту не інший. Слово Optional плутає: воно натякає на «необовʼязковий параметр», хоча означає «значення може бути None». Запис Продаж | None не залишає місця для такого прочитання.

07 / ІсторіяЯк змінювався запис

Optional — не єдина річ, яка виглядає в старому коді інакше. Мова анотацій за десять років двічі спрощувалась, і в реальних проєктах ти зустрінеш обидві редакції. Читати треба всі, писати — лише праву колонку.

Схема 3 · Що бачиш у старому коді й що писати зараз

Обидві форми працюють. Права коротша й не потребує імпорту.

Старий запис не зламався — він і далі працює, тому переписувати робочий код заради самого запису не варто. Але новий не потребує рядка import, коротший на око й читається без знання, чим List відрізняється від list. У новому коді причин писати по-старому немає.

Логіка змін проста. Спочатку вбудовані list і dict не вміли квадратних дужок узагалі, тому в модулі typing зробили їхні двійники — List, Dict, Tuple з великої літери. У Python 3.9 дужки навчилися самі вбудовані типи, і двійники стали зайвими. У Python 3.10 вертикальна риска замінила Union й Optional. Модуль typing нікуди не подівся — у ньому лишилось усе складніше, — але для щоденних анотацій він більше не потрібен.

08 / ВигодаПомилка, яку видно до запуску

Досі ми дивились на іграшкові приклади, де помилка помітна оком. Тепер справжній випадок — той, заради якого перевірювач типів взагалі варто запускати. Функція знайти має тиху ваду: в одній гілці вона повертає продаж, а в іншій — None. Код виглядає бездоганно й працює — доки шукають те, що є в журналі.

Інтерактив 2 · Пропущена гілка None

Перемикай анотацію підпису й дані, на яких програму запускають.

запуск
mypy
Проведи два перемикачі незалежно — і побачиш головне: нижній (дані) змінює лише запуск, верхній (анотації) — лише вирок mypy. Без анотацій інструменту нічого сказати, і вада живе в коді, поки хтось не пошукає товар, якого в журналі немає. Пʼятий рядок return None при цьому не змінюється ніколи — помилка весь час на місці, змінюється тільки те, чи хтось її бачить.

Порівняй ціну двох способів дізнатись про цю ваду. Перевірювач знаходить її за секунду, на своєму компʼютері, з точною вказівкою рядка. Запуск знаходить її колись потім, на конкретних даних, у вигляді AttributeError посеред звіту — і то лише якщо такі дані взагалі трапляться. Тести теж її спіймають, але тільки якщо хтось здогадається написати тест на пошук неіснуючого товару. Перевірювач не здогадується — він обходить усі гілки завжди.

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

09 / ВимикачAny — вимикач

У модулі typing є тип Any, і його дуже легко зрозуміти навпаки. Any — це не «будь-який тип». Це «не перевіряй це місце».

from typing import Any def порахувати(дані: Any) -> Any: return дані * 2 # жодних зауважень, хай там що

Значення типу Any можна передати куди завгодно, до нього можна звернутися будь-яким полем і викликати будь-який метод — усе це не викличе скарги. Перевірка в цьому місці просто вимкнена, і вимкнена вона заразливо: результат операції з Any теж стає Any й розносить безперевірність далі по коду.

Це не робить Any поганим — у нього є законні застосування: дані, що щойно прийшли з JSON і ще не розібрані; аргумент, який справді може бути чим завгодно (як у print); чужа бібліотека без анотацій. Погано, коли Any ставлять, щоб інструмент замовк. Це те саме, що заклеїти лампочку на панелі приладів: блимати перестане, ламатись — ні.

Хочеш «будь-що» чесно — бери object. Тип object теж приймає будь-яке значення, але поводиться протилежно: із ним нічого не можна робити, поки не звузив через isinstance. Any дозволяє все й не перевіряє нічого; object перевіряє все й дозволяє мінімум. Друге зазвичай і є тим, чого ти насправді хотів.

10 / ІменаПсевдоніми типів

Складні типи швидко стають нечитабельними. Ось реальний підпис із нашого журналу:

def звіт(журнал: dict[str, list[tuple[str, int, float]]]) -> dict[str, float]: ...

Прочитати це можна, але не з першого разу, а якщо такий тип трапляється в пʼятьох місцях — його ще й доведеться повторити пʼять разів. Розвʼязок звичайний для програмування: дати імʼя.

Позиція = tuple[str, int, float] # товар, кількість, ціна Журнал = dict[str, list[Позиція]] # продавець → його продажі def звіт(журнал: Журнал) -> dict[str, float]: ...

Псевдонім типу (type alias) — це звичайне присвоєння: ліворуч імʼя, праворуч тип. Ніякого спеціального синтаксису не потрібно, і працює це, бо типи в Python — звичайні обʼєкти. Тепер підпис читається як речення, а якщо структура журналу зміниться, правити доведеться один рядок.

Зрідка виникає неоднозначність: чи Журнал = dict[str, list[Позиція]] — це псевдонім типу, чи просто змінна, у яку поклали якийсь обʼєкт? Щоб сказати це вголос, є позначка:

from typing import TypeAlias Журнал: TypeAlias = dict[str, list[Позиція]]

У Python 3.12 зʼявився ще коротший варіант — окреме ключове слово type Журнал = dict[str, list[Позиція]]. Обидві форми потрібні рідко: у переважній більшості випадків звичайного присвоєння цілком достатньо, і перевірювач розбирається сам.

11 / Всередині__annotations__

Ми кілька разів казали: інтерпретатор кладе анотації у словник. Подивимось на цей словник руками — це знімає останній наліт таємничості.

>>> def сума(кількість: int, ціна: float) -> float: ... return кількість * ціна ... >>> сума.__annotations__ {'кількість': <class 'int'>, 'ціна': <class 'float'>, 'return': <class 'float'>}

Звичайний словник: ключ — імʼя параметра, значення — сам обʼєкт типу. Тип результату лежить під службовим ключем 'return'. Ніякої окремої структури в мові немає: анотації — це дані, до яких можна дотягнутися кодом.

Анотації змінних працюють так само. У тілі класу вони збираються в __annotations__ класу, на рівні модуля — у словник модуля. Саме звідси випливає та частина теми 28, яку ми відкладали:

>>> @dataclass ... class Продаж: ... товар: str ... кількість: int ... >>> Продаж.__annotations__ {'товар': <class 'str'>, 'кількість': <class 'int'>} >>> list(Продаж.__dataclass_fields__) ['товар', 'кількість']

Тепер видно, звідки декоратор дізнається про поля. Він читає той самий __annotations__, що доступний тобі, і перетворює його на перелік полів. Ось чому в темі 28 рядок без двокрапки полем не ставав: без двокрапки імʼя просто не потрапляло в цей словник, і декоратору не було про що дізнатися. Це не магія @dataclass, а звичайне читання звичайного словника.

Одна анотація, дві різні ролі. Для @dataclass має значення сам факт двокрапки — що саме після неї написано, декоратору майже байдуже. Для перевірювача типів навпаки: важливо саме написане, а не факт. Тому товар: int у dataclass створить поле нормально й водночас буде помилкою для mypy при першому ж Продаж("кава"). Два читачі, дві незалежні думки про той самий рядок.

12 / МіраКоли анотувати, а коли ні

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

Схема 4 · Два стовпчики: варто й зайве

Ліворуч анотація додає те, чого в коді немає. Праворуч — повторює видиме.

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

Порожній контейнер із правої частини лівого стовпчика вартий окремої згадки, бо це єдиний випадок, де анотація потрібна навіть перевірювачу. Рядок журнал = [] не каже нічого: список чого? Інструмент не може вгадати, а тому або відмовиться робити висновки, або поскаржиться прямо. Рядок журнал: list[Продаж] = [] знімає питання.

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

13 / Для профіЩо коїться під час виконання

Три речі, які варто знати, якщо ти вже пишеш код щодня.

Анотації обчислюються

Анотація — це вираз, і за замовчуванням Python його обчислює у момент читання def. Звідси дві наслідки. По-перше, у ній не можна назвати клас, який ще не оголошений, — а це звичайна ситуація, коли метод класу повертає такий самий клас. По-друге, обчислення коштує часу при імпорті модуля.

class Вузол: def наступний(self) -> Вузол: # NameError: name 'Вузол' is not defined ...

Історичне розвʼязання — рядок на початку файла, який перетворює всі анотації модуля на текст і відкладає їх обчислення:

from __future__ import annotations

Після нього Вузол усередині анотації — просто рядок 'Вузол', який перевірювач розбере сам, а інтерпретатор не чіпатиме. Класичний спосіб зробити те саме точково — узяти імʼя в лапки: def наступний(self) -> "Вузол":. Плата за це є: усе, що читає анотації під час виконання@dataclass зі спеціальними формами, pydantic, механізми впровадження залежностей, — отримує рядки замість типів і мусить розвʼязувати їх самотужки через typing.get_type_hints().

Дженерики: тип як параметр

Коли функція працює з будь-яким типом, але зберігає його, Any не підходить: він губить звʼязок між входом і виходом. Для цього є змінна типу:

def перший[T](послідовність: list[T]) -> T | None: return послідовність[0] if послідовність else None

T — не конкретний тип, а той самий тип. Передав list[Продаж] — отримав Продаж | None; передав list[str] — отримав str | None. Синтаксис із квадратними дужками після імені функції зʼявився в Python 3.12; до нього те саме писали через T = TypeVar("T"), і цей запис ще довго траплятиметься в коді.

Protocol: качина типізація, записана формально

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

from typing import Protocol class Друкований(Protocol): def надрукувати(self) -> str: ... def показати(що: Друкований) -> None: print(що.надрукувати())

Будь-який клас із методом надрукувати підходить під Друкованийнічого не наслідуючи і навіть не знаючи про його існування. Перевірювач звіряє не походження, а форму. Це і є качина типізація, переказана мовою, якою вміє користуватися інструмент: замість «сподіваюсь, у нього є цей метод» — «перевір, що в нього є цей метод».

14 / ПідсумокЩо забрати з теми

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

Ця тема відкриває блок «Сучасний Python» — усе те, чого не було в підручниках десятирічної давнини. Анотації тут перші не випадково: вони змінюють не поведінку коду, а те, як швидко ти дізнаєшся про власну помилку. Наступні три теми змінюють уже саму поведінку.

Найближча з них — тема 30, ітератори й генератори. Ми весь час писали list[Продаж], ніби журнал завжди вміщається в памʼять цілком. А якщо продажів мільярд і вони приходять потоком? Виявляється, у Python є спосіб описати послідовність, якої не існує повністю в жодну мить часу, — і саме на ньому побудований кожен цикл for, який ти писав із теми 12.

Далі в практиці. У practice.ipynb ти переконаєшся assert-ом, що функція з анотацією int спокійно приймає рядок; заглянеш у __annotations__ функції, класу й модуля; доведеш, що @dataclass бере перелік полів саме звідти; напишеш функцію з поверненням Продаж | None і обробиш обидві гілки; побудуєш власний крихітний перевірювач типів на isinstance і подивишся, чого він не вміє порівняно з mypy; і спробуєш запустити справжній mypy через subprocess, якщо він у системі є.

Далі в темі

Теорію прочитано. Тепер закріпи її на практиці.