Метаклассы в Python
Привет, Хабр! У нас продолжается распродажа в честь черной пятницы. Там вы найдете много занимательных книг.
Возможен вопрос: а что такое метакласс? Если коротко, метакласс относится к классу точно как класс к объекту.
Метаклассы – не самый популярный аспект языка Python; не сказать, что о них воспоминают в каждой беседе. Тем не менее, они используется в весьма многих статусных проектах: в частности, Django ORM[2], стандартная библиотека абстрактных базовых классов (ABC)[3] и реализации Protocol Buffers [4].
Это сложная фича, позволяющая программисту приспособить под задачу некоторые самые базовые механизмы языка. Именно по причине такой гибкости открываются и возможности для злоупотреблений – но нас это уже не удивляет. С большими возможностями приходит большая ответственность.
Данная тема обычно не затрагивается в различных руководствах и вводных материалах по языку, поскольку считается «продвинутой» — но и с ней надо с чего-то начинать. Я немного поискал в онлайне и в качестве наилучшего введения в тему нашел соответствующий вопрос на StackOverflow и ответы на него [1].
Поехали. Все примеры кода приведены на Python 3.6 – на момент написания статьи это новейшая версия.
Первый контакт
Мы уже кое-что успели обсудить, но пока еще не видели, что представляет собой метакласс. Скоро разберемся с этим, но пока следите за моим рассказом. Начнем с чего-нибудь простого: создадим объект.
>>> o = object()
>>> print(type(o))
<class 'object'>
Мы создали новый object
и сохранили ссылку на него в переменной o
.
Тип o
– это object
.
Мы также можем объявить и наш собственный класс:
>>> class A:
... pass
...
>>> a = A()
>>> print(type(a))
<class '__main__.A'>
Теперь у нас две плохо названные переменные a
и o
, и мы можем проверить, в самом ли деле они относятся к соответствующим классам:
>>> isinstance(o, object)
True
>>> isinstance(a, A)
True
>>> isinstance(a, object)
True
>>> issubclass(A, object)
True
Выше заметна одна интересная вещь: объект a
также относится к типу object
. Ситуация такова, поскольку класс A
является подклассом object
(все классы, определяемые пользователем, наследуют от object
).
Еще одна интересная вещь – во многих контекстах мы можем взаимозаменяемо применять переменные a
и A
. Для таких функций как print
невелика разница, какую переменную мы ей выдадим, a
или A
– оба вызова «что-то» выведут на экран.
Давайте поподробнее рассмотрим класс B
, который мы только что определили:
>>> class B:
... def __call__(self):
... return 5
...
>>> b = B()
>>> print(b)
<__main__.B object at 0x1032a5a58>
>>> print(B)
<class '__main__.B'>
>>> b.value = 6
>>> print(b.value)
6
>>> B.value = 7
>>> print(B.value)
7
>>> print(b())
5
>>> print(B())
<__main__.B object at 0x1032a58d0>
Как видим, b
и B
во многих отношениях действуют похоже. Можно даже сделать выражение с вызовом функции, в котором использовались бы обе переменные, просто возвращены в данном случае будут разные вещи: b
возвращает 5, как и указано в определении класса, тогда как B
создает новый экземпляр класса.
Это сходство – не случайность, а намеренно спроектированная черта языка. В Python классы являются сущностями первой категории[5] (ведут себя как все нормальные объекты).
Более того, если классы – как объекты, то у них обязательно должен быть собственный тип:
>>> print(type(object))
<class 'type'>
>>> print(type(A))
<class 'type'>
>>> isinstance(object, type)
True
>>> isinstance(A, type)
True
>>> isinstance(A, object)
True
>>> issubclass(type, object)
True
Оказывается, что и object
, и A
относятся к классу type
– type
это "метакласс, задаваемый по умолчанию ". Все остальные метаклассы должны наследовать от него. Возможно, на данном этапе вас уже немного путает, что класс имеет имя type
, но в то же время это и функция, возвращающая тип сообщаемого объекта (семантика у type
будет совершенно разной в зависимости от того, сколько аргументов вы ему сообщите – 1 или 3). В таком виде его сохраняют по историческим причинам.
Как object
, так и A
также являются экземплярами object
– в конечном итоге, все они объекты. Каков же в таком случае тип type
, могли бы вы спросить?
>>> print(type(type))
<class 'type'>
>>> isinstance(type, type)
True
Оказывается, никакого двойного дна здесь нет, поскольку type
относится к собственному типу.
Весь фокус, заключающийся в метаклассах: мы создали A
, подкласс object
, так, чтобы новый экземпляр a
относился к типу A
и, следовательно, object
. Таким же образом можно создать подкласс от type
под названием Meta
. Впоследствии мы можем использовать его как тип для новых классов; они будут экземплярами обоих типов: type
и Meta
.
Рассмотрим это на практике:
class Meta(type):
def __init__(cls, name, bases, namespace):
super(Meta, cls).__init__(name, bases, namespace)
print("Creating new class: {}".format(cls))
def __call__(cls):
new_instance = super(Meta, cls).__call__()
print("Class {} new instance: {}".format(cls, new_instance))
return new_instance
Это наш первый метакласс. Мы могли бы сделать его определение еще более минималистичным, но хотели сделать, чтобы в итоге он делал хотя бы что-нибудь полезное.
Он переопределяет магический метод
__init__
, чтобы на экран выводилось сообщение всякий раз, когда создается новый экземплярMeta
.Он переопределяет магический метод
call
, чтобы выводилось сообщение · всякий раз, когда пользователь применяет синтаксис вызова функций к экземпляру – пишетvariable()
.
Оказывается, что в Python создание экземпляра класса имеет ту же форму, что и вызов функции. Если у нас есть функция f
, то, чтобы вызвать ее, мы пишем f()
. Если у нас есть класс A
, то мы пишем A()
для создания нового экземпляра. Соответственно, мы используем хук __call__
.
Все-таки, метакласс сам по себе не так интересен. Интересное начинается, лишь когда мы создаем экземпляр метакласса. Давайте это и сделаем:
>>> class C(metaclass=Meta):
... pass
...
Creating new class: <class '__main__.C'>
>>> c = C()
Class <class '__main__.C'> new instance: <__main__.C object at 0x10e99ae48>
>>> print(c)
<__main__.C object at 0x10e99ae48>
Действительно, наш метакласс работает как задумано: выводит сообщения, когда в жизненном цикле класса происходят определенные события. В данном случае важно понимать, что мы работаем сразу с тремя разными уровнями абстракции - метаклассом, классом и экземпляром.
Когда мы пишем class C(metaclass=Meta)
, мы создаем C
, представляющий собой экземпляр Meta
- вызывается Meta.init
, и выводится сообщение. На следующем шаге мы вызываем C()
для создания нового экземпляра класса C
, и на этот раз выполняется Meta.__call__
. На последнем шаге мы вывели на экран c
, вызывая C.__str__
, который, в свою очередь, разрешается в заданную по умолчанию реализацию, определенную в базовом классе object
.
Сейчас можем посмотреть все типы наших переменных:
>>> print(type(C))
<class '__main__.Meta'>
>>> isinstance(C, Meta)
True
>>> isinstance(C, type)
True
>>> issubclass(Meta, type)
True
>>> print(type(c))
<class '__main__.C'>
>>> isinstance(c, C)
True
>>> isinstance(c, object)
True
>>> issubclass(C, object)
True
Выше я попытался сделать мягкое введение в тему метаклассов и, надеюсь, вы уже представляете, что это такое, и как ими можно пользоваться. Но, на мой взгляд, этот текст ничего бы не стоил без нескольких практических примеров. К ним и перейдем.
Полезный пример: синглтон
В этом разделе мы напишем совсем маленькую библиотеку, в которой будет малость метаклассов. Мы реализуем "эскиз" для паттерна проектирования синглтон [6] – это класс, который может иметь всего один экземпляр.
Честно говоря, его можно было бы реализовать и без всякого использования метаклассов, просто переопределив метод __new__
в базовом классе, так, чтобы он вернул ранее запомненный экземпляр:
class SingletonBase:
instance = None
def __new__(cls, *args, **kwargs):
if cls.instance is None:
cls.instance = super().__new__(cls, *args, **kwargs)
return cls.instance
Вот и все. Любой подкласс, наследующий от SingletonBase
, теперь проявляет поведение синглтона.
Рассмотрим, каков он в действии:
>>> class A(SingletonBase):
... pass
...
>>> class B(A):
... pass
...
>>> print(A())
<__main__.A object at 0x10c8d8710>
>>> print(A())
<__main__.A object at 0x10c8d8710>
>>> print(B())
<__main__.A object at 0x10c8d8710>
Тот подход, который мы здесь используем, вроде бы работает – при каждой попытке создать экземпляр возвращается тот же самый объект. Но есть и такое поведение, которое может показаться нам неожиданным: при попытке создать экземпляр класса B
мы получаем в ответ тот же самый экземпляр A
, что и раньше.
Эту проблему можно решить, и не прибегая никоим образом к метаклассам, но решение с ними просто очевидное – так почему бы ими не воспользоваться?
У нас будет такой класс SingletonBaseMeta
, чтобы каждый его подкласс при создании инициализировал поле instance
со значением None
.
Вот что получается:
class SingletonMeta(type):
def __init__(cls, name, bases, namespace):
super().__init__(name, bases, namespace)
cls.instance = None
def __call__(cls, *args, **kwargs):
if cls.instance is None:
cls.instance = super().__call__(*args, **kwargs)
return cls.instance
class SingletonBaseMeta(metaclass=SingletonMeta):
pass
Можем попробовать, а работает ли этот подход:
>>> class A(SingletonBaseMeta):
... pass
...
>>> class B(A):
... pass
...
>>> print(A())
<__main__.A object at 0x1101f6358>
>>> print(A())
<__main__.A object at 0x1101f6358>
>>> print(B())
<__main__.B object at 0x1101f6eb8>
Поздравляем, по-видимому наша библиотека-синглтон работает именно так, как и планировалось!
На правах опытных проектировщиков библиотеки с метаклассами, давайте замахнемся на что-нибудь посложнее.
Полезный пример: упрощенное ORM
Как упоминалось выше, с паттерном синглтон можно красиво разобраться, слегка воспользовавшись метаклассами, но острой необходимости в них нет. Большинство реальных проектов, в которых метаклассы действительно используются – это те или иные вариации на тему ORM[7].
В качестве упражнения построим подобный пример, но сильно упрощенный. Это будет уровень сериализации/десериализации между классами Python и JSON.
Вот как должен выглядеть интерфейс, который мы хотим получить (смоделирован на Django ORM/SQLAlchemy):
class User(ORMBase):
""" Пользователь в нашей системе """
id = IntField(initial_value=0, maximum_value=2**32)
name = StringField(maximum_length=200)
surname = StringField(maximum_length=200)
height = IntField(maximum_value=300)
year_born = IntField(maximum_value=2017)
Мы хотим иметь возможность определять классы и их поля вместе с типами. Для этого нам пригодилась бы возможность сериализовать наш класс в JSON:
>>> u = User()
>>> u.name = "Guido"
>>> u.surname = "van Rossum"
>>> print("User ID={}".format(u.id))
User ID=0
>>> print("User JSON={}".format(u.to_json()))
User JSON={"id": 0, "name": "Guido", "surname": "van Rossum", "height": null, "year_born": null}
И десериализовать его:
>>> w = User('{"id": 5, "name": "John", "surname": "Smith", "height": 185, "year_born": 1989}')
>>> print("User ID={}".format(w.id))
User ID=5
>>> print("User NAME={}".format(w.name))
User NAME=John
Для всего вышеприведенного нам не так уж и нужны метаклассы, так что давайте реализуем одну «изюминку» - добавим валидацию.
>>> w.name = 5
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "simple-orm.py", line 96, in __setattr__
raise AttributeError('Invalid value "{}" for field "{}"'.format(value, key))
AttributeError: Invalid value "5" for field "name"
>>> w.middle_name = "Stephen"
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "simple-orm.py", line 98, in __setattr__
raise AttributeError('Unknown field "{}"'.format(key))
AttributeError: Unknown field "middle_name"
>>> w.year_born = 3000
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "simple-orm.py", line 96, in __setattr__
raise AttributeError('Invalid value "{}" for field "{}"'.format(value, key))
AttributeError: Invalid value "3000" for field "year_born"
Напоминание о конструкторе типов
Прежде чем перейти к реализации ORM-библиотеки, я должен напомнить еще об одной вещи, конструкторе типов type
. Я упоминал его лишь вскользь, но это важная тема, которую требуется развернуть.
Вспомните эпизод из предыдущего раздела, когда мы определяли метод __init__
для нашего первого метакласса:
class Meta(type):
def __init__(cls, name, bases, namespace):
Откуда же взялись эти три аргумента name
, bases
и namespace
? Это параметры конструктора типов. Три этих значения полностью описывают класс, создаваемый в данный момент.
name – просто имя класса в формате строки
bases – кортеж базовых классов, может быть пустым
namespace – словарь всех полей, определенных внутри класса. Сюда идут все методы и переменные класса.
Вот и все, что здесь есть. На самом деле, можно было бы и не определять класс при помощи общего синтаксиса, а вызвать конструктор type
напрямую:
class A:
X = 5
def f(self):
print("Class A {}".format(self))
def f(self):
print("Class B {}".format(self))
B = type("B", (), {'X': 6, 'f': f})
В этом коде мы определили два почти идентичных класса, A
и B
.
У них отличаются значения, присвоенные переменной класса X
, и выводятся на экран разные значения при вызове метода f
. Но на этом все – фундаментальных отличий нет, и оба принципа определения классов эквивалентны. Фактически, интерпретатор Python преобразует первый из описанных здесь механизмов во второй.
>>> print(A)
<class '__main__.A'>
>>> print(B)
<class '__main__.B'>
>>> print(A.X)
5
>>> print(B.X)
6
>>> a = A()
>>> b = B()
>>> a.f()
Class A <__main__.A object at 0x1023432b0>
>>> b.f()
Class B <__main__.B object at 0x1023431d0>
Именно на этом этапе определение собственного метакласса позволяет вам влиять на события. Можно перехватывать параметры, передаваемые конструктору type
, изменять их и создавать собственный класс таким образом, как вам угодно.
Упрощенное ORM – грамотная программа
Мы уже знаем, чего хотим – написать библиотеку, удовлетворяющую требованиям указанного интерфейса. Мы также знаем, что будем решать эту задачу при помощи метаклассов.
Далее я приведу реализацию в стиле грамотного программирования. Код из этого раздела можно загрузить в интерпретатор Python и запустить.
Мы будем использовать всего один пакет – для синтаксического разбора/сериализации JSON:
import json
Далее определим базовый класс для всех наших полей. Он устроен весьма просто, как и большинство других отдельных частей данной библиотеки. В нем есть реализация-заглушка для валидационной функции и пустое начальное значение.
class Field:
""" Базовый класс для всех полей. Каждому полю должно быть присвоено начальное значение """
def __init__(self, initial_value=None):
self.initial_value = initial_value
def validate(self, value):
""" Проверить, является ли это значение допустимым для данного поля """
return True
Для простоты я реализую всего два подкласса Field
: IntField
и StringField
. При необходимости можно добавить и другие.
class StringField(Field):
""" Строковое поле. Опционально в нем можно проверять длину строки """
def __init__(self, initial_value=None, maximum_length=None):
super().__init__(initial_value)
self.maximum_length = maximum_length
def validate(self, value):
""" Проверить, является ли это значение допустимым для данного поля """
if super().validate(value):
return (value is None) or (isinstance(value, str) and self._validate_length(value))
else:
return False
def _validate_length(self, value):
""" Проверить, имеет ли строка верную длину """
return (self.maximum_length is None) or (len(value) <= self.maximum_length)
class IntField(Field):
""" Целочисленное поле. Опционально можно проверять, является ли записанное в нем число целым"""
def __init__(self, initial_value=None, maximum_value=None):
super().__init__(initial_value)
self.maximum_value = maximum_value
def validate(self, value):
""" Проверить, является ли это значение допустимым для данного поля """
if super().validate(value):
return (value is None) or (isinstance(value, int) and self._validate_value(value))
else:
return False
def _validate_value(self, value):
""" Проверить, относится ли целое число к желаемому дмапазону """
return (self.maximum_value is None) or (value <= self.maximum_value)
Если не считать перенаправления initial_value
конструктору базового класса, этот код состоит в основном из процедур валидации. Опять же, не сложно добавить в него другие подобные акты валидации, но я хотел показать вам простейшую возможную модель в качестве доказательства концепции.
В StringField
мы хотим проверить, относится ли значение к правильному типу – str
, и является ли длина строки меньшей или равной максимальному значению (если такое значение определено). В поле IntField
мы проверяем, является ли значение целым числом, и является ли оно меньшим или равным, чем сообщенное максимальное значение.
Важно отметить: мы допускаем, чтобы значения в полях были равны None
. В качестве интересного упражнения предлагаю читателю реализовать обязательные поля, в которых не допускается значение None
.
Следующий фрагмент кода – это наш метакласс:
class ORMMeta(type):
""" Метакласс для нашего собственного ORM """
def __new__(self, name, bases, namespace):
fields = {
name: field
for name, field in namespace.items()
if isinstance(field, Field)
}
new_namespace = namespace.copy()
# Удалить поля, относящиеся к переменным класса
for name in fields.keys():
del new_namespace[name]
new_namespace['_fields'] = fields
return super().__new__(self, name, bases, new_namespace)
Наш метакласс совсем не кажется сложным. В нем одна функция, и единственное его назначение – собрать все экземпляры Field
в новую переменную класса, которая называется _fields
. Все экземпляры полей также удаляются из словаря класса.
Единственная вещь, для которой нам нужен наш метакласс – чтобы он подключался в момент, когда создается наш класс, брал все определения полей и сохранял их все в одном месте.
Собственно, большая часть фактической работы выполняется в базовом классе нашей библиотеки:
class ORMBase(metaclass=ORMMeta):
""" Пользовательский интерфейс для базового класса """
def __init__(self, json_input=None):
for name, field in self._fields.items():
setattr(self, name, field.initial_value)
# Если предоставляется JSON, то мы разберем его
if json_input is not None:
json_value = json.loads(json_input)
if not isinstance(json_value, dict):
raise RuntimeError("Supplied JSON must be a dictionary")
for key, value in json_value.items():
setattr(self, key, value)
def __setattr__(self, key, value):
""" Установщик магического метода """
if key in self._fields:
if self._fields[key].validate(value):
super().__setattr__(key, value)
else:
raise AttributeError('Invalid value "{}" for field "{}"'.format(value, key))
else:
raise AttributeError('Unknown field "{}"'.format(key))
def to_json(self):
""" Преобразовать заданный объект в JSON """
new_dictionary = {}
for name in self._fields.keys():
new_dictionary[name] = getattr(self, name)
return json.dumps(new_dictionary)
У класса ORMBase
три метода, и у каждого из них своя конкретная задача:
__init__
- первым делом, установить все поля в начальные значения. Затем, если в качестве параметра передается документ в формате JSON, разобрать его и присвоить значения, полученные в процессе считывания, полям нашей модели.__setattr__
- Это магический метод, вызываемый всякий раз, когда кто-нибудь пытается присвоить значение атрибуту класса. Когда кто-нибудь записываетobject.attribute = value
, вызывается методobject.__setattr__("attribute", value)
. Переопределив этот метод, мы можем изменить поведение, заданное по умолчанию, в данном случае – при помощи инъекции валидационного кода.to_json
– простейший из всех методов в классе. Просто принимает все значения полей и сериализует их в документ JSON.
Вот и вся реализация – наша библиотека готова. Можете сами убедиться, что она работает как положено, и менять ее, если считаете, что она должна работать иначе.
>>> User('{"name": 5}')
Traceback (most recent call last):
File "/usr/local/lib/python3.6/site-packages/IPython/core/interactiveshell.py", line 2881, in run_code
exec(code_obj, self.user_global_ns, self.user_ns)
File "<ipython-input-1-76a1a93378fc>", line 1, in <module>
User('{"name": 5}')
File "/Users/jrx/repos/metaclass-playground/simple-orm.py", line 86, in __init__
setattr(self, key, value)
File "/Users/jrx/repos/metaclass-playground/simple-orm.py", line 94, in __setattr__
raise AttributeError('Invalid value "{}" for field "{}"'.format(value, key))
AttributeError: Invalid value "5" for field "name"
Заключительные замечания
Весь код к этому посту можно скачать в репозитории на GitHub [8].
Надеюсь, эта статья вам понравилась и подсказала вам какие-то идеи. Метаклассы могут казаться немного непонятными и не всегда полезными. Однако, они определенно позволяют собирать элегантные библиотеки и интерфейсы, если уметь метаклассами пользоваться.
Подробнее о том, как метаклассы используются в реальной жизни, можно почитать в статье [9].