Аннотации типов на уровне классов

Курс по Python: https://stepik.org/course/100707

На предыдущем занятии мы с вами в целом рассмотрели различные способы аннотаций отдельных переменных и функций. Здесь же посмотрим на аннотацию на уровне классов.

Если вы пока еще не знакомы с классами языка Python, то советую прежде изучить курс по ООП на Python, а потом вернуться к этому занятию. Этот материал будет интересен тем, кто уже знает, что такое классы и объекты.

https://www.youtube.com/playlist?list=PLA0M1Bcd0w8zPwP7t-FgwONhZOHt9rz9E

Итак, давайте рассмотрим простую аннотацию, например, типом dict:

tr: dict = {'car': 'машина'}

В действительности, здесь dict – это класс, который определяет работу со словарем. А имя класса воспринимается, как название типа. Значит, при аннотации мы можем указывать любые классы в том числе и object – класс, от которого неявно наследуются все классы в Python 3. Давайте это сделаем и посмотрим к чему приведет:

x: object = None
x = "123"
x = 123

Смотрите, мы совершенно спокойно можем прописывать любые типы данных, унаследованные от object, и интегрированная среда нам не выдаст никаких предупреждений. Почему так произошло? Дело в том, что к базовому типу object можно привести любой другой тип, который от него наследуется. А это практически все типы данных в Python. Поэтому строки, числа и любые другие стандартные объекты допускается присваивать переменной x без нарушения данной аннотации.

Или такой пример. Объявим два класса:

class Geom: pass
class Line(Geom): pass

и аннотируем переменную типом Geom:

g: Geom

Далее, присвоим этой переменной объект класса Line:

g = Line()

Как видите интегрированная среда не подсвечивает код, значит, данные соответствуют типу, указанному при аннотации – базовому классу Geom. А если убрать наследование у класса Line:

class Line: pass

то сразу появляется подсветка, т.к. теперь типы Line и Geom независимы.

Все эти примеры показывают, как именно аннотация типов учитывает иерархию наследования классов при проверке корректности присваиваемых данных.

Отличие между object и typing.Any

Учитывая все сказанное, внимательный слушатель может спросить: а в чем отличия между типом object и типом Any из модуля typing? Если все типы данных в Python наследуются от object, значит это эквивалентно использованию типа Any? Почти. Но есть один нюанс. Его можно сформулировать так:

Тип Any совместим с любым другим типом, а тип object – ни с каким другим.

Давайте я поясню смысл этой фразы на конкретном примере. Допустим, у нас имеется некая переменная аннотированная типом Any:

a: Any = None

А, затем, мы определим еще одну переменную с аннотацией типа str:

s: str

После этого можно совершенно спокойно присвоить первую переменную второй:

s = a

Подсветки кода, т.е. каких-либо предупреждений не будет. Но, если вместо Any прописать object:

a: object = None

то появляется подсветка, т.к. тип object не совместим с типом str. То есть, с точки зрения приведенной аннотации будет неверным присваивать переменной типа str переменную типа object. Потому что str наследуется от object, а не наоборот. А вот если бы типы были записаны в другом порядке:

a: str = '123'
s: object
s = a

то никаких предупреждений не возникало бы. Когда же мы указываем Any, то совершенно не имеет значения какой тип будет присвоен этой переменной или куда она далее будет присваиваться:

a: Any = '123'
s: object
s = a

Any – это эквивалент отключения проверки типов, то есть, поведение статического анализатора по умолчанию, словно никакая типизация не была указана. Однако, через Any мы явно показываем, что переменная может ссылаться на произвольный тип данных. Вот в этом отличие между Any и object.

Модуль mypy

До сих пор мы непосредственно в интегрированной среде смотрели на подсветку кода и понимали, что где то нарушили аннотацию типов. Но доверять целиком и полностью такой анализ интегрированной среде неправильно. Вполне могут возникать случаи, когда она не подсвечивает нарушения типов и я вам это уже демонстрировал. Чтобы строже оценивать корректность программы с точки зрения аннотации типов, часто на практике используют специальный модуль:

mypy

Мало того, этим модулем можно воспользоваться, если под рукой нет интегрированной среды, а неточности как-то выявить нужно. Подробнее о нем можно почитать на странице официальной документации:

https://mypy.readthedocs.io/en/stable/

Чтобы воспользоваться этим модулем его нужно вначале установить. Делается это уже известной вам командой:

pip install mypy

После установки в терминале достаточно записать команду:

mypy <имя файла>

В моем случае программа находится в файле main.py, а сам файл хранится в текущем рабочем каталоге. Поэтому команда примет вид:

mypy main.py

Если нужно анализировать сразу несколько файлов, то они прописываются через пробел.

После запуска команды здесь же в терминале видим строчки, в которых нарушена типизация. Все достаточно просто и удобно.

Аннотация с помощью Type и TypeVar

Но давайте вернемся непосредственно к теме «аннотация типов» и предположим, что у нас объявлены два класса:

class Geom: pass
class Point2D(Geom): pass

и некая функция, которая должна создавать экземпляры переданных ей классов, унаследованных от Geom:

def factory_point(cls_geom):
    return cls_geom()

Обратите внимание, здесь предполагается, что параметр cls_geom будет ссылаться на сам класс, а не объект класса. Почему это важно, вы сейчас увидите. Используя текущие знания по аннотации типов, первое, что приходит в голову – это прописать в функции следующие определения:

def factory_point(cls_geom: Geom) -> Geom:
    return cls_geom()

Но нам здесь интегрированная среда сразу подсвечивает фрагмент cls_geom(). Почему это произошло? Как раз по той причине, что аннотация :Geom подразумевает, что параметр cls_geom будет ссылаться на объекты класса Geom, а не на сам класс Geom. Вот это очень важно понимать, когда вы прописываете аннотации типов. Везде подразумеваются объекты тех типов, которые указываются. Но как тогда поправить эту ситуацию? Очень просто. Для этого существует специальный тип Type из модуля typing. Если мы перепишем аннотацию в виде:

def factory_point(cls_geom: Type[Geom]) -> Geom:
    return cls_geom()

то никаких нарушений уже не будет. Тем самым мы указали, что параметр cls_geom будет ссылаться непосредственно на класс Geom, а не его объекты. А далее, используя переменную cls_geom, создается объект этого класса и возвращается функцией.

Давайте теперь воспользуемся этой функцией. Если ее вызвать так:

geom = factory_point(Geom)
point = factory_point(Point2D)

то с аннотациями никаких конфликтов не будет. Но, если мы дополнительно аннотируем и переменные geom и point соответствующими типами:

geom: Geom = factory_point(Geom)
point: Point2D = factory_point(Point2D)

то во второй строчке появится подсветка кода. Очевидно это из-за того, что мы явно указываем ожидаемый тип Point2D, а в определении функции прописан тип Geom. И, так как Geom – базовый класса для Point2D, то возникает конфликт аннотаций.

Для исправления таких ситуаций в Python можно описывать некие общие типы с помощью класса TypeVar. Например:

T = TypeVar("T", bound=Geom)

Мы здесь объявили универсальный тип с именем T и сказали, что он должен быть или классом Geom или любым его дочерним классом. Далее, в самой функции, достаточно прописать этот тип:

def factory_point(cls_geom: Type[T]) -> T:
    return cls_geom()

и он будет автоматически вычисляться при вызове функции. Когда передается класс Geom, то T будет соответствовать этому типу, а когда передается Point2D – то тип T будет Point2D. И так далее. Вот смысл универсальных типов при формировании аннотаций.

Для полноты картины сразу отмечу здесь, что класс TypeVar можно использовать и в таких вариациях:

T = TypeVar("T")   # T – произвольный тип без ограничений
T = TypeVar("T", int, float)   # T – тип связанный только с типами int и float

Подробнее обо всем этом можно почитать в официальной документации. Но я не вижу большого смысла глубоко погружаться в эту тему, т.к. различные вариации данного класса используются на практике не часто.

Аннотация типов в классах

В заключение этой темы добавлю пару слов об аннотации типов внутри классов. В целом все делается практически также как и в случае с переменными и функциями. Давайте распишем класс Point2D следующим  образом:

class Point2D:
    def __init__(self, x: int, y: int) -> None:
        self.x = x
        self.y = y

Обратите внимание, что параметр self не принято аннотировать. Также метод __init__ всегда возвращает значение None.

Воспользоваться этим классом можно так:

p = Point2D(10.5, 20)

В этом случае первый аргумент будет подсвечен, т.к. не соответствует целому типу. Но программа отработает без ошибок. Главный вопрос здесь: существует ли аннотация типов у локальных атрибутов x, y объекта класса Point2D? Давайте проверим. Запишем команду:

p.x = '10'

и интегрированная среда нам не выдает никакой подсветки. Но если перейти в терминал и выполнить команду:

mypy main.py

то статический анализатор модуля mypy отметит две строчки. То есть, с точки зрения mypy в локальных атрибутах x, y ожидаются целые значения. Однако если нужно аннотировать атрибуты класса или его объектов, то лучше это явно прописать непосредственно в самом классе следующим образом:

class Point2D:
    x: int
    y: int
 
    def __init__(self, x: int, y: int) -> None:
        self.x = x
        self.y = y

В заключение этого занятия покажу еще одну особенность аннотации типов в классах. Объявим метод с именем copy():

class Point2D:
    x: int
    y: int
 
    def __init__(self, x: int, y: int) -> None:
        self.x = x
        self.y = y
 
    def copy(self) -> Point2D:
        return Point2D(self.x, self.y)

Предполагается, что он должен возвращать копию объекта класса Point2D. Однако просто так записать имя класса внутри самого класса не получится. Здесь есть два способа обойти этот момент. Первый (устаревший), заключить имя класса в кавычки, прописать его как строку:

    def copy(self) -> 'Point2D':
        return Point2D(self.x, self.y)

Но можно сделать лучше. Если импортировать из модуля __future__ объект annotations:

from __future__ import annotations

то после этого можно убрать кавычки у имени класса:

    def copy(self) -> Point2D:
        return Point2D(self.x, self.y)

Вы спросите почему это явно не внедрили в новых версиях языка Python? Зачем требуется что то дополнительно импортировать? Ответ прост. Это сделано специально для обратной совместимости с более ранними версиями. Разработчики языка решили, что это важно. По крайней мере пока. Поэтому просто запомните этот обстоятельство.

Следует ли использовать аннотацию типов

На данный момент мы с вами рассмотрели основные возможности типизации данных в языке Python. Конечно, это далеко не все, что существует по этой теме, но в большинстве случаев данного материала вполне достаточно, чтобы описывать типы в ваших программах. И завершить я бы хотел краткими указаниями, когда и для чего следует вообще использовать инструмент аннотации типов.

Если вы пишите небольшие короткие программы, которые достаточно просто охватить взглядом, то как таковая аннотация мало чего добавляет в текст программы. Программист и так легко сможет понять какие данные куда передаются. Поэтому излишняя аннотация может даже ухудшить восприятие кода. На мой взгляд для небольших программ достаточно аннотировать ключевые функции или даже совсем ее не делать, как это было в ранних версиях языка и при этом программы воспринимались вполне нормально.

Если же пишется более-менее крупный проект, состоящий из нескольких модулей, то здесь грамотное аннотирование заметно упрощает понимание кода сторонними программистами. Да и сам автор программы спустя продолжительное время сможет быстро восстановить в памяти все нюансы ее работы. Здесь аннотация типов действительно играет положительную роль и ее стоит использовать. Однако во всем должна быть мера. Можно аннотациями так замусорить текст программы, что получим обратный эффект. Всегда следует помнить, что цель аннотирования – это упрощение восприятия текста программы. И, как только, аннотации начинают мешать – это верный признак усмирить свой энтузиазм и вернуться непосредственно к написанию кода. Увлекаться аннотацией типов не стоит.

Я думаю, что приведенный материал позволит вам теперь грамотно применять аннотацию типов в своих программах и будет действительно помогать лучше понимать программный код.

Курс по Python: https://stepik.org/course/100707

Видео по теме