Метаклассы в Python

Kate

Administrator
Команда форума
Метаклассы – не самый популярный аспект языка 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].

Надеюсь, эта статья вам понравилась и подсказала вам какие-то идеи. Метаклассы могут казаться немного непонятными и не всегда полезными. Однако, они определенно позволяют собирать элегантные библиотеки и интерфейсы, если уметь метаклассами пользоваться.

 
Сверху