У темі 28 ми писали
товар: str і чесно казали: Python цього не перевіряє, а потрібне воно
декоратору як розмітка. Це була не відмовка, а відкладена обіцянка — і час її виконати.
Двокрапка після імені означає значно більше, ніж «поле для @dataclass».
Це повноцінна мова опису типів, вбудована в синтаксис Python, яку читають три різні
співрозмовники — і жоден із них не той, на кого ти подумав.
Наскрізний приклад лишається знайомий: журнал продажів із тем 25 і 28. Продаж має товар, кількість, ціну й теги; над журналом ми рахуємо суми й шукаємо записи. Це достатньо простий код, щоб кожна помилка була видна одразу, і достатньо реальний, щоб помилки в ньому справді траплялись.
01 / БоргОбіцянка з теми 28
Почнімо з чесного визнання: анотації ти вже бачив і вже писав, просто без пояснень. У темі 28 вони були рядками всередині класу. Насправді ж місць, де двокрапка легальна, рівно три — і всі три виглядають однаково.
Три однакові конструкції «імʼя двокрапка тип». Стрілка
-> перед двокрапкою в кінці рядка def — це те саме, тільки
для результату функції: «ця функція повертає float».
Питання, на яке ця тема відповідає, звучить прямо: навіщо писати те, що нікого ні до чого не зобовʼязує? Відповідь коротка й дещо несподівана: анотація — це документація, яку вміє перевіряти інструмент. Не Python, а окрема програма, яку ти запускаєш сам. І саме тому Python може дозволити собі її ігнорувати.
Порівняй із коментарем. Коментар -- кількість має бути цілим числом теж
документація. Але його ніхто не читає, крім людини, і він старіє мовчки: код змінили,
коментар лишили, і тепер він бреше. Анотація — це той самий коментар, записаний
формально, а тому придатний до автоматичної перевірки. Її не можна
залишити застарілою непомітно: інструмент почне сваритися.
02 / МаршрутКуди йде анотація
Ось головна схема теми. Один рядок із двокрапкою потрапляє до трьох різних читачів, і кожен робить із ним щось своє. Плутанина, через яку анотації здаються безглуздими, береться з того, що новачок бачить лише першого з трьох.
Схема 1 · Три читачі однієї анотації
Той самий текст програми потрапляє в три різні пари рук.
Розкладемо це словами, бо схема стисла.
Інтерпретатор обчислює анотацію, кладе її у словник __annotations__
відповідного обʼєкта — і на цьому все. У жодну операцію він її не підставляє.
Функція з анотацією int прийме що завгодно.
mypy або pyright — окремі програми. Вони відкривають твої файли як текст, будують із анотацій картину «що куди можна класти» й звіряють її з усіма викликами. Програму при цьому ніхто не запускає: аналіз статичний, тобто по нерухомому коду.
Усередині редактора живе той самий аналіз. Саме завдяки анотації редактор знає,
що продаж — це Продаж, і після крапки пропонує
товар, кількість, ціна, а не порожній список.
Це найпомітніша щоденна вигода, і вона не коштує жодної окремої команди.
03 / ДоказДоказ, що Python мовчить
Твердження «Python не перевіряє анотації» варте того, щоб побачити його на власні очі, а не повірити на слово. Ось функція з максимально суворим підписом:
Другий виклик мав би бути помилкою за будь-якою логікою: у параметр
кількість: int поїхав рядок, у ціна: float — ціле число, а
функція, яка обіцяла повернути float, повернула рядок. Python не сказав ані
слова. Він узяв "кава" * 3, чесно повторив рядок тричі й повернув результат.
Причина не в недбалості, а в свідомому рішенні. Перевіряти типи під час виконання — означає перед кожним викликом виконувати додаткову роботу, і це сповільнювало б геть усі програми, зокрема ті, що не написали жодної анотації. Крім того, довелося б визначати, що робити з випадками, які до перевірки не зводяться. Тому мова пішла іншим шляхом: анотації є, перевірка — окремо й на вимогу.
кількість: int захистить від рядка під час
роботи програми — не захистить. Захистить перевірювач типів, запущений до того,
як програма взагалі стартувала.04 / ІнструментПеревірювач типів у роботі
Тепер подивись, як виглядає та сама функція очима mypy. Ліворуч у схемі — що зробить Python, якщо просто запустити файл. Праворуч — що скаже перевірювач, якщо спершу запустити його. Ці дві відповіді не збігаються, і саме в розбіжності вся користь.
Інтерактив 1 · Що скаже mypy
Обери анотацію параметра й аргумент виклику. Внизу — два вироки: інтерпретатора й перевірювача.
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. Ця вилка є в кожній програмі, і саме на ній
трапляється більшість помилок.
Вертикальна риска читається як «або»: або Продаж, або
None. Такий тип називають обʼєднанням (union). Він не обмежується
двома варіантами — int | str | None теж законний, — але понад три варіанти
зазвичай означають, що функція робить забагато різного.
Найважливіше починається на місці використання. Перевірювач знає, що
результат може виявитись None, і не дає звертатися до полів, поки ти цього
не виключив:
Це називають звуженням типу (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
Перемикай анотацію підпису й дані, на яких програму запускають.
return None при цьому не змінюється
ніколи — помилка весь час на місці, змінюється тільки те, чи хтось її бачить.Порівняй ціну двох способів дізнатись про цю ваду. Перевірювач знаходить її за секунду,
на своєму компʼютері, з точною вказівкою рядка. Запуск знаходить її колись потім, на
конкретних даних, у вигляді AttributeError посеред звіту — і то лише якщо
такі дані взагалі трапляться. Тести теж її спіймають, але тільки якщо хтось здогадається
написати тест на пошук неіснуючого товару. Перевірювач не здогадується — він
обходить усі гілки завжди.
Ось той самий клас помилок, які ловить статичний аналіз, коротким переліком: забуте
повернення значення в одній із гілок, звертання до поля значення, яке може бути
None, одруківка в імені атрибута, зайвий чи пропущений аргумент виклику,
неправильний порядок аргументів однакової кількості, повернення не того, що обіцяно.
Жодна з них не робить код синтаксично неправильним — усі вони чекають на запуск.
09 / ВимикачAny — вимикач
У модулі typing є тип Any, і його дуже легко зрозуміти
навпаки. Any — це не «будь-який тип». Це «не перевіряй це
місце».
Значення типу Any можна передати куди завгодно, до нього можна звернутися
будь-яким полем і викликати будь-який метод — усе це не викличе скарги. Перевірка в цьому
місці просто вимкнена, і вимкнена вона заразливо: результат операції з
Any теж стає Any й розносить безперевірність далі по коду.
Це не робить Any поганим — у нього є законні застосування: дані, що
щойно прийшли з JSON і ще не розібрані; аргумент, який справді може бути чим завгодно
(як у print); чужа бібліотека без анотацій. Погано, коли Any
ставлять, щоб інструмент замовк. Це те саме, що заклеїти лампочку на панелі приладів:
блимати перестане, ламатись — ні.
object. Тип
object теж приймає будь-яке значення, але поводиться протилежно: із ним
нічого не можна робити, поки не звузив через isinstance.
Any дозволяє все й не перевіряє нічого; object перевіряє все
й дозволяє мінімум. Друге зазвичай і є тим, чого ти насправді хотів.10 / ІменаПсевдоніми типів
Складні типи швидко стають нечитабельними. Ось реальний підпис із нашого журналу:
Прочитати це можна, але не з першого разу, а якщо такий тип трапляється в пʼятьох місцях — його ще й доведеться повторити пʼять разів. Розвʼязок звичайний для програмування: дати імʼя.
Псевдонім типу (type alias) — це звичайне присвоєння: ліворуч імʼя, праворуч тип. Ніякого спеціального синтаксису не потрібно, і працює це, бо типи в Python — звичайні обʼєкти. Тепер підпис читається як речення, а якщо структура журналу зміниться, правити доведеться один рядок.
Зрідка виникає неоднозначність: чи Журнал = dict[str, list[Позиція]] —
це псевдонім типу, чи просто змінна, у яку поклали якийсь обʼєкт? Щоб сказати це вголос,
є позначка:
У Python 3.12 зʼявився ще коротший варіант — окреме ключове слово
type Журнал = dict[str, list[Позиція]]. Обидві форми потрібні рідко:
у переважній більшості випадків звичайного присвоєння цілком достатньо, і перевірювач
розбирається сам.
11 / Всередині__annotations__
Ми кілька разів казали: інтерпретатор кладе анотації у словник. Подивимось на цей словник руками — це знімає останній наліт таємничості.
Звичайний словник: ключ — імʼя параметра, значення — сам обʼєкт типу. Тип результату
лежить під службовим ключем 'return'. Ніякої окремої структури в мові немає:
анотації — це дані, до яких можна дотягнутися кодом.
Анотації змінних працюють так само. У тілі класу вони збираються в
__annotations__ класу, на рівні модуля — у словник модуля. Саме звідси
випливає та частина теми 28, яку ми відкладали:
Тепер видно, звідки декоратор дізнається про поля. Він читає той самий
__annotations__, що доступний тобі, і перетворює його на перелік полів. Ось
чому в темі 28 рядок без двокрапки полем не ставав: без двокрапки імʼя просто не
потрапляло в цей словник, і декоратору не було про що дізнатися. Це не магія
@dataclass, а звичайне читання звичайного словника.
@dataclass має
значення сам факт двокрапки — що саме після неї написано, декоратору майже
байдуже. Для перевірювача типів навпаки: важливо саме написане, а не факт. Тому
товар: int у dataclass створить поле нормально й водночас буде помилкою для
mypy при першому ж Продаж("кава"). Два читачі, дві незалежні думки про
той самий рядок.12 / МіраКоли анотувати, а коли ні
Природна реакція після такої теми — піти й анотувати геть усе. Це помилка, і вона дорога: код розпухає, зміни стають повільнішими, а користі не додається. Анотації корисні там, де інформації бракує, і шкідливі там, де вони її дублюють.
Схема 4 · Два стовпчики: варто й зайве
Ліворуч анотація додає те, чого в коді немає. Праворуч — повторює видиме.
Порожній контейнер із правої частини лівого стовпчика вартий окремої згадки, бо це
єдиний випадок, де анотація потрібна навіть перевірювачу. Рядок журнал = []
не каже нічого: список чого? Інструмент не може вгадати, а тому або відмовиться робити
висновки, або поскаржиться прямо. Рядок журнал: list[Продаж] = [] знімає
питання.
І практичний порядок, якщо в тебе вже є код без анотацій. Не переписуй усе.
Почни з підписів функцій у модулях, які використовують інші файли; потім поля класів;
потім місця, де щось може бути None. Це вісімдесят відсотків користі за
двадцять відсотків роботи, а глибина всередині приватних функцій майже нічого не додає.
13 / Для профіЩо коїться під час виконання
Три речі, які варто знати, якщо ти вже пишеш код щодня.
Анотації обчислюються
Анотація — це вираз, і за замовчуванням Python його обчислює у момент
читання def. Звідси дві наслідки. По-перше, у ній не можна назвати клас,
який ще не оголошений, — а це звичайна ситуація, коли метод класу повертає такий самий
клас. По-друге, обчислення коштує часу при імпорті модуля.
Історичне розвʼязання — рядок на початку файла, який перетворює всі анотації модуля на текст і відкладає їх обчислення:
Після нього Вузол усередині анотації — просто рядок
'Вузол', який перевірювач розбере сам, а інтерпретатор не чіпатиме.
Класичний спосіб зробити те саме точково — узяти імʼя в лапки:
def наступний(self) -> "Вузол":. Плата за це є: усе, що читає анотації
під час виконання — @dataclass зі спеціальними формами,
pydantic, механізми впровадження залежностей, — отримує рядки замість типів
і мусить розвʼязувати їх самотужки через typing.get_type_hints().
Дженерики: тип як параметр
Коли функція працює з будь-яким типом, але зберігає його, Any
не підходить: він губить звʼязок між входом і виходом. Для цього є змінна типу:
T — не конкретний тип, а той самий тип. Передав
list[Продаж] — отримав Продаж | None; передав
list[str] — отримав str | None. Синтаксис із квадратними
дужками після імені функції зʼявився в Python 3.12; до нього те саме писали через
T = TypeVar("T"), і цей запис ще довго траплятиметься в коді.
Protocol: качина типізація, записана формально
У темі 26 ми говорили про качину типізацію: коду байдуже, який у обʼєкта клас, — важливо, чи має він потрібний метод. Анотації вміють це записати:
Будь-який клас із методом надрукувати підходить під
Друкований — нічого не наслідуючи і навіть не знаючи про
його існування. Перевірювач звіряє не походження, а форму. Це і є качина типізація,
переказана мовою, якою вміє користуватися інструмент: замість «сподіваюсь, у нього є цей
метод» — «перевір, що в нього є цей метод».
14 / ПідсумокЩо забрати з теми
Уся тема тримається на одному розрізненні: анотація адресована не інтерпретатору. Плутанина, роздратування й зневіра в типах беруться з того, що читач чекає перевірки від Python, не отримує її й робить висновок про марність усієї затії.
- Python анотації не перевіряє. Він обчислює їх, кладе в
__annotations__і виконує програму так, ніби їх немає. Це рішення заради швидкості й простоти, а не недогляд. - Перевіряє окремий інструмент —
mypyабоpyright, запущений командою в терміналі й вбудований у редактор. Він читає код як текст, не виконуючи його, і повідомляє файл, рядок і суть невідповідності. - Базовий словник маленький:
int,str,float,bool, а вміст контейнера — у квадратних дужках:list[int],dict[str, int],tuple[int, str]. Усередину дужок можна класти власні класи й інші контейнери. - Відсутність значення пишеться як
X | None. Це найкорисніша анотація, бо вона змушує обробити гілку, про яку легко забути.Optional[X]— те саме старим записом. - Сучасний запис коротший:
list[int]замістьList[int](з 3.9),int | NoneзамістьOptional[int](з 3.10). Старий працює далі, але писати його немає причин. Any— це не «будь-який тип», а вимикач перевірки. Він законний на межі з невідомими даними й шкідливий як спосіб змусити інструмент замовкнути. Чесне «будь-що» — цеobject.- Складний тип варто назвати: псевдонім — звичайне присвоєння,
Журнал = dict[str, list[Позиція]]. ПозначкаTypeAliasі ключове словоtypeпотрібні рідко. - Анотації живуть у звичайному словнику
__annotations__, і саме його читає@dataclassіз теми 28, щоб дізнатись перелік полів. Ніякого окремого механізму під це в мові немає. - Анотуй межі, а не все підряд. Підписи функцій, поля класів,
порожні контейнери, можливий
None— так. Очевидні локальні змінні на три рядки життя — ні.
Ця тема відкриває блок «Сучасний Python» — усе те, чого не було в підручниках десятирічної давнини. Анотації тут перші не випадково: вони змінюють не поведінку коду, а те, як швидко ти дізнаєшся про власну помилку. Наступні три теми змінюють уже саму поведінку.
Найближча з них — тема 30, ітератори й генератори. Ми весь час
писали list[Продаж], ніби журнал завжди вміщається в памʼять цілком.
А якщо продажів мільярд і вони приходять потоком? Виявляється, у Python є спосіб описати
послідовність, якої не існує повністю в жодну мить часу, — і саме на ньому побудований
кожен цикл for, який ти писав із теми
12.
practice.ipynb ти
переконаєшся assert-ом, що функція з анотацією int спокійно
приймає рядок; заглянеш у __annotations__ функції, класу й модуля;
доведеш, що @dataclass бере перелік полів саме звідти; напишеш функцію
з поверненням Продаж | None і обробиш обидві гілки; побудуєш власний
крихітний перевірювач типів на isinstance і подивишся, чого він
не вміє порівняно з mypy; і спробуєш запустити справжній mypy через
subprocess, якщо він у системі є.Далі в темі
Теорію прочитано. Тепер закріпи її на практиці.