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



Привет, Хабр! У нас продолжается распродажа в честь черной пятницы. Там вы найдете много занимательных книг.

Возможен вопрос: а что такое метакласс? Если коротко, метакласс относится к  классу точно как класс к объекту.

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

Источники




Комментарии (0):