View on GitHub

Methodics of Python Intro

Practical materials for learning Python

Эта глава в большей мере посвящена метаклассам — особым конструкторам классов в Python. Здесь же подробно рассматривается упоминавшееся где-то давно сопоставление шаблону. Просто потому что больше этого делать негде. Также здесь затрагиваются основы работы с аннотациями, подробно этот вопрос обсуждается в отдельной главе про статическую типизацию.

Быстрый поиск


Метаклассы

Перед тем, как погрузиться в изучение метаклассов, рассмотрим специальный метод __init_subclass__ — он позволяет при создании класса-наследника передать ему дополнительные параметры:

>>> class Isub:
...     def __init_subclass__(self, *args, **kwargs):
...         print(args, kwargs)
...         super().__init_subclass__(*args, **kwargs)
...
>>> class C(Isub):
...     pass
...
() {}
>>> class Isub:
...     def __init_subclass__(self, **kwargs):
...         print(kwargs)
...         super().__init_subclass__()
...
>>> class C(Isub, parameters="QQ"):
...     pass
...
{'parameters': 'QQ'}  
>>>

Этот метод по умолчанию является классовым, но никак не помечается (@classmethod)


Теперь про сами метаклассы. Зададимся вопросом: с помощью чего можно сделать класс? Можно явно его описать, можно вызвать функцию-генератор (мы уже рассматривали type), сделать декоратор для одного класса и получать другой класс (при этом можно непосредственно создавать новый класс, а можно просто патчить текущий). Также, как мы только что изучили, можно породить производный класс и встраиванием __init_subclass__ его улучшать.

Метакласс — это своего рода универсальная заглушка, которая своей сутью, созданием классов и метаклассов (да, рекурсия здесь работает), решает бесконечную диллему первосоздателя. В обычной жизни метакласс почти наверное не нужен для полноценного программирования. Но его возможности дают дополнительные ходы в создании объектов.

Например, рассмотрим функцию type, которая, вообще говоря, класс, и, если быть совсем точным¸ метакласс.

>>> type  
<class 'type'>  
>>> type(type)  
<class 'type'>  
>>>
>>> C = type('C', (), {'A': 123})  
>>> C  
<class '__main__.C'>  
>>>

Что будет, если мы создадим производный от type класс? Получим другой конструктор классов — другой метакласс. Для его применения необходимо указать его в специальном параметре metaclass при описании класса, по умолчанию там зафиксировано значение type.

>>> class overtype(type):  
...     def __init__(self, *args, **kwargs):
...         print(args, kwargs)
...         super().__init__(*args, **kwargs)
...
>>> C = overtype('C', (), {'A': 123})  
('C', (), {'A': 123}) {}  
>>>
>>> class C(metaclass=overtype):  
...     A = 100500
...
('C', (), {'__module__': '__main__', '__qualname__': 'C', '__firstlineno__': 1, 'A': 100500, '__static_  
attributes__': ()}) {}  
>>>

Структура метакласса

Протокол метакласса включает в себя 4 основных метода:

from pprint import pprint
class ctype(type):
    @classmethod
    def __prepare__(metacls, name, bases, **kwds):
        pprint(("prepare", name, bases, kwds))
        return super().__prepare__(name, bases, **kwds)
    def __new__(metacls, name, parents, ns, **kwds):
        pprint(("new", metacls, name, parents, ns, kwds))
        return super().__new__(metacls, name, parents, ns)
    def __init__(cls, name, parents, ns, **kwds):
        pprint(("init", cls, parents, ns, kwds))
        return super().__init__(name, parents, ns)
    def __call__(cls, *args, **kwargs):
        pprint(("call", cls, args, kwargs))
        return super().__call__(*args, **kwargs)
class C(int, metaclass=ctype, parameter="See me"):
    field = 42
print("Create an instance:")
c = C("100500", base=16)
print(c, c % 256, type(c), type(c % 256))

C __prepare__ и __new__ нужно быть особо внимательными: это строго классовый и статический методы, причём для первого явное указания обязательно, а для второго — нет.

>>> from pprint import pprint  
...
... class ctype(type):  
...     @classmethod
...     def __prepare__(metacls, name, bases, **kwds):
...         pprint(("prepare", name, bases, kwds))
...         return super().__prepare__(name, bases, **kwds)
...
...     def __new__(metacls, name, parents, ns, **kwds):
...         pprint(("new", metacls, name, parents, ns, kwds))
...         return super().__new__(metacls, name, parents, ns)
...
...     def __init__(cls, name, parents, ns, **kwds):
...         pprint(("init", cls, parents, ns, kwds))
...         return super().__init__(name, parents, ns)
...
...     def __call__(cls, *args, **kwargs):
...         pprint(("call", cls, args, kwargs))
...         return super().__call__(*args, **kwargs)
...
... class C(int, metaclass=ctype, parameter="See me"):  
...     field = 42
...
... print("Create an instance:")  
... c = C("100500", base=16)  
... print(c, c % 256, type(c), type(c % 256))  
...
('prepare', 'C', (<class 'int'>,), {'parameter': 'See me'})  
('new',  
<class '__main__.ctype'>,  
'C',  
(<class 'int'>,),  
{'__firstlineno__': 21,  
 '__module__': '__main__',
 '__qualname__': 'C',
 '__static_attributes__': (),
 'field': 42},
{'parameter': 'See me'})  
('init',  
<class '__main__.C'>,  
(<class 'int'>,),  
{'__firstlineno__': 21,  
 '__module__': '__main__',
 '__qualname__': 'C',
 '__static_attributes__': (),
 'field': 42},
{'parameter': 'See me'})  
Create an instance:  
('call', <class '__main__.C'>, ('100500',), {'base': 16})  
1049856 0 <class '__main__.C'> <class 'int'>  
>>>

Примеры использования метаклассов

На практике метаклассы используются для описания каких=то специальных свойств на уровне работы самих классов.

Первый пример — ненаследуемый класс. В момент создания класса проверяется наличие метакласса в MRO создаваемого класса (в параметр parent приезжает именно mro-последовательность родителей без текущего класса [его же ещё не существует]), при его наличии создания блокируется.

>>> class Final(type):  
...     def __new__(metacls, cls, parents, namespace):
...         for parent in parents:
...             if isinstance(parent, Final):
...                 raise TypeError(f"No inheritancefrom {parent}")
...         return super().__new__(metacls, cls, parents, namespace)
...
>>> class E(metaclass=Final):  
...     a = 1
...
>>> class D(E):  
...     pass
...
Traceback (most recent call last):  
 File "<python-input-26>", line 1, in <module>
   class D(E):
       pass
 File "<python-input-24>", line 5, in __new__
   raise TypeError(f"No inheritancefrom {parent}")
TypeError: No inheritancefrom <class '__main__.E'>  
>>>

Ещё один классический пример использования метаклассов — Синглтон, класс с единственным экземпляром. Проверка осуществляется непосредственно перед созданием экземпляра класса, при наличии экземпляра просто присылается ссылка на него.

>>> class Singleton(type):  
...     _instance = None
...     def __call__(cls, *args, **kwargs):
...         if cls._instance is None:
...             cls._instance = super().__call__(*args, **kwargs)
...         return cls._instance
...
>>> class A(metaclass=Singleton):  
...     B = 100500
...
>>> a = A()  
>>> a.B  
100500  
>>> a  
<__main__.A object at 0x7fe3d0aa1400>  
>>> b = A()  
>>> b.B  
100500  
>>> b.B = 42  
>>> a.B  
42  
>>> b  
<__main__.A object at 0x7fe3d0aa1400>  
>>> a is b  
True  
>>>

Оператор Match-case

От метаклассов переходим к Match-case. Формальное его название — «Структурное сопоставление шаблону». Синтаксис шаблонов сильно отличается от Python, поскольку в match-case используется другой вид парсера — PEG-парсеру вместо LL —, это позволяет отделять контексты в шаблонах.

Классические стуктуры мы уже рассматривали: match-case отлично заменяет последовательности if-elif-else конструкций для проверки множества условий на вид данных:

>>> cmd = "help command"  
>>> cmd.split()  
['help', 'command']  
>>> if cmd.split() == ['help']:
...     print("help")
... elif cmd.split() == ['help', 'command']:  
...     print('smth more')
...     # …
...
smth more  
>>>

>>> match cmd.split():  
...     case ['help']:
...         print('HELP')
...     case ['help', 'command']:
...         print('Command help')
...     case _:
...         print("UNKNOWN")
...
Command help  

>>> cmd = 'PAWSHFPA'  
>>> match cmd.split():  
...     case ['help']:
...         print('HELP')
...     case ['help', 'command']:
...         print('Command help')
...     case _:
...         print("UNKNOWN")
...
UNKNOWN  
>>>

Напомним, что в шаблонах поддерживаются связанные переменные. Более того, поскольку дял match-case не создаётся отдельного пространства имён (Python вообще не делает их без явного указания или строгих случаев, как в функциях, например), переменные продолжают жить после match-case:

>>> cmd = 'help me!'  
... match cmd.split():  
...     case ['help']:
...         print('HELP')
...     case ['help', 'command']:
...         print('Command help')
...     case ['help', command]:
...         print(f'help on {command}')
...     case _:
...         print("UNKNOWN")
...
help on me!  
>>> command  
'me!'  
>>>

Переменные могут быть множественными, т.е. поддерживать упаковку и распаковку объектов:

>>> cmd = 'help me please!'  
... match cmd.split():  
...     case ['help']:
...         print('HELP')
...     case ['help', 'command']:
...         print('Command help')
...     case ['help', command]:
...         print(f'help on {command}')
...     case _:
...         print("UNKNOWN")
...
UNKNOWN  
>>> cmd = 'help me please!'  
... match cmd.split():  
...     case ['help']:
...         print('HELP')
...     case ['help', 'command']:
...         print('Command help')
...     case ['help', command]:
...         print(f'help on {command}')
...     case ['help', *cmds]:
...         print('Hepl on', cmds)
...     case _:
...         print("UNKNOWN")
...
Hepl on ['me', 'please!']  
>>>

Принцип поиска шаблона — до первого совпадения. При этом для вариантов одного уровня можно задавать альтернативы, для них используется перегруженный оператор побитового ИЛИ:

>>> cmd = 'usage'  
... match cmd.split():  
...     case ['help']:
...         print('HELP')
...     case ['help', 'command']:
...         print('Command help')
...     case ['help', command]:
...         print(f'help on {command}')
...     case ['help', *cmds]:
...         print('Hepl on', cmds)
...     case _:
...         print("UNKNOWN")
...
UNKNOWN  
>>> cmd = 'usage'  
... match cmd.split():  
...     case ['help' | 'usage']:
...         print('HELP')
...     case ['help', 'command']:
...         print('Command help')
...     case ['help', command]:
...         print(f'help on {command}')
...     case ['help', *cmds]:
...         print('Hepl on', cmds)
...     case _:
...         print("UNKNOWN")
...
HELP  
>>>

Для альтернатив также могут использоваться связанные переменные. Для этого используется дополнительное ключевое слово as:

>>> cmd = 'usage'  
... match cmd.split():  
...     case ['help' | 'usage' as topic]:
...         print(f'HELP = {topic}')
...     case ['help', 'command']:
...         print('Command help')
...     case ['help', command]:
...         print(f'help on {command}')
...     case ['help', *cmds]:
...         print('Hepl on', cmds)
...     case _:
...         print("UNKNOWN")
...
HELP = usage  
>>>

К шаблонам можно добавлять фильтры:

>>> cmd = 'help go go'  
... match cmd.split():  
...     case ['help' | 'usage' as topic]:
...         print(f'HELP = {topic}')
...     case ['help', 'command']:
...         print('Command help')
...     case ['help', command]:
...         print(f'help on {command}')
...     case ['help', *cmds] if len(cmds) < 3:
...         print('Hepl on', cmds)
...     case _:
...         print("UNKNOWN")
...
Hepl on ['go', 'go']  

>>> cmd = 'help go go go'  
... match cmd.split():  
...     case ['help' | 'usage' as topic]:
...         print(f'HELP = {topic}')
...     case ['help', 'command']:
...         print('Command help')
...     case ['help', command]:
...         print(f'help on {command}')
...     case ['help', *cmds] if len(cmds) < 3:
...         print('Hepl on', cmds)
...     case _:
...         print("UNKNOWN")
...
UNKNOWN  
>>>

В Match-case предусмотрено сопоставление структур и типов согласно параметрам создания объектов (то есть можно задать просто класс, а можно указать, какие поля в нём должны проверяться для более точного совпадения):

>>> match v := eval(input()):  
...     case int(n):
...         print(f'integer {n}')
...     case float():
...         print(f'Float {v}')
...
123  
integer 123  
>>> match v := eval(input()):  
...     case int(n):
...         print(f'integer {n}')
...     case float():
...         print(f'Float {v}')
...
123.123  
Float 123.123  
>>> # это про структуру класса, его первое поле мы закрываем связанной переменной  
>>>

При этом мы можем также указывать связанные переменные, например, для параметров из структуры. n из прошлого примера именно такая.

>>> class C:  
...     A = 123
...
>>> c = C()  
>>> c.A = 100500  
>>>
>>> match c:  
...     case C(A=100500):
...         print('Y')
...
Y  
>>> c.A = 42  
>>> match c:  
...     case C(A=100500):
...         print('Y')
...
>>>

Можно подвязываться как поимённо, так и позиционно

>>> from collections import namedtuple  
... C = namedtuple("C", "a b")  
... for c in C(2, 3), C(1, 2), C(2, 1), C(42, 100500), C(-1, -1):  
...     match c:
...         case C(2, 3):                           # Позиционное перечисление
...             print(C, "with 2 and 3")
...         case C(a=1, b=V) | C(a=V, b=1):         # Поимённое перечисление, одна переменная связана
...             print(C, "with 1 and", V)
...         case C(42):                             # Позиционное задание только одного поля
...             print("Special", C)
...         case C(A, b=B):                         # Одна переменная связана позиционно, другая — именем
...             print("Any", C, "with", A, "and", B)
...
<class '__main__.C'> with 2 and 3  
<class '__main__.C'> with 1 and 2  
<class '__main__.C'> with 1 and 2  
Special <class '__main__.C'>  
Any <class '__main__.C'> with -1 and -1  
>>>

Можно искать по шаблонам словари, однако при этом будет браться минимальный блок словаря, удовлетворяющий требованиям:

>>> d = {1:2, 3:4, 5:6}  
... match d:  
...     case {3:4}:
...         print("3:4")
...
3:4  
>>> d = {1:2, 3:4, 5:7}  
... match d:  
...     case {3:4, **tail}:
...         print("3:4", tail)
...
3:4 {1: 2, 5: 7}  
>>>

Аннотации

Зададим небольшое вводное описание аннотаций. Их возникновение, в первую очередь, связано с главным достоинством и недостатком Python — с неявной типизацией. Duck Typing помогает быстро писать короткий код, не требуя перечислений всех типов. Использование функций становится использованием алгоритмов, а не преобразователей типов. Неявная динамическая строгая типизация помогает делать код читаемым и маленьким, однако все ошибки с типами становятся RunTime-ошибками. Статический анализ невозможно добавить без описания типов, и проверять приходится прямо на ходу. Аннотации — это синтаксис питона, который позволяет сообщать подсказки аргументов и возвращаемых значений функций, а также полей классов.

>>> class C:  
...     A: int
...     B: float = 1.2
...     def __init__(self, a: int, b: float) -> None:
...         self.A, self.B = a, b
...     def sum(self) -> float:
...         return self.A + self.B
...
>>> c = C('#WS', 'POI')  
>>> c.sum()  
'#WSPOI'  
>>>

Просмотреть аннотации объектов можно с помощью специального модуля inspect, в котором есть выделенные для этого функции:

>>> import inspect  
>>> inspect.get_annotations(C)  
{'A': <class 'int'>, 'B': <class 'float'>}  
>>> ann = inspect.get_annotations(C)  
>>> ann['A']  
<class 'int'>  
>>> ann['B']  
<class 'float'>  
>>> ann = inspect.get_annotations(C.sum)  
>>> ann  
{'return': <class 'float'>}  
>>> inspect.get_annotations(C.__init__)  
{'a': <class 'int'>, 'b': <class 'float'>, 'return': None}  

При этом наличие аннотации не означает наличие объекта или требование строгого следования ему. Это, буквально, лишь подсказка; логика использования её для непосредственной проверки ложится на плечи внешних систем: отдельных анализаторов, работающих поверх (MyPy); просто специальных алгоритмов разработчика и др.

>>> C.B  
1.2  
>>> C.A  
Traceback (most recent call last):  
 File "<python-input-87>", line 1, in <module>
   C.A
AttributeError: type object 'C' has no attribute 'A'  
>>>

Более того, аннотациями может быть вообще любой питоновский объект. Это просто хранилище, с которым сверяются алгоритмы, которые должны это делать

>> class C:  
...     a: "askfiha"  = 12
...     b: 42 = 'QQ'
...
>>> inspect.get_annotations(C)  
{'a': 'askfiha', 'b': 42}  
>>>