Метаклассы в 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):

Откуда же взялись эти три аргумента namebases и 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

Для простоты я реализую всего два подкласса FieldIntField и 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].

Источники


ссылка на оригинал статьи https://habr.com/ru/articles/592127/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *