Расширенная статическая типизация с помощью mypy (часть 2)


Больше уроков, полученных за 7 лет аннотирования большой базы кода

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

Краткое руководство по дженерикам и дисперсиям

Это продвинутая тема, и чем лучше вы ее поймете, тем меньше времени вы потратите на решение ошибок типа методом проб и ошибок. Это особенно важно, когда вы начинаете создавать свои собственные generic-классы, поэтому первое, что вы должны сделать, это прочитать о дисперсии generic-типов в документации mypy.

Теперь, когда вы все это прочитали, давайте продолжим.

Рассмотрим этот простой пример:


class Employee:
    def work(self) -> None:
        pass

class Manager(Employee):
    def manage(self) -> None:
        pass

def do_work(x: Employee) -> None:
   x.work()

do_work(Employee())
do_work(Manager())

Вход в полноэкранный режим Выход из полноэкранного режима

mypy проходит успешно. Мы можем передать экземпляр Manager в do_work, потому что Manager считается подтипом Employee. Подклассы являются подтипами. Достаточно просто. Но все становится сложнее, когда мы вводим дженерики.

Рассмотрим следующее:

from typing import Iterable, TypeVar, Generic
T = TypeVar('T')

class Employee:
    def work(self):
        pass

class Manager(Employee):
    def manage(self):
        pass

class Team(Generic[T]):
    pass

def do_work(x: Team[Employee]) -> None:
   pass

do_work(Team[Employee]())
do_work(Team[Manager]())  # mypy error!
Войти в полноэкранный режим Выход из полноэкранного режима

Является ли Team[Manager] подтипом Team[Employee]? Интуитивно кажется, что это так, но на самом деле это не так! Приведенный выше код выдает следующую ошибку:

Argument 1 to "do_work" has incompatible type "Team[Manager]"; expected "Team[Employee]
Войдите в полноэкранный режим Выйти из полноэкранного режима

Это довольно неинтуитивно. Настало время для RTFD!

Из документации по mypy о дисперсии общих типов:

По умолчанию mypy предполагает, что все определяемые пользователем общие типы инвариантны.

Team — это пользовательский дженерик, поэтому он инвариантен. Что это значит?

Чтобы понять это, мы должны рассмотреть 3 типа дисперсии: инвариантную, ковариантную и контравариантную. Вот моя упрощенная версия из документации mypy:

Даны эти классы:

class A:
    pass

class B(A):
    pass
Войти в полноэкранный режим Выйти из полноэкранного режима

Вот как мы можем думать о дисперсии некоторого общего типа Thing:

  • ковариант: Thing[T] является ковариантным, если Thing[B] всегда является подтипом Thing[A].
  • контравариантным: Thing[T] является контравариантным, если Thing[A] всегда является подтипом Thing[B].
  • инвариант: Thing[T] называется инвариантным, если ни одно из вышеперечисленных утверждений не является истинным.

Это определение рекурсивно, потому что чтобы узнать, является ли B подтипом A, мы должны снова обратиться к их дисперсии.

Мы хотим, чтобы Team[B] был подтипом Team[A], поэтому, обращаясь к списку выше, нам нужен первый параметр — ковариация. Вот как мы это сделаем:

from typing import Iterable, TypeVar, Generic
T_co = TypeVar('T_co', covariant=True)

class Employee:
    def work(self):
        pass

class Manager(Employee):
    def manage(self):
        pass

class Team(Generic[T_co]):
    pass

def do_work(x: Team[Employee]) -> None:
   pass

do_work(Team[Employee]())
do_work(Team[Manager]())  # <-- NO mypy error!
Войдите в полноэкранный режим Выйти из полноэкранного режима

Это не означает, что вы должны использовать covariant=True для каждого TypeVar, который вы определяете! Ковариантный TypeVar должен быть зарезервирован для неизменяемых дженериков. Если это не так, то вы подрываете защиту от дисперсии, которую обеспечивает mypy. Вот почему Sequence является ковариантным, а List — нет.

Кажется, это довольно устоявшееся соглашение использовать суффиксы _co и _contra для ковариантности и контравариантности соответственно.

Как работать с классами, которые инстанцируют атрибуты вне __init__

Часто возникает ситуация, когда класс имеет функцию для обновления переменных экземпляра, и принципы повторного использования кода диктуют нам использовать эту функцию в нашем __init__ для инициализации переменных:

def read_from_db() -> Tuple[int, str]:
    ...

class Person(object):
    def __init__(self, name: str) -> None:
        self.name = name
        self.age: int = None         # error!
        self.location: str = None    # error!
        self.refresh()

    def refresh(self) -> None:
        self.age, self.location = read_from_db()
Вход в полноэкранный режим Выход из полноэкранного режима

mypy выдает следующие ошибки:

error: Incompatible types in assignment (expression has type "None", variable has type "int")
error: Incompatible types in assignment (expression has type "None", variable has type "str")
Вход в полноэкранный режим Выход из полноэкранного режима

Наивное решение этой проблемы — сделать self.age и self.location Optional, однако в данном случае это не то, чего мы хотим, потому что в нашем надуманном примере read_from_db() всегда возвращает значение не None, и мы не хотим, чтобы код, использующий наш Person, должен был добавлять везде проверки is None для этих атрибутов.

Вот одно из решений:

def read_from_db() -> Tuple[int, str]:
    ...

class Person(object):
    def __init__(self, name: str) -> None:
        self.name = name
        self.refresh()

    def refresh(self) -> None:
        self.age, self.location = read_from_db()
Войдите в полноэкранный режим Выйти из полноэкранного режима

Это работает, потому что тип переменной присваивается в первой строке, в которой она определена. Недостатком является то, что переменные и их типы не находятся в центре внимания в __init__, где мы их ожидаем, поэтому разработчики, читающие ваш код, могут пропустить их, если не будут охотиться. Кроме того, этот способ несколько хрупок, так как изменение порядка методов во время рефакторинга может привести к тому, что первой присвоенной переменной окажется какая-нибудь другая строка (не в этом примере, конечно, потому что здесь только два метода и одно присвоение для каждого атрибута).

Вот альтернативное решение:

def read_from_db() -> Tuple[int, str]:
    ...

class Person(object):
    age: int = None
    location: str = None

    def __init__(self, name: str) -> None:
        self.name = name
        self.refresh()

    def refresh(self) -> None:
        self.age, self.location = read_from_db()
Войти в полноэкранный режим Выйти из полноэкранного режима

Это верно, но мы должны быть осторожны при использовании техники. Мы должны инстанцировать self.age и self.location как можно раньше в жизненном цикле Person, потому что mypy теперь считает, что они не-None.

Рассмотрим следующий пример:

def read_from_db() -> Tuple[int, str]:
    ...

class Person(object):
    age: int = None
    location: str = None

    def __init__(self, name: str) -> None:
        self.name = name
        # self.refresh() not called!

    def refresh(self) -> None:
        "You must call this manually!"
        self.age, self.location = read_from_db()

p = Person('chad')
next_year = p.age + 1  # runtime error!
Вход в полноэкранный режим Выход из полноэкранного режима

mypy не будет жаловаться, потому что считает, что p.age является int, однако этот код будет неудачным во время выполнения, потому что p.age на самом деле None, поскольку мы не инстанцировали его.

Когда использовать assert и typing.cast

typing.cast и assert — простые способы решения ошибок mypy, особенно с типами Optional, но их следует использовать в крайнем случае.

assert можно использовать для сужения типа так же, как и оператор if/else, но непосредственно в текущей области видимости.

typing.cast делает то же самое, но без последствий для времени выполнения.

Эти два подхода следует использовать только для исправления ошибок mypy, которые не могут быть исправлены другими способами.

Почему? assert — это проверка на вменяемость, она означает «если все работает, то это утверждение никогда не должно сработать». Если есть хоть малейший шанс, что оно может не сработать, вы должны raise выдать ошибку с соответствующим типом исключения.

В случае typing.cast, mypy слепо изменит тип переменной на тот, к которому вы ее привели, и вы можете ошибиться! Нет никакой проверки во время выполнения, чтобы сохранить честность. Я почти всегда оставляю cast для сценариев, включающих «воображаемые» типы, которые не могут быть использованы в операторе isinstance, например, TypeVars.

Рассмотрим пример, адаптированный из приведенного выше:

def read_from_db() -> Tuple[int, str]:
    ...

class Person(object):

    def __init__(self, name: str) -> None:
        self.name = name
        self.age: Optional[int] = None

    def load_age(self) -> None:
        "You must call this manually!"
        self.age = read_from_db()[0]

    def is_younger_than(self, other_age: int) -> bool:
        self.load_age()
        return self.age < other_age   # mypy error!
Вход в полноэкранный режим Выход из полноэкранного режима

self.age является Optional внутри is_younger_than, потому что mypy не может отслеживать условные изменения состояния, происходящие вне функции.

Мы можем решить эту проблему, добавив assert:

    def is_younger_than(self, other_age: int) -> bool:
        self.load_age()
        assert self.age is not None, 
            "self.age is set by load_age"
        return self.age < other_age
Enter fullscreen mode Выйти из полноэкранного режима

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

Лучшей альтернативой почти всегда является реструктуризация, позволяющая использовать статическую типизацию. Например, вы можете попробовать перепроектировать ваш API так, чтобы значения всегда устанавливались при __init__ (например, предоставляя альтернативные инстансы в качестве classmethods).

Ниже приведен пример использования кэшируемого свойства вместо атрибута для решения описанной выше ситуации:

from functools import cached_property

def read_from_db() -> Tuple[int, str]:
    ...

class Person:

    def __init__(self, name: str) -> None:
        self.name = name

    @cached_property
    def age(self) -> int:
        "You must call this manually!"
        return read_from_db()[0]

    def is_younger_than(self, other_age: int) -> bool:
        return self.age < other_age
Войти в полноэкранный режим Выйти из полноэкранного режима

Иногда лекарство хуже болезни — то есть рефакторинг кода для устранения ошибки типа будет слишком разрушительным — поэтому вы сами решаете, является ли утверждение правильным решением, но использовать его следует осторожно.

Вот мой порядок предпочтений:

  1. Попытайтесь решить проблему с помощью лучшей типизации. Ошибка типа часто означает, что A) тип переменной неверен, или B) тип функции, в которую передается переменная, неверен. Если типы не соответствуют действительности, то я стараюсь сделать все возможное, чтобы они соответствовали действительности. Это может потребовать сделать аргумент более разрешительным (использовать протокол), добавить перегрузки в функцию или дженерики в класс.
  2. Если вариант 1 не работает, или дополнительная сложность оказалась неприемлемой, но я знаю фактический тип во время выполнения, тогда я делаю одну из двух вещей:
    1. использовать assert, если я достаточно уверен, что утверждение никогда не сработает. предоставить комментарий, объясняющий, почему оно никогда не сработает.
  3. В качестве последнего запасного варианта, я использую typing.cast и предоставляю комментарий, объясняющий, зачем нужен этот cast.

Я настоятельно рекомендую включить --warn-redundant-casts, чтобы вы могли быть уведомлены, если вызов cast пытается привести выражение к тому же типу. Избыточное приведение может ввести разработчиков в заблуждение, заставив их думать, что тип выражения или переменной не является типом приведения.

Будьте внимательны с кортежами, они бывают двух видов

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

  1. определенная, ограниченная и, возможно, неоднородная группа типов. Например, Tuple[str, int] — это кортеж, содержащий строку и целое число.
  2. неограниченная последовательность однородных типов. например, Tuple[str, ...] (обратите внимание на многоточие!). это последовательность строк произвольной длины.

И, конечно, вы также можете комбинировать эти два типа: Tuple[Tuple[str, int], ...].

Когда mypy определяет тип литерала кортежа, по умолчанию предполагается, что он имеет в виду вариант 1. Чтобы сказать mypy: «Нет, на самом деле я имел в виду вариант 2», вы должны добавить аннотацию. Это особенно раздражает при работе с переменными наследуемых классов.

class Base(object):
    valid_things = ('thing1',)

class A(Base):
    valid_things = ('thing1', 'thing2')  # error: expression has type "Tuple[str, str]", base class defined type as "Tuple[str]"
Войти в полноэкранный режим Выйти из полноэкранного режима

Это приводит к ошибке, потому что Base.validThings является Tuple[str], а A.validThings является Tuple[str, str]. Вот наивное решение этой проблемы:

class Base(object):
    valid_things: Tuple[str, ...] = ('thing1',)

class A(Base):
    valid_things: Tuple[str, ...] = ('thing1', 'thing2')
Войдите в полноэкранный режим Выйти из полноэкранного режима

К сожалению, оно довольно многословное. Одно из решений, которое менее многословно и сохраняет неизменяемость — использовать frozenset (предполагая, конечно, что порядок не имеет значения):


class Base(object):
    valid_things = frozenSet(['thing1'])

class A(Base):
    valid_things = frozenSet(['thing1', 'thing2'])
Войти в полноэкранный режим Выход из полноэкранного режима

Или же мы можем использовать подход «ложной неизменяемости», описанный выше в разделе «Использование абстрактных типов для обеспечения неизменяемости»:

class Base(object):
    valid_things: Sequence[str] = ['thing1']

class A(Base):
    valid_things: Sequence[str] = ['thing1', 'thing2']
Войти в полноэкранный режим Выход из полноэкранного режима

В приведенном выше решении вам действительно нужно переобъявить тип атрибута как Sequence[str] в каждом подклассе, иначе он будет переведен в List[str] и, таким образом, станет изменяемым.

Получите преимущества abc.ABCMeta без конфликтов метаклассов

Цель abc.ABCMeta — выдать ошибку во время выполнения, если вы определяете класс, который не реализует все методы, помеченные как абстрактные, в абстрактном базовом классе. К сожалению, это может вызвать конфликты с другими классами, использующими метаклассы, что особенно неприятно для сторонних проектов, таких как PyQt, PySide, или typing.Generic (до python 3.7). Решения этой проблемы могут быть весьма неблагоприятными.

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


Я что-то упустил? Не стесняйтесь оставлять комментарии с вопросами или другими советами, которые я упустил!

Оцените статью
devanswers.ru
Добавить комментарий