Как [не надо] ломать систему типов Python, или Криминал в сопоставлении с образцом

от автора

__subclasshook__ — один из моих любимых элементов Python. Абстрактные базовые классы (ABC — Abstract Base Class) с помощью __subclasshook__ могут указывать, что считается подклассом ABC, даже если целевой класс не знает об ABC:

class PalindromicName(ABC):    @classmethod   def __subclasshook__(cls, C):     name = C.__name__.lower()     return name[::-1] == name  class Abba:   ...  class Baba:   ...  >>> isinstance(Abba(), PalindromicName) True >>> isinstance(Baba(), PalindromicName) False

Странные вещи можно делать с этим __subclasshook__. Ещё в 2019 году я использовал его для создания немонотонных типов, где чтото считается NotIterable, когда не имеет метода __iter__. И не было ничего слишком уж дьявольского, что можно было с этим сделать, ведь ничто в Python не взаимодействовало с ABC. И это сокращало ущерб коду в продакшене.

Но в Python 3.10 добавили сопоставление с образцом.

Краткий обзор сопоставления с образцом

Из туториала:

match command.split():     case ["quit"]:         print("Goodbye!")         quit_game()     case ["look"]:         current_room.describe()     case ["get", obj]:         character.get(obj, current_room)

Сопоставлять можно массивы, словари и пользовательские объекты. Для поддержки сопоставления объектов используется isinstance(obj, class). Этот код проверяет случаи, когда:

  • obj имеет тип class;

  • obj — транзитивный подтип class;

  • class — это ABC и он определяет __subclasshook__, соответствующий типу obj.

Это заставило меня задуматься, способны ли ABC «угнать» сопоставление с образцом:

from abc import ABC  class NotIterable(ABC):      @classmethod     def __subclasshook__(cls, C):         return not hasattr(C, "__iter__")  def f(x):     match x:         case NotIterable():             print(f"{x} is not iterable")         case _:             print(f"{x} is iterable")  if __name__ == "__main__":     f(10)     f("string")     f([1, 2, 3])

Конечно, Python прекратит эти придирки, правильно?

$ py10 abc.py 10 is not iterable string is iterable [1, 2, 3] is iterable

Ох…

Сделаем хуже

Сопоставление с образцом может деструктурировать поля объекта:

match event.get():     case Click(position=(x, y)):         handle_click_at(x, y)

Получить поле можно только после определения объекта. Если не использовать ABC, то не сопоставляется «любой объект, у которого есть поле foo»:

from abc import ABC from dataclasses import dataclass from math import sqrt  class DistanceMetric(ABC):      @classmethod     def __subclasshook__(cls, C):         return hasattr(C, "distance")  def f(x):     match x:         case DistanceMetric(distance=d):             print(d)         case _:             print(f"{x} is not a point")  @dataclass class Point2D:     x: float     y: float      @property     def distance(self):         return sqrt(self.x**2 + self.y**2)  @dataclass class Point3D:     x: float     y: float     z: float      @property     def distance(self):         return sqrt(self.x**2 + self.y**2 + self.z**2)  if __name__ == "__main__":     f(Point2D(10, 10))     f(Point3D(5, 6, 7))     f([1, 2, 3])
14.142135623730951 10.488088481701515 [1, 2, 3] is not a point

Теперь лучше! ABC разрешает вопрос сопоставления, а объект — вопрос деструктурирования, то есть можно сделать так:

def f(x):     match x:         case DistanceMetric(z=3):             print(f"A point with a z-coordinate of 3")         case DistanceMetric(z=z):             print(f"A point with a z-coordinate that's not 3")         case DistanceMetric():             print(f"A point without a z-coordinate")         case _:             print(f"{x} is not a point")

Комбинаторы

Сопоставление с образцом — это гибкий, но и достаточно ограниченный инструмент: оно возможно только с типом объекта. А значит, отдельный ABC нужно писать для всего, что мы должны тестировать. К счастью, это ограничение можно обойти: типизация в Python динамическая; в 99% случаев это значит: «вам не нужны статические типы, если вы не против, чтобы во время выполнения чтонибудь упало». Но ещё это означает, что информация о типах существует во время выполнения, и во время выполнения типы могут создаваться.

Можно ли воспользоваться этим при сопоставлении с образцом? Давайте попробуем:

def Not(cls):     class _Not(ABC):         @classmethod         def __subclasshook__(_, C):             return not issubclass(C, cls)     return _Not  def f(x):     match x:         case Not(DistanceMetric)():             print(f"{x} is not a point")         case _:             print(f"{x} is a point")

Not принимает класс, определяет новый ABC, устанавливает для этого ABC хук на «всё, что не относится к классу», — и возвращает этот ABC:

    case Not(DistanceMetric)():                             ^ SyntaxError: expected ':'

Ошибка! Наконец, мы добрались до предела сопоставления с образцом в ABC. Но это «просто» синтаксическая ошибка:

+   n = Not(DistanceMetric)     match x: -       case Not(DistanceMetric)(): +       case n():
PlanePoint(x=10, y=10) is a point SpacePoint(x=5, y=6, z=7) is a point [1, 2, 3] is not a point

Получилось! И просто для проверки напишем And:

from abc import ABC from dataclasses import dataclass from collections.abc import Iterable  def Not(cls):     class _Not(ABC):         @classmethod         def __subclasshook__(_, C):             return not issubclass(C, cls)     return _Not  def And(cls1, cls2):     class _And(ABC):         @classmethod         def __subclasshook__(_, C):             return issubclass(C, cls1) and issubclass(C, cls2)     return _And   def f(x):     n = And(Iterable, Not(str))     match x:         case n():             print(f"{x} is a non-string iterable")         case str():             print(f"{x} is a string")         case _:             print(f"{x} is a string or not-iterable")   if __name__ == "__main__":     f("abc")     f([1, 2, 3])

Работает, """как ожидается""".

Всем заправляет кеширование

Это заставило меня задуматься: «Что, если функция __subclasshook__ не была бы чистой?» Можно ли написать ABC, соответствующий первому, но не последующему переданному типу?

from abc import ABC  class OneWay(ABC):     seen_classes = set()      @classmethod     def __subclasshook__(cls, C):         print(f"trying {C}")         if C in cls.seen_classes:             return False         cls.seen_classes |= {C}         return True  def f(x):     match x:         case OneWay():             print(f"{x} is a new class")         case _:             print(f"we've seen {x}'s class before")  if __name__ == "__main__":     f("abc")     f([1, 2, 3])     f("efg")

Увы, всё бесполезно:

trying <class 'str'> abc is a new class trying <class 'list'> [1, 2, 3] is a new class efg is a new class

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

class FlipFlop(ABC):     flag = False      @classmethod     def __subclasshook__(cls, _):         cls.flag = not cls.flag         return cls.flag

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

А этот ABC спрашивает пользователя, что он должен делать с каждым типом:

class Ask(ABC):     first_class = None      @classmethod     def __subclasshook__(cls, C):         choice = input(f"hey should I let {C} though [y/n]  ")         if choice == 'y':             print("okay we'll pass em through")             return True         return False

Попробуйте эти два ABC выше в сопоставлении с образцом. Они работают!

Должен ли я этим пользоваться?

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


  1. Декоратор @property делает distance доступным для чтения и записи в качестве атрибута: вместо point.distance() с ним можно написать point.distance, и __subclasshook__ будет проще понять эту конструкцию.

А мы поможем прокачать ваши навыки или с самого начала освоить профессию, актуальную в любое время:

Выбрать другую востребованную профессию.


ссылка на оригинал статьи https://habr.com/ru/company/skillfactory/blog/680744/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *