Pull to refresh

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

Reading time8 min
Views41K

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

Содержание

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

Часто, когда речь заходит о 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. Кроме того, если вы используете аннотации и проверки типов в своих проектах, то новые протоколы могут сделать код более гибким, сохраняя при этом безопасность и уверенность, которую дает статическая типизация.

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

Примечания

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

Tags:
Hubs:
Total votes 30: ↑30 and ↓0+30
Comments9

Articles