Протоколы в Python: утиная типизация по-новому

Kate

Administrator
Команда форума
В новых версиях Python аннотации типов получают всё большую поддержку, всё чаще и чаще используются в библиотеках, фреймворках, и проектах на Python. Помимо дополнительной документированности кода, аннотации типов позволяют таким инструментам, как mypy, статически произвести дополнительные проверки корректности программы и выявить возможные ошибки в коде. В этой статье пойдет речь об одной, как мне кажется, интересной теме, касающейся статической проверки типов в Python – протоколах, или как сказано в PEP-544, статической утиной типизации.

ngbcit4xnqke0rlw_fcudqiyq6e.jpeg

Содержание​

Утиная типизация​



Часто, когда речь заходит о Python, всплывает фраза утиная типизация, или даже что-нибудь вроде:

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

>>> class Meter:
... def __len__(self):
... return 1_000
...
>>> len([1, 2, 3])
3
>>> len("Duck typing...")
14
>>> len(Meter())
1000
В примере выше функции len не важен тип аргумента, а важно лишь то, что у объекта можно вызвать метод __len__().

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

Номинальная типизация​



При номинальной типизации (nominal type system) совместимость типов определяется, основываясь на явных декларациях в коде программы, например, на именах классов и иерархии наследования. Если класс Duck явно объявлен наследником класса Bird, то объекты класса Duck могут быть использованы везде, где ожидаются объекты класса Bird. Применительно к Python, mypy может статически, без непосредственного запуска программы, основываясь только на исходном коде, проверить такую совместимость.

Рассмотрим небольшой пример:

class Bird:
def feed(self) -> None:
print("Feeding the bird...")

class Duck(Bird):
def feed(self) -> None:
print("Feeding the duck...")

class Goose:
"""
Этот класс по каким-то причинам не объявлен наследником класса Bird.
"""
def feed(self) -> None:
print("Feeding the goose...")

def feed(bird: Bird) -> None:
bird.feed()

# OK
feed(Bird())

# OK
feed(Duck())

# Mypy error: Argument 1 to "feed" has incompatible type "Goose";
# expected "Bird"
feed(Goose())

# Mypy error: Argument 1 to "feed" has incompatible type "None";
# expected "Bird"
feed(None)
Хотя класс Goose имеет нужный нам метод feed, с точки зрения номинальной типизации он не является подтипом Bird, о чем и сообщает mypy.

Проверка совместимости типов в соответствии с номинальной типизацией и иерархией наследования существует во многих языках программирования. Например, Java, C#, C++ и многие другие языки используют номинальную систему типов.

Структурная типизация​



Структурная типизация (structural type system) определяет совместимость типов на основе структуры этих типов, а не на явных декларациях. Подобный механизм может рассматриваться как некоторый аналог утиной типизации, но для статических проверок, в некотором смысле compile time duck typing.

Структурная типизация также довольно широко распространена. Например, интерфейсы в Go – это набор методов, которые определяют некоторую функциональность. Типы, реализующие интерфейсы в Go не обязаны декларировать каким-либо образом, что они реализуют данный интерфейс, достаточно просто реализовать соответствующие методы интерфейса.

Другой пример – это TypeScript, который также использует структурную систему типов:

// TypeScript

interface Person {
name: String
age: Number
}

function show(person: Person) {
console.log("Name: " + person.name)
console.log("Age: " + person.age)
}

class Employee {
name: String
age: Number

constructor(name: String, age: Number) {
this.name = name
this.age = age
}
}

class Figure {}

// OK
show(new Employee("John", 30))

// OK
show({name: "Peter", age: 25})


// Error:
// Argument of type 'Figure' is not assignable to parameter of type 'Person'.
// Type 'Figure' is missing the following properties
// from type 'Person': name, age

show(new Figure())
Здесь класс Employee является подтипом Person, хотя в коде нет никаких явных деклараций наследования. Важно лишь то, что Employee имеет необходимые свойства name и age. Класс Figure, напротив, не имеет указанных свойств и, следовательно, не может быть использован там, где ожидается Person.

Python и протоколы​



Начиная с версии Python 3.8 (PEP-544), появляется новый механизм протоколов для реализации структурной типизации в Python. Термин протоколы давно существует в мире Python и хорошо знаком всем, кто работает с языком. Можно вспомнить, например, протокол итераторов, протокол дескрипторов, и несколько других.

Новые протоколы в некотором смысле "перегружают" уже устоявшийся термин, добавляя возможность структурно проверять совместимость типов при статических проверках (с помощью, например, mypy). В момент исполнения программы, протоколы в большинстве случаев не имеют какого-то специального значения, являются обычными абстрактными классами (abc.ABC), и не предназначены для инстанциирования объектов напрямую.

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

import typing as t

# t.Iterable[int] - это протокол итераций
def iterate_by(numbers: t.Iterable[int]) -> None:
for number in numbers:
print(number)

# OK
iterate_by([1, 2, 3])

# OK
iterate_by(range(1_000_000))


# Mypy error: Argument 1 to "iterate_by" has incompatible type "str";
# expected "Iterable[int]"
# note: Following member(s) of "str" have conflicts:
# note: Expected:
# note: def __iter__(self) -> Iterator[int]
# note: Got:
# note: def __iter__(self) -> Iterator[str]

iterate_by("duck")
Mypy сообщит нам об ошибке, если переданный в функцию iterate_by объект не будет поддерживать протокол итераций (напомню, у объекта должен быть метод __iter__ возвращающий итератор).

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

# ... продолжение предыдущего примера

class Fibonacci:
def __iter__(self) -> t.Iterator[int]:
a, b = 0, 1
while True:
yield a
a, b = b, a + b

# OK
iterate_by(Fibonacci())

class Animals:
"""
Этот класс, хотя и достаточно интересен сам по себе,
но не поддерживает итерации по целым числам,
поэтому не соответствует нашему протоколу.
"""
def __iter__(self) -> t.Iterator[str]:
yield from ["duck", "cat", "dog"]


# Mypy error: Argument 1 to "iterate_by" has incompatible type "Animals";
# expected "Iterable[int]"

iterate_by(Animals())
В стандартной библиотеке (в модуле typing) определено довольно много протоколов для статических проверок. Полный список и примеры использования встроенных протоколов можно посмотреть в документации mypy.

Пользовательские протоколы​



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

Пример использования​



Разберем небольшой пример использования пользовательских протоколов:

import typing as t

class Figure(t.Protocol):
"""Геометрическая фигура."""

# Протоколы могут определять не только методы, но и атрибуты
name: str

def calculate_area(self) -> float:
"""Вычислить площадь фигуры."""

def calculate_perimeter(self) -> float:
"""Вычислить периметр фигуры."""

def show(figure: Figure) -> None:
print(f"S ({figure.name}) = {figure.calculate_area()}")
print(f"P ({figure.name}) = {figure.calculate_perimeter()}")
Класс декларирующий протокол должен являться наследником класса Protocol определенного в модуле typing. Атрибуты и методы перечисленные в теле класса-протокола должны быть реализованы во всех классах, соответствующих данному протоколу. В общем случае тело методов класса-протокола не имеет значения (хотя и существует возможность добавить реализацию методов по-умолчанию).

Объявим несколько классов, соответствующих протоколу Figure.

# ... продолжение предыдущего примера

class Square:
name = "квадрат"

def __init__(self, size: float):
self.size = size

def calculate_area(self) -> float:
return self.size * self.size

def calculate_perimeter(self) -> float:
return 4 * self.size

def set_color(self, color: str) -> None:
"""
Класс может содержать собственные методы,
которые не относятся к протоколу.
"""
self.color = color

# OK
show(Square(size=3.14))
Обратите внимание, что класс Square номинально не является наследником класса Figure. Mypy может проверить соответствие аргумента функции show протоколу Figure, основываясь на структуре класса Square. В этом смысле, структурная типизация позволяет сократить внутренние зависимости между частями кода. Представим, что протокол Figure и функция show объявлены в одном модуле, а класс Square – в совершенно другом (или даже эти классы находятся в разных библиотеках). При этом между двумя модулями не будет никаких зависимостей, что может способствовать более гибкому проектированию приложения.

Если реализация протокола будет некорректной, то mypy сообщит об ошибке:

# ... продолжение предыдущего примера

class Circle:
PI = 3.1415926
name = "окружность"

def __init__(self, radius: float):
self.radius = radius

def calculate_perimeter(self) -> float:
return 2 * self.PI * self.radius


# Mypy error: Argument 1 to "show" has incompatible type "Circle";
# expected "Figure"
# note: 'Circle' is missing following 'Figure' protocol member:
# note: calculate_area

show(Circle(radius=1))
В данном примере mypy не только сообщает об ошибке в коде программы, но и подсказывает какой метод протокола не реализован (или реализован неправильно).

Явная имплементация протокола​



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

import typing as t
import abc

class Readable(t.Protocol):
@abc.abstractmethod
def read(self) -> str:
...

def get_size(self) -> int:
"""
Этот метод имеет реализацию по-умолчанию.
"""
return 1_000

# OK
class File(Readable):
def read(self) -> str:
return "содержимое файла"

# OK
print(File().get_size()) # Выведет 1000


# Mypy error: Return type "int" of "read" incompatible
# with return type "str" in supertype "Readable"

class WrongFile(Readable):
def read(self) -> int:
return 42
В случае явной имплементации протоколы становятся больше похожи на абстрактные классы (abc.ABC), позволяют проверять корректность реализации методов и свойств, а так же использовать реализацию по-умолчанию. Но опять же, явное наследование не является обязательным, соответствие произвольного объекта протоколу mypy сможет проверить при статическом анализе.

Декоратор runtime_checkable​



В основном протоколы не предназначены для проверок времени выполнения, наподобие isinstance и issubclass. Например, если в коде будет следующая проверка, то mypy сообщит об ошибке:

# ... продолжение предыдущего примера с протоколом Figure

s = Square(4)


# Mypy error: Only @runtime_checkable protocols can be used
# with instance and class checks
# isinstance(s, Figure)

isinstance(s, Figure)
Как видно из текста ошибки, можно использовать специальный декоратор @runtime_checkable, чтобы добавить возможность для проверок соответствий типов в момент выполнения программы.

import typing as t

@t.runtime_checkable
class HasLength(t.Protocol):
def __len__(self) -> int:
...

# OK
print(isinstance("Утка", HasLength)) # Выведет True
print(isinstance([1, 2, 3], HasLength)) # Выведет True
Хотя это и может быть полезно в каких-то случаях, у этого метода есть несколько серьезных ограничений, которые подробно разобраны в PEP-544.

Несколько слов в заключение​



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

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

Примечания​

Полезные ссылки​



Истоник статьи: https://habr.com/ru/post/557898/
 
Сверху