Аннотации и статическая типизация
Аннотации
Базовая статья: О дисциплине использования аннотаций
(немного копипасты из ../12_MetaclassMatch)
Duck typing:
Экономия кода на описаниях и объявлениях типа
Экономия (несравненно бо́льшая) кода на всех этих ваших полиморфизмах ⇒
Компактный читаемый код, хорошее отношение семантика/синтаксис ⇒
Быстрое решение Д/З ☺
Однако:
Практически все ошибки — runtime, их сложно предсказать / локализовать
Много страданий от невнимательности (передал объект не того типа, и не заметил, пока не свалилось) Вашей невнимательности не поможет даже хитрое IDE: оно тоже не знает о том, какого типа объекты правильные, какого — нет
- (соответственно, о полях вашего объекта тоже)
Часть прагматики растворяется в коде (например, вы написали строковую функцию, как об этом узнать?)
Большие и сильно разрозненные проекты всегда полны runtime-ошибок ⇒ чем их меньше, тем лучше
Поэтому нужны указания о типе полей классов, параметрах и возвращаемых значений функций/методов и т. п. — Аннотации (annotations)
- Пример аннотаций полей (переменных), параметров и возвращаемых значений
1 #!/usr/bin/python3.14 2 import annotationlib 3 4 class C: 5 A: int = 2 6 N: float 7 8 def __init__(self, param: int = None, signed: bool = True) -> None: 9 if param != None: 10 self.A = param if signed else abs(param) 11 12 def mult(self, mlt: int) -> str: 13 return self.A * mlt 14 15 a: C = C(3) 16 b: C = C("QWE") 17 print(f"{a.mult([2])=}, {b.mult(2)=}") 18 print(f"{C.__annotations__=}") 19 print(f"{C.__annotate_func__(annotationlib.Format.VALUE)=}") 20 print(f"{annotationlib.get_annotations(a.mult)=}") 21 print(f"{annotationlib.get_annotations(C)=}") 22 print(f"{annotationlib.get_annotations(C.__init__)=}") 23 24 print(a.mult(2)) 25 print(b.mult(2)) 26 print(a.mult("Ho! ")) 27 print(a.N) # Ошибка!
- Аннотации сами по себе не влияют на семантику кода
- …в т. ч. не занимаются проверкой типов
Аннотации заполняют словарь __annotations__ в соответствующем пространстве имён
…но не они заводят там имена
…и это не единственный способ создавать аннотации (например, можно задать .__annotate_func__())
так что рекомендуется обращаться к аннотации не напрямую, а с помощью annotationlib.get_annotations()
- Типы в аннотациях —
это настоящие типы
- …и даже
- ⇒ Можно использовать для всяких альтернативных семантик
- У нас было такое Д/З ☺
А хорошо ли понятно, почему вообще работает последний пример?
В действительности объект-аннотация не вычисляется по имени,
1 >>> class C: 2 ... other: D 3 >>> annotationlib.get_annotations(C) 4 Traceback (most recent call last): 5 File "<python-input-3>", line 1, in <module> 6 annotationlib.get_annotations(C) 7 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^ 8 File "/usr/lib64/python3.14/annotationlib.py", line 891, in get_annotations 9 ann = _get_dunder_annotations(obj) 10 File "/usr/lib64/python3.14/annotationlib.py", line 1055, in _get_dunder_annotations 11 ann = _BASE_GET_ANNOTATIONS(obj) 12 File "<python-input-0>", line 2, in __annotate__ 13 other: D 14 ^ 15 NameError: name 'D' is not defined
В действительности аннотации — это дескриптор(ы). Их значения можно получать в разных форматах:
1 >>> annotationlib.get_annotations(C, format=annotationlib.Format.STRING) 2 {'other': 'D'} 3 >>> annotationlib.get_annotations(C, format=annotationlib.Format.FORWARDREF) 4 {'other': ForwardRef('D', is_class=True, owner=<class '__main__.C'>)} 5 >>> f = annotationlib.get_annotations(C, format=annotationlib.Format.FORWARDREF)['other'] 6 >>> f.evaluate() 7 Traceback (most recent call last): 8 File "<python-input-12>", line 1, in <module> 9 f.evaluate() 10 ~~~~~~~~~~^^ 11 File "/usr/lib64/python3.14/annotationlib.py", line 183, in evaluate 12 raise NameError(_NAME_ERROR_MSG.format(name=arg), name=arg) 13 NameError: name 'D' is not defined 14 >>> class D: pass 15 ... 16 >>> f.evaluate() 17 <class '__main__.D'> 18
Этот же приём используется в т. н. псевдонимах типов (type alias):
Итого, в 3.14 в принципе имеется отложенное вычисление. Оператор type = работает с правой частью не так, как обычное связывание: имя не превращается в объект перед связыванием, а помещается куда-то внутрь TypeAlias.
1 >>> type C = D
2 >>> annotationlib.call_evaluate_function(C.evaluate_value, annotationlib.Format.STRING)
3 'D'
4 >>> C.evaluate_value()
5 Traceback (most recent call last):
6 File "<python-input-3>", line 1, in <module>
7 C.evaluate_value()
8 ~~~~~~~~~~~~~~~~^^
9 File "<python-input-1>", line 1, in C
10 type C = D
11 ^
12 NameError: name 'D' is not defined
Аннотации настолько отвязаны от реализации, что, например, получить доступ к собственным аннотациям из функции, например, довольно трудно, и получается «хрупкий» код (с классами гораздо проще)
В действительности могут быть вообще чем угодно (например, строками)
Статическая модель типизации
Модуль typing
Сборник Pep-ов, имеющих отношение к статической типизации
⇒ Это тема для целого курса.
Кратко: в Python есть (никогда не полная, но стремящаяся к полноте) модель статического описания типов в аннотациях.
- Это описание (и его отсутствие) никак не влияет на работу программы — если к нему явно не обратились
Оно предназначено для внешних инструментов, которые могут перевести ошибки, связанные с несоответствием типов, из runtime в compile time.
Составные и нечёткие типы
pep-0585: Во многих случаях можно писать что-то вроде list[int]
1 >>> import typing 2 >>> def fun(lst: list[int]): pass 3 >>> annotationlib.get_annotations(fun) 4 {'lst': list[int]} 5 >>> annotationlib.get_annotations(fun)['lst'] 6 list[int] 7 >>> type(annotationlib.get_annotations(fun)['lst']) 8 <class 'types.GenericAlias'> 9 >>> ann = annotationlib.get_annotations(fun)['lst'] 10 >>> typing.get_args(ann) 11 (<class 'int'>,) 12 >>> typing.get_origin(ann) 13 <class 'list'> 14
.get_args() возвращает кортеж с аннотациями элемента, .get_origin() — тип контейнера
Again, на семантику работы аннотация не влияет
В собственном классе реализуется с помощью .__class_getitem__()
Собственно, псевдонимы типов нужны для упрощения:
1 from itertools import pairwise
2 from math import dist
3 type Path = list[tuple[int, int]]
4 def distance(p: Path) -> float:
5 return sum(dist(a, b) for a, b in pairwise(p))
6 print(distance([(0, 0), (3, 4), (-1, 1)]))
7 print(distance(([0, 0], [3, 4], [-1, 1]))) # неправильно задано, но всё равно работает!
Кстати, tuple[int] означает кортеж из одного элемента
Again, на семантику они не влияют.
Альтернативы вида тип1 | тип2:
Абстрактные типы
Абстрактные базовые классы — для проверки .isinstance()/issubclass()
Базовые классы для последовательностей
1 >>> import collections.abc 2 >>> isinstance([1,2,3], collections.abc.Iterable) 3 True 4 >>> isinstance("wer", collections.abc.Iterable) 5 True 6 >>> isinstance((i for i in range(10)), collections.abc.Iterable) 7 True 8 >>> isinstance(1+3j, collections.abc.Iterable) 9 False 10 >>> isinstance("wer", collections.abc.Sequence) 11 True 12 >>> isinstance((i for i in range(10)), collections.abc.Sequence) 13 False
1 import numbers 2 3 def classify(num): 4 match num: 5 case numbers.Rational(numerator=a, denominator=b): 6 print(f"{a}/{b}") 7 case numbers.Real(real=a): 8 print(f"[{a}]") 9 case numbers.Complex(real=a, imag=b): 10 print(f"<{a}, {b}>") 11 case numbers.Number() as a: 12 print(f"Unknown number {a}") 13 case _: 14 print("NaN")
Callable/Awaitable
Any, Self, Type (если сама переменная — класс)
- …
Дженерики
Уже виденные нами псевдонимы (AKA «переменные типа»)
Параметризированные дженерики (это уже почти C++ темплейты… или не почти, с учётом pep-695?
) def mul[T](obj:T, num: int) -> T: return obj * num
Другие инструменты
NoReturn, Never, Union, Optional, Literal, Final…
- …
К делу не относится, но именно перегрузка функций/методов бывает нужна безо всякой статической типизации
- …
Пример: dataclasses — типизированные структуры, логика базируется на аннотациях
MyPy
Что показательно:
Статическая типизация в Python очень активно развивается, достаточно посмотреть сводный What's New и поискать там «type» или «typing».
три официальных блога Гвидо: The Mypy Blog, Neopythonic, The History of Python
Совпадение? Не думаю!™
Ещё раз: зачем аннотации?
- Прагматика, включенная в синтаксис языка
- открытая разработка и более полное информационное пространство
- Использование аннотаций для задания дополнительной семантики
Есть ли примеры популярных инструментов?
- ⇒ Статическая типизация и статические анализ кода
- Дисциплина программирования
- большие, сверхбольшие и «долгие» проекты
- «индус-триальное программирование»©
- Преобразование Python-кода в представления, требующие статической типизации
- Дисциплина программирования
- …
http://www.mypy-lang.org: статическая типизация в Python by default (ну, почти… или совсем!)
- Проверка выражений с типизированными данными
В т. ч. проверка или не-проверка нетипизиварованных
- Пример:
- Он запускается! Но проверку на статическую типизацию не проходит:
1 $ mypy ex1.py 2 ex1.py:6: error: Unsupported operand types for + ("int" and "str") 3 ex1.py:7: error: Incompatible return value type (got "int", expected "str") 4 ex1.py:13: error: Incompatible types in assignment (expression has type "str", variable has type "int") 5 Found 3 errors in 1 file (checked 1 source file) 6 $ mypy --strict ex1.py 7 ex1.py:1: error: Function is missing a type annotation for one or more arguments 8 ex1.py:3: error: Returning Any from function declared to return "str" 9 ex1.py:6: error: Unsupported operand types for + ("int" and "str") 10 ex1.py:7: error: Incompatible return value type (got "int", expected "str") 11 ex1.py:13: error: Incompatible types in assignment (expression has type "str", variable has type "int") 12 Found 5 errors in 1 file (checked 1 source file) 13
На MyPy основано большинство дисциплин разработки и систем проверки кода в различных IDE.
Компиляция
Если все объекты полностью типизированы, у них имеется эквивалент в виде соответствующих структур PythonAPI. ЧСХ, у байт-кода тоже есть эквивалент в Python API
Таинственный mypyc
- Пока не рекомендуют использовать, но сами все свои модули им компилируют!
Пример для mypyc:
- Крайне неэффективная реализация чисел Фибоначчи
- Сравнение производительности (Python 3.12.5 / ALT):
- В Python 3.11 на этом же компьютере результаты были 178 msec и 13.2 msec соответственно
Д/З
Пока что на EJudge Python3.12, в котором нет отложенных аннотаций / отложенных псевдонимов.
- Прочитать про
Аннотации, модули collections.abc и typing
TODO 2 обязательных задачи (match/case и asyncio + задача на аннотации + задача на статическую типизацию + задача на mypy
EJudge: AnnoCalc 'Вычислимые поля'
Input:Написать метакласс AnnoCalc, который добавляет в конструируемый с его помощью класс такое свойство: если (1) в классе есть аннотация к полю, (2) самого поля у экземпляра/класса нет, и (3) эта аннотация — строка, то при чтении из этого поля строка-аннотация интерпретируется как выражение с участием других полей объекта/класса. Выражение вычисляется и возвращается соответствующее значение. Если какое-то из условий (1), (2), (3) не выполнено, класс ведёт себя стандартно (добывает имеющееся значение поля или вызывает исключение).
Output:47 201
EJudge: MulSum 'Умносложение'
Input: Output:Написать функцию anymulsum(a, b, c), которая возвращает a * b + c. Проверка mypy --strict должна проходить без ошибок, только если b — целое, a и c — одного типа, который предусматривает сложение и умножение на целое (обоих случаях результат должен получиться того же типа). В тестах будут проверяться как допустимые, так и недопустимые сочетания.
Недопустимые сочетания: anymulsum("QW", 2, 3), anymulsum("QW", 2, (1, 2)).
QWQWRTY 5
Обязательная задача из основного корпуса:
EJudge: AbsoluteMeta 'Метакласс c модулем'
Input:Написать класс Absolute, который можно использовать как метакласс. Absolute добавляет в порождаемый класс дескриптор abs и метод __abs__(). При создании класса ему можно передавать два именных параметра: width — имя поля «ширина» и height — имя поля «высота». По умолчанию width="width" и height="height". Создание полей abs и __abs__ происходит по следующим правилам (правила применяются по принципу «первое подходящее»):
Если метод __abs__() существует, он не меняется; если это не метод — поле заменяется на метод
Если существует метод abs() и этот метод допускает вызов без параметров, то __abs__() должен делать то же самое
Если существует метод __len__() и этот метод допускает вызов без параметров, то __abs__() должен делать то же самое
Если существуют методы «ширина»() и «высота»() и они допускают вызов без параметров, то __abs__() возвращает их произведение
Если в классе существуют не-callable поля «ширина» и «высота», то __abs__() возвращает их произведение
В противном случае __abs__() возвращает сам объект без изменений
Дескриптор abs создаётся всегда (в том числе вместо любого атрибута abs, если он был): возвращает __abs__().
Output:2 64
Обязательная задача из основного корпуса:
EJudge: AsyncPoly 'Вычисление многочлена'
Input:Написать класс YesFuture и функцию parse_poly(многочлен, x) со следующими свойствами:
YesFuture — это класс, похожий на Future, только проще.
obj = YesFuture(значение=None) задаёт awaitable-объект obj, для которого await obj немедленно возвращает значение.
obj.set(новое_значение) подменяет значение
У parse_poly() два параметра.
Второй параметр — объект типа YesFuture.
Первый параметр — строка, в которой описан многочлен от x по стандартным правилам: знак * между необязательным коэффициентом и x не ставится, цифры степени — это unicode-цифры верхнего регистра (SUPERSCRIPT).
parse_poly() возвращает корутину, вычисляющую значение многочлена для текущего значения x.
При создании корутины вместо операций сложения, умножения и возведения в степень необходимо пользоваться только специальными корутинами Sum(a, b), Mul(a, b) и Pow(a, b), параметры которых — awaitable-объекты (другие корутины или YesFuture)
Специальные корутины будут входить в каждый тест.
Output:1 async def Sum(a, b): 2 return await a + await b 3 4 async def Mul(a, b): 5 return await a * await b 6 7 async def Pow(a, b): 8 return await a ** await b 9 10 async def Run(poly, *args): 11 x = YesFuture() 12 for arg in args: 13 s = parse_poly(poly, x) 14 x.set(arg) 15 print(await s) 16 17 asyncio.run(Run("3x⁵ + x² - 6x + 4", 4, 2))
3068 92
