Как [не надо] ломать систему типов 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__ будет проще понять эту конструкцию.

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

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