Скидки до 20% + 2-ая профессия бесплатно и подарки на 50 000₽

Главная | Все статьи | Код

Продвинутый Python, часть 3: классы и метаклассы

Время чтения статьи ~14 минут 5
Продвинутый Python, часть 3: классы и метаклассы главное изображение

Это завершающая статья цикла «Продвинутый Python», в которой пойдёт речь о классах и метаклассах. В первой части мы познакомились с итераторами, генераторами и модулем itertools, а во второй говорили о замыканиях, декораторах и модуле functools.

Классы как объекты

Классы в Python — это объекты, как и функции. Сразу после объявления класса Python создаёт объект класса и присваивает его переменной с именем класса. Классы — это объекты с типом type. Из этого правила есть исключения, о которых пойдёт речь ниже.

Объекты класса можно вызывать, то есть в них есть метод __call__. При вызове создаётся объект соответствующего класса. С классами можно обращаться как с другими объектами. Например, можно определять атрибуты, присваивать классы переменным, использовать их там, где требуется вызываемая сущность, например, в map. Когда вы пишете map(str, [1, 2, 3]), список чисел конвертируется в список строк, так как str — это класс.

Посмотрите пример кода, чтобы ближе познакомиться с описанными особенностями.

>>> class C:
...     def __init__(self, s):
...         print(s)
...
>>> MyClass = C
>>> type(C)
<class 'type'>
>>> type(MyClass)
<class 'type'>
>>> MyClass(2)
2
<__main__.C object at 0x7fae87e0d208>
>>> list(map(MyClass, [1,2,3]))
1
2
3
[<__main__.C object at 0x7fae87e0d2e8>, <__main__.C object at 0x7fae87e0d358>, <__main__.C object at 0x7fae87e0d390>]
>>> list(map(C, [1,2,3]))
1
2
3
[<__main__.C object at 0x7fae87e0d3c8>, <__main__.C object at 0x7fae87e0d400>, <__main__.C object at 0x7fae87e0d438>]
>>> C.test_attribute = True
>>> MyClass.test_attribute
True

В некоторых языках, таких как C++, классы можно объявлять только на верхнем уровне модулей. В Python class можно использовать внутри функции. Этот подход можно использовать, чтобы создавать классы на лету. Ниже пример:

>>> def make_class(class_name):
...     class C:
...         def print_class_name(self):
...             print(class_name)
...     return C
...
>>> C1, C2 = map(make_class, ["C1", "C2"])
>>> c1, c2 = C1(), C2()
>>> c1.print_class_name()
C1
>>> c2.print_class_name()
C2
>>> type(c1)
<class '__main__.make_class.<locals>.C'>
>>> type(c2)
<class '__main__.make_class.<locals>.C'>
>>> c1.print_class_name.__closure__
(<cell at 0x7fae89666558: str object at 0x7fae87e0d340>,)

В этом примере классы, созданные с помощью make_class, это разные объекты. Поэтому объекты, созданные этими классами, имеют разный тип. В данном случае устанавливаем имя класса вручную после создания класса. Это похоже на работу с декораторами. Также заметьте, что метод print_class_name созданного класса захватывает его замыкание, в котором есть class_name. Если вы не очень уверенно работаете с замыканиями, самое время перечитать вторую статью из цикла «Продвинутый Python», в которой рассматривались замыкания и декораторы.

Метаклассы

Если классы — это объекты, которые создают объекты, то как называются объекты, которые создают классы? Поверьте, это не загадка «яйцо или курица». Здесь есть чёткий ответ: такие объекты называются метаклассами. Самым простым метаклассом можно считать type. Когда type получает на вход один параметр, он возвращает тип объекта, переданного в качестве параметра. В данном случае он не работает как метакласс. Когда type получает на вход три параметра, он работает как метакласс и создаёт класс на основе переданных параметров. В качестве параметров должны передаваться имя класса, родители (классы, от которых происходит наследование), словарь атрибутов. Последние два параметра могут быть пустыми. Вот пример кода:

>>> MyClass = type('MyClass', (object,), {'my_attribute': 0})
>>> type(MyClass)
<class 'type'>
>>> o = MyClass()
>>> o.my_attribute
0

Обратите внимание, второй параметр должен быть кортежем, поэтому синтаксис может выглядеть странно. Если вам нужны методы, создавайте функции и передавайте их как атрибуты. Вот пример:

>>> def my_class_init(self, attr_value):
...     self.my_attribute = attr_value
...
>>> MyClass = type('MyClass', (object,), {'__init__': my_class_init})
>>> o = MyClass('test')
>>> o.my_attribute
'test'

Можно создавать свои метаклассы: сгодится любой вызываемый (callable) объект, который способен принять три параметра и вернуть объект класса. Такие метаклассы можно применять к классу. Метакласс можно указать при объявлении класса. Давайте рассмотрим этот приём на примере, который заодно продемонстрирует возможности метаклассов:

>>> def my_metaclass(name, parents, attributes):
...     return 'Hello'
...
>>> class C(metaclass=my_metaclass):
...     pass
...
>>> C
'Hello'
>>> type(C)
<class 'str'>

В примере выше C оказывается переменной, которая указывает на строку 'Hello'. Конечно, вряд ли кто-то в здравом уме будет писать такой код. Мы просто хотим посмотреть, как работают метаклассы. Теперь давайте создадим что-то более практичное. В предыдущей статье серии мы видели, как с помощью декоратора можно логировать каждый метод в классе. Давайте сделаем то же самое с помощью метакласса. Позаимствуем декоратор logged из поста о декораторах и итераторах.

def log_everything_metaclass(class_name, parents, attributes):
    print('Creating class', class_name)
    myattributes = {}
    for name, attr in attributes.items():
        myattributes[name] = attr
        if hasattr(attr, '__call__'):
            myattributes[name] = logged(
                "%b %d %Y - %H:%M:%S", class_name + "."
            )(attr)
    return type(class_name, parents, myattributes)


class C(metaclass=log_everything_metaclass):

    def __init__(self, x):
        self.x = x

    def print_x(self):
        print(self.x)
# Usage:
print('Starting object creation')
c = C('Test')
c.print_x()
# Output:
Creating class C
- Running 'C.__init__' on Nov 21 2019 - 12:56:59
- Finished 'C.__init__', execution time = 0.000s
- Running 'C.print_x' on Nov 21 2019 - 12:57:06
Test
- Finished 'C.print_x', execution time = 0.000s

Как видите, у декораторов и метаклассов есть много общего. Фактически, метаклассы умеют всё, что можно сделать с помощью декоратора класса. Синтаксис декораторов более простой и читабельный, поэтому по возможности следует использовать именно их. Метаклассы умеют больше, так как они запускаются перед созданием класса, а не после, как декораторы. Чтобы убедиться в этом, давайте создадим декоратор и метакласс и посмотрим на порядок исполнения.

def my_metaclass(class_name, parents, attributes):
    print('In metaclass, creating the class.')
    return type(class_name, parents, attributes)


def my_class_decorator(class_):
    print('In decorator, chance to modify the class.')
    return class_


@my_class_decorator
class C(metaclass=my_metaclass):
    def __init__(self):
        print('Creating object.')
# Output:
In metaclass, creating the class.
In decorator, chance to modify the class.
Creating object.

Изучайте Python на Хекслете Первые курсы в профессии «Python-программист» доступны бесплатно. Регистрируйтесь и начинайте учиться!

Пример использования метаклассов

Рассмотрим более полезное приложение. Предположим, мы пишем набор классов для обработки ID3v2 тегов, которые используются, например, в MP3-файлах. Подробности можно узнать в «Википедии». Для реализации примера надо понимать, что теги состоят из фреймов. Каждый фрейм содержит четырёхбуквенный идентификатор. Например, TOPE — фрейм имени артиста, TOAL — фрейм названия альбома и так далее. Предположим, нам надо написать класс для каждого типа фреймов. Также нужно дать возможность пользователям библиотеки ID3v2 тегов добавлять собственные классы фреймов для поддержки новых или кастомных фреймов. С помощью метаклассов можно реализовать паттерн «фабрика классов». Это может выглядеть так:

frametype_class_dict = {}


class ID3v2FrameClassFactory(type):
    def __new__(cls, class_name, parents, attributes):
        print('Creating class', class_name)
        # Here we could add some helper methods or attributes to c
        c = type(class_name, parents, attributes)
        if attributes['frame_identifier']:
            frametype_class_dict[attributes['frame_identifier']] = c
        return c

    @staticmethod
    def get_class_from_frame_identifier(frame_identifier):
        return frametype_class_dict.get(frame_identifier)


class ID3v2Frame(metaclass=ID3v2FrameClassFactory):
    frame_identifier = None


class ID3v2TitleFrame(ID3v2Frame, metaclass=ID3v2FrameClassFactory):
    frame_identifier = 'TIT2'


class ID3v2CommentFrame(ID3v2Frame, metaclass=ID3v2FrameClassFactory):
    frame_identifier = 'COMM'


title_class = ID3v2FrameClassFactory.get_class_from_frame_identifier('TIT2')
comment_class = ID3v2FrameClassFactory.get_class_from_frame_identifier('COMM')
print(title_class)
print(comment_class)
# Output:
Creating class ID3v2Frame
Creating class ID3v2TitleFrame
Creating class ID3v2CommentFrame
<class '__main__.ID3v2TitleFrame'>
<class '__main__.ID3v2CommentFrame'>

Конечно, задачу можно решить с помощью декораторов классов. Для сравнения посмотрите, как это может выглядеть.

frametype_class_dict = {}


class ID3v2FrameClass(object):
    def __init__(self, frame_id):
        self.frame_id = frame_id

    def __call__(self, cls):
        print('Decorating class', cls.__name__)
        # Here we could add some helper methods or attributes to c
        if self.frame_id:
            frametype_class_dict[self.frame_id] = cls
        return cls

    @staticmethod
    def get_class_from_frame_identifier(frame_identifier):
        return frametype_class_dict.get(frame_identifier)


@ID3v2FrameClass(None)
class ID3v2Frame(object):
    pass


@ID3v2FrameClass('TIT2')
class ID3v2TitleFrame(ID3v2Frame):
    pass


@ID3v2FrameClass('COMM')
class ID3v2CommentFrame(ID3v2Frame):
    pass


title_class = ID3v2FrameClass.get_class_from_frame_identifier('TIT2')
comment_class = ID3v2FrameClass.get_class_from_frame_identifier('COMM')
print(title_class)
print(comment_class)
Decorating class ID3v2Frame
Decorating class ID3v2TitleFrame
Decorating class ID3v2CommentFrame
<class '__main__.ID3v2TitleFrame'>
<class '__main__.ID3v2CommentFrame'>

Как видите, можно передавать параметры в декораторы, но не в метаклассы. Если нужно передать параметры в метаклассы, это нужно делать через атрибуты. Поэтому код с декораторами чище и проще в поддержке. Заметьте, что ко времени вызова декоратора класс уже создан. Это значит, что уже поздно менять его свойства, предназначенные только для чтения.

Метаклассы, полученные из type

Как сказано выше, самый простой метакласс — это type, и полученные из него классы имеют тип type. Здесь возникает естественный вопрос: что представляет собой тип type. Ответ простой: type. Это значит, что type представляет собой класс, и он выступает в качестве своего метакласса. Это экстраординарно, и это стало возможным на уровне интерпретатора Python. Вручную написать класс, который выступает в качестве своего метакласса, невозможно.

Понимая, что type представляет собой класс, мы можем написать классы, которые наследуются от него. Эти классы можно использовать как метаклассы. Классы, которые их используют, будут иметь такой же тип, как метаклассы, полученные из type. Вот пример:

>>> class meta(type):
...     def __new__(cls, class_name, parents, attributes):
...         print('meta.__new__')
...         return super().__new__(cls, class_name, parents, attributes)
...     def __call__(self, *args, **kwargs):
...         print('meta.__call__')
...         return super().__call__(*args, **kwargs)
...
>>> class C(metaclass=meta):
...     pass
...
meta.__new__
>>> o = C()
meta.__call__

При вызове класса для создания нового объекта вызывается его функция __call__. Она вызывает type.__call__ для создания объекта. В следующем разделе подытожим рассмотренное выше.

Подводим итоги

Предположим, что некий класс C имеет метакласс my_metaclass и декорирован с помощью my_class_decorator. Далее предположим, что my_metaclass представляет собой класс, полученный из type. Соберём всё вместе, чтобы увидеть, как создаётся C и как создаются объекты его типа. Вот как выглядит код:

class my_metaclass(type):
    def __new__(cls, class_name, parents, attributes):
        print('- my_metaclass.__new__ - Creating class instance of type', cls)
        return super().__new__(cls, class_name, parents, attributes)

    def __init__(self, class_name, parents, attributes):
        print('- my_metaclass.__init__ - Initializing the class instance', self)
        super().__init__(class_name, parents, attributes)

    def __call__(self, *args, **kwargs):
        print('- my_metaclass.__call__ - Creating object of type ', self)
        return super().__call__(*args, **kwargs)


def my_class_decorator(cls):
    print('- my_class_decorator - Chance to modify the class', cls)
    return cls


@my_class_decorator
class C(metaclass=my_metaclass):

    def __new__(cls):
        print('- C.__new__ - Creating object.')
        return super(C, cls).__new__(cls)

    def __init__(self):
        print('- C.__init__ - Initializing object.')

c = C()
print('Object c =', c)

На этом этапе вы можете потратить пару минут и попробовать определить порядок исполнения print.

Посмотрим, как Python интерпретирует код выше. Затем посмотрим на вывод, чтобы подтвердить или опровергнуть наши предположения.

  • Python читает определение класса и готовится передать три параметра в метакласс. Вот параметры: class_name, parents и attributes.
  • В нашем случае метакласс представляет собой класс, поэтому его вызов похож на создание нового класса. Это значит, что первый my_metaclass.__new__ вызывается с четырьмя параметрами. Так создаётся объект, который и станет классом с именем C. У объекта вызывается __init__, а затем в переменную C записывается ссылка на объект.
  • Затем Python смотрит на декораторы, которые можно применить к классу. В нашем случае есть только один декоратор. Python вызывает его, передаёт возвращённый из метакласса класс в качестве параметра. Класс заменяется объектом, который возвращается из декоратора.
  • Тип класса будет таким же, как определено в метаклассе.
  • Когда класс вызывается для создания нового объекта, Python ищет __call__ в метаклассе, так как тип класса — метакласс. В нашем случае my.metaclass.__call__ просто вызывает type.__call__, который создаёт объект из переданного класса.
  • Затем type.__call__ создаёт объект. Для этого он ищет C.__new__ и запускает его.
  • Возвращённый объект готов к использованию.

Основываясь на этой логике, можно ожидать, что my_metaclass.__new__ вызывается первым. Затем следует my_metaclass.__init__, затем my_class_decorator. В этот момент класс C полностью готов к использованию. Когда мы вызываем C для создания объекта, который вызывает my.metaclass.__call__ (каждый раз при вызове объекта Python пытается вызвать __call__), затем type.__call__ вызывает C.__new__, наконец, вызывается C.__init__. Вот вывод:

- my_metaclass.__new__ - Creating class instance of type <class '__main__.my_metaclass'>
- my_metaclass.__init__ - Initializing the class instance <class '__main__.C'>
- my_class_decorator - Chance to modify the class <class '__main__.C'>
- my_metaclass.__call__ - Creating object of type  <class '__main__.C'>
- C.__new__ - Creating object.
- C.__init__ - Initializing object.
Object c = <__main__.C object at 0x7fef85a8ecf8>

Метаклассы на практике

Метаклассы — мощный инструмент, хотя и скорее эзотерический. Но достойных применений метаклассов известно не так уж и много. Автор оригинальной публикации нашёл только два репозитория, в которых метаклассы применяются по-настоящему. Это ABCMeta и djangoplugins.

ABCMeta — это метакласс, позволяющий создавать абстрактные базовые классы. Детали смотрите в официальной документации.

Идея djungoplugins основана на статье, в которой описывается простой фреймворк плагинов для Python. Здесь метаклассы используются для создания системы расширений. Автор оригинальной публикации считает, что такой же фреймворк можно создать с помощью декораторов.

Финальный аккорд

Понимание метаклассов помогает досконально разобраться, как ведут себя объекты и классы в Python. Но применение самих метаклассов в реальности может быть сложным, как показано в предыдущем разделе. Практически всё, что можно сделать с помощью метаклассов, можно реализовать и с помощью декораторов. Поэтому прежде чем использовать метаклассы, остановитесь на минуту и подумайте, так ли они необходимы. Если можно обойтись без них, лучше пойти по этому пути. Результат будет более читабельным и простым для поддержки и отладки.

Над адаптированным переводом статьи A Study of Python's More Advanced Features Part III: Classes and Metaclasses by Sahand Saba работали Алексей Пирогов и Дмитрий Дементий. Мнение автора оригинальной публикации может не совпадать с мнением администрации «Хекслета».

Аватар пользователя Дмитрий Дементий
Дмитрий Дементий 21 ноября 2019
5
Похожие статьи
Рекомендуемые программы
профессия
Верстка на HTML5 и CSS3, Программирование на JavaScript в браузере, разработка клиентских приложений используя React
10 месяцев
с нуля
Старт 26 декабря
профессия
Программирование на Python, Разработка веб-приложений и сервисов используя Django, проектирование и реализация REST API
10 месяцев
с нуля
Старт 26 декабря
профессия
Тестирование веб-приложений, чек-листы и тест-кейсы, этапы тестирования, DevTools, Postman, SQL, Git, HTTP/HTTPS, API
4 месяца
с нуля
Старт 26 декабря
профессия
Программирование на Java, Разработка веб-приложений и микросервисов используя Spring Boot, проектирование REST API
10 месяцев
с нуля
Старт 26 декабря
профессия
новый
Google таблицы, SQL, Python, Superset, Tableau, Pandas, визуализация данных, Anaconda, Jupyter Notebook, A/B-тесты, ROI
9 месяцев
с нуля
Старт 26 декабря
профессия
Программирование на PHP, Разработка веб-приложений и сервисов используя Laravel, проектирование и реализация REST API
10 месяцев
с нуля
Старт 26 декабря
профессия
Программирование на Ruby, Разработка веб-приложений и сервисов используя Rails, проектирование и реализация REST API
5 месяцев
c опытом
Старт 26 декабря
профессия
Программирование на JavaScript в браузере и на сервере (Node.js), разработка бекендов на Fastify и фронтенда на React
16 месяцев
с нуля
Старт 26 декабря
профессия
Программирование на JavaScript, разработка веб-приложений, bff и сервисов используя Fastify, проектирование REST API
10 месяцев
с нуля
Старт 26 декабря
профессия
новый
Git, JavaScript, Playwright, бэкенд-тесты, юнит-тесты, API-тесты, UI-тесты, Github Actions, HTTP/HTTPS, API, Docker, SQL
8 месяцев
c опытом
Старт 26 декабря