__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__
— это очень чёрная магия. Уловки с ним могут иметь место в тёмном сердце сложной библиотеки, но, безусловно, не для кода, с которым ваши коллеги будут иметь дело каждый день. Так что да, ничего полезного вы не узнали. Я просто люблю жуткие штуки.
Декоратор
@property
делаетdistance
доступным для чтения и записи в качестве атрибута: вместоpoint.distance()
с ним можно написатьpoint.distance
, и__subclasshook__
будет проще понять эту конструкцию.
А мы поможем прокачать ваши навыки или с самого начала освоить профессию, актуальную в любое время:
Выбрать другую востребованную профессию.