Метаклассы - магия?
В комментариях вспомнили одну интригующую фразу Тима Питерса:
"Метаклассы это глубокая магия, о которой 99% пользователей даже не нужно задумываться. Если вы думаете, нужно ли вам их использовать — вам не нужно (люди, которым они реально нужны, точно знают, зачем они им, и не нуждаются в объяснениях, почему)."
Но неужели нам не хочется научиться магии?
Или всё сложно? А давайте-ка попробуем 😁
Создаём экземпляр класса привычным способом
Определим простой класс:
При создании экземпляра MyClass кажется, что первым делом вызывается метод __new__ (конструктор), затем — __init__ (инициализатор). Но если копнуть глубже, то оказывается, что процесс создания экземпляра контролируется метаклассом. Разберёмся по шагам.
Базовый пример
Результат:
На первый взгляд:
1. Сначала вызывается __new__, который создаёт объект и возвращает его.
2. Потом вызывается __init__, который инициализирует созданный __new__ объект.
Но это упрощённая картина. На самом деле первым делом в игру вступает метакласс.
Роль метакласса
Все классы в Python являются экземплярами метакласса (по умолчанию это type).
Когда мы пишем `x = MyClass(10)`, Python вызывает метод __call__ у метакласса класса MyClass.
Давайте убедимся в этом:
Вывод:
Теперь мы уверены:
1. Сначала вызывается Meta.__call__.
2. Уже внутри него вызывается MyClass.__new__.
3. Если __new__ вернул экземпляр MyClass, вызывается MyClass.__init__.
Проверка с type
Может возникнуть вопрос:
«А вдруг вызов __call__ происходит только потому, что мы явно указали metaclass=Meta?»
Чтобы убедиться в обратном, рассмотрим обычный класс:
Результат будет одинаковым в обоих случаях:
То есть при любом создании объекта фактически вызывается __call__ у метакласса.
Отсюда следует несколько выводов:
1. Первым в цепочке действительно вызывается __call__ у метакласса.
2. Метод __new__ вызывается изнутри __call__.
3. Метод __init__ вызывается после успешного создания объекта в __new__.
4. Если __new__ вернёт не экземпляр класса, __init__ не вызовется.
Таким образом, __new__ и __init__ — это «верхний уровень» процесса инстанцирования, а реальный «дирижёр» — метакласс.
Самый интересный вопрос
А теперь давайте разберёмся, почему процесс инстанцирования (создания экземпляра класса) происходит именно так, как мы описали выше.
И для начала вспомним отличную фразу: "В Python всё - объекты".
Посмотрим внимательно на эту строку кода:
Точнее, на её правую часть
`MyClass` возвращает ссылку на сам объект - то есть на наш класс `MyClass`.
Затем следует вызов с помощью скобок и передача аргумента.
То есть скобки - это вызов __call__ у объекта `MyClass` (на первый взгляд, но это не совсем так, дальше разберём).
Значит полная запись выглядит так:
Убедимся в этом:
Получаем:
Запомним этот момент и переключим внимание на другой пример.
Определим класс, экземпляры которого будут вызываемыми объектами.
Создадим экземпляр `MyCallableObj` и затем вызовем его:
Получаем:
Тут начинает приходит понимание))
`obj` - объект, как и всё в Python.
В парадигме ООП: `obj` - экземпляр класса `MyCallableObj`.
Когда мы вызываем `obj`:
то на самом деле происходит следующее:
То есть в этот момент мы получаем тип `obj` (то есть его класс `MyCallableObj`) и вызываем у полученного класса метод __call__ , в который передаём ссылку на сам экземпляр `obj`.
Давайте проверим:
Получаем:
Фиксируем следующее наблюдение:
`obj` - экземпляр класса `MyCallableObj`.
Значит при вызове экземпляра `obj` метод `__call__` будет вызываться у его типа (класса `MyCallableObj`).
Теперь возвращаемся к примеру с метаклассом:
Здесь для создания экземпляра `MyClass` мы вызываем сам класс `MyClass`.
Снова вспоминаем "В Python - всё объекты".
В парадигме ООП: `MyClass` - тоже чей-то экземпляр.
Конкретно в этом случае `MyClass` - экземпляр метакласса `type`.
Это определено в Python по умолчанию: для всех классов классом-создателем является класс`type`.
Именно поэтому `type` называют метакласс: метакласс == класс, который создаёт классы.
Получается, что нам надо получить тип `MyClass` и у полученного объекта вызвать метод `__call__`, в который необходимо передать ссылку на сам `MyClass` и дополнительные аргументы, если они нужны.
Проверяем:
Получаем:
Отлично! Наша гипотеза подтвердилась, мы успешно создали экземпляр класса `MyClass` с помощью прямого вызова `__call__` у его метакласса!
И теперь мы снова вернёмся к нашему базовому примеру с кастомным метаклассом, чтобы всё окончательно встало на свои места:
Выполняем и получаем:
И теперь сделаем прямой вызов и проследим всю цепочку:
Получаем:
Вот и вся магия, друзья)) Мы тут наблюдаем эдакий факториал - процесс инстанцирования идентичен, просто разные уровни - в одном случае создаётся экземпляр класса в чистом виде, а в другом - тоже экземпляр класса, только этот экземпляр сам является классом.
Для тех, кто хочет копнуть ещё глубже
Возможно у вас осталось непонимание - как это:
в итоге превращается в это:
В какой момент и кем за нас выполняется превращение `MyClass(10)` в `type(MyClass)`?
Или как `MyClass` передаёт вызов `__call__` своему метаклассу?
На самом деле вызов объекта всегда идёт через `__call__` объекта, который представляет тип этого самого объекта.
В нашем случае типом класса `MyClass` является метакласс `Meta`.
Поэтому Python делает часть работы за нас.
Такой путь зашит в Python: в самом интерпретаторе операция вызова использует слот `tp_call` типа объекта. В нашем случае тип класса `MyClass` — это его метакласс `Meta`.
Это поведение операции вызова мы не можем изменить напрямую. Сейчас узнаем почему.
Может сложиться обманчивое впечатление, что вызов:
эквивалентен:
А первая запись - просто своего рода синтаксический сахар.
Поэтому Python будет сначала искать `__call__` в самом классе `MyClass`.
Если он его там не найдёт, то поднимется к типу объекта (к метаклассу `Meta`) и найдёт `__call__` там.
Но это ошибочное предположение и сейчас мы его легко развенчаем.
Определим у `MyClass` метод класса `__call__` и создадим его экземпляр:
Получаем:
В этом случае у метакласса вызывается `__call__` и экземпляр класса `MyClass` успешно создаётся.
А теперь меняем вызов:
Выполняем:
В этом случае `__call__` вызывается у класса `MyClass` и объект не создаётся.
Поэтому, вызов
не эквивалентен вызову
Это абсолютно два разных вызова у разных объектов!
И ещё немного интересного
Теперь мы уберём у `MyClass` метод `__call__` и попытаемся вызвать напрямую:
Получаем:
Вуаля - и объект создан!
Как? у нас же нет метода `__call__` в `MyClass`?
Дело в том, что при таком прямом вызове Python ищет метод `__call__` у класса `MyClass`.
Он не находит его и идет искать его в цепочке MRO.
Там тоже не находит и идёт к метаклассу `Meta`.
Тут он его находит, происходит вызов и объект успешно создаётся!
Но вдруг такое поведение только у `__call__`?
Давайте ещё добавим кода и убедимся что это работает для любого метода:
Получаем:
Мы стали свиделями двух абсолютно разных действий.
1. Инстанцирование класса с помощью метакласса:
2. Вызов метода напрямую с помощью:
Простой пример использования метакласса
Пример применения метакласса - реализуем паттерн Singleton.
Сделаем так, что бы у класса мог быть только один экземпляр.
Если экземпляр есть - возвращаем ссылку на него. Если нет - создаём и возвращаем инстанс.
Это просто пример - маловероятно, что стоит использовать метакласс для реализации Singleton 😁
Метаклассы часто используются в ORM и различных фреймворках.
Вывод
Друзья, мы немножко прикоснулись к магии Python - пощупали руками метаклассы.
Но заметьте, когда приходит понимание - становится не так страшно.
Бонусом мы разобрались в некоторых смежных нюансах.
В следующей статье посмотрим, как происходит создание самого класса с помощью метакласса.
Надеюсь, вы осилили эту нудную статью до конца 😊
Если я где-то ошибаюсь - пишите в комментарии, будем разбираться💪
Наш сайт:
Наши курсы:
Python [START]
Git и GitHub [JUNIOR+]
Python для Excel с библиотекой openpyxl
Создание PDF с помощью Python и ReportLab
Python: подготовка к собеседованию Часть 1 [JUNIOR | MIDDLE]
Наши программы:
Python [START] + Git и GitHub [JUNIOR+] 2 курса
Профессия: Python-разработчик [Python | Git | SQL | Linux] 4 курса
Python для работы с Excel и PDF 2 курса