
Привет, Хабр! У нас продолжается распродажа в честь черной пятницы. Там вы найдете много занимательных книг.
Возможен вопрос: а что такое метакласс? Если коротко, метакласс относится к классу точно как класс к объекту.
Метаклассы – не самый популярный аспект языка 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].
