Содержание


Часть 3. Программирование метаклассов в Python

Метапрограммирование без метаклассов

Comments

Введение

В прошлом году я участвовал в конференции EuroPython 2006. Конференция была хороша, организация великолепна, разговоры проходили на очень высоком уровне, а люди были исключительно приятны. Но всё же я отметил в сообществе Python некоторые беспокоящие меня тенденции, которые побудили меня написать эту статью. Практически в тот же момент мой соавтор, Дэвид Мертц, отреагировал на похожую проблему, выпустив несколько исправлений Gnosis Utilities. Нас беспокоит тенденция движения в сторону заумности. К сожалению, если раньше заумность в сообществе Python ограничивалась большей частью Zope и Twisted, теперь она появляется везде.

Мы ничего не имеем против заумности в экспериментальных проектах и учебных упражнениях. Мы против заумности в рабочих средах, с которыми мы пытаемся работать как пользователи. Этой статьей мы надеемся внести небольшой вклад в избавление от этой заумности, как минимум в той области, в которой у нас есть опыт - в злоупотреблении метаклассами.

В этой статье мы займем безжалостную позицию: мы считаем злоупотреблением метаклассами любое их использование для решения проблемы, которая может быть решена без использования собственных метаклассов. Конечно же, здесь очевидна вина авторов: наши предыдущие статьи по метаклассам в Python помогли популяризировать их использование. Искупление вины.

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

Эта статья предназначена для двух групп читателей: рядовых программистов, которым было бы полезно знать приемы метапрограммирования, но их останавливают крайне сложные понятия; и умных программистов, которые слишком умны и должны были бы знать больше. Проблема с последними состоит в том, что умным быть очень просто, а для того, чтобы научиться быть проще, требуется много времени. Например, нам самим потребовалось несколько месяцев, чтобы понять, как использовать метаклассы, но на то, чтобы понять, как их не использовать, ушло несколько лет.

Об инициализации классов

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

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

Например, наш знакомый, отличный программист Ананд Пиллай (Anand Pillai), предложил путь к реализации пакета gnosis.xml.objectify в Gnosis Utilities, который делает именно это. Базовый класс gnosis.xml.objectify._XO_, адаптируемый (в реальном времени) для обработки "объектов узлов xml", "декорируется" несколькими расширенными моделями поведения, например:

Листинг 1. Динамическое расширение базового класса
setattr(_XO_, 'orig_tagname', orig_tagname)
setattr(_XO_, 'findelem', findelem)
setattr(_XO_, 'XPath', XPath)
setattr(_XO_, 'change_pcdata', change_pcdata)
setattr(_XO_,'addChild',addChild)

Вы можете подумать, достаточно обоснованно, что те же усовершенствования можно было выполнить простым созданием подклассов базового класса XO. В каком-то смысле это верно, но Ананд предложил два десятка возможных усовершенствований, и конкретным пользователям может понадобиться использование некоторых из них, но не всех. Всевозможных комбинаций слишком много, чтобы можно было создать подклассы для каждого сценария усовершенствования. Тем не менее приведенный выше код не слишком симпатичен. Решить задачу описанного выше рода можно с помощью собственного метакласса, подключенного к XO, с динамически определяемым поведением. Однако это возвращает нас к излишней заумности (и непрозрачности), которой мы пытались избежать.

Чистое и красивое решение описанной выше задачи может состоять в добавлении в Python декораторов класса. Если бы они у нас были, мы могли бы написать код вроде следующего:

Листинг 2. Добавление декораторов класса в Python
features = [('XPath',XPath), ('addChild',addChild), ('is_root',is_root)]
@enhance(features)
class _XO_plus(gnosis.xml.objectify._XO_): pass
gnosis.xml.objectify._XO_ = _XO_plus

Однако такой синтаксис - это вопрос будущего, если он вообще будет реализован.

Когда метаклассы приводят к усложнению

Может показаться, что все разговоры в этой статье идут ни о чем. Почему бы, например, просто не определить метакласс XO как Enhance и покончить с этим. Enhance.__init__() может запросто добавить любые нужные для конкретного случая возможности. Это может выглядеть следующим образом:

Листинг 3. Определение XO как Enhance
class _XO_plus(gnosis.xml.objectify._XO_):
      __metaclass__ = Enhance
      features = [('XPath',XPath), ('addChild',addChild)]
gnosis.xml.objectify._XO_ = _XO_plus

К сожалению, все не так просто, если задуматься о наследовании. Если вы определили для своего базового класса собственный метакласс, все производные классы будут наследовать этому метаклассу, поэтому код инициализации волшебным образом будет неявно вызываться на всех производных классах. В определенных обстоятельствах это может быть неплохо (например, предположим, что вам нужно зарегистрировать в вашей среде все определенные классы: использование метаклассов гарантирует, что вы не забудете зарегистрировать производный класс), однако во многих случаях вам такое поведение может не понравиться, поскольку:

  • Вы считаете, что явное лучше, чем косвенное.
  • У производных классов будут те же динамические атрибуты, что и у базового класса. Установка их еще раз для каждого производного класса не нужна, поскольку они и так будут доступны посредством наследования. Это может быть особенно серьезной проблемой в случаях, если код инициализации выполняется медленно или требует большого объема вычислений. Можно было бы добавить в коде метакласса проверку на предмет наличия атрибутов в родительском классе, но это повысит сложность и не даст реального контроля над каждым классом.
  • Собственные метаклассы делают ваши классы в каком-то смысле шаманскими и нестандартными: вы не захотите повышать шанс возникновения конфликтов метакласса, проблем со "__slots__", борьбы с классами расширений (Zope) и других сложностей, разрешение которых требует особой квалификации. Метаклассы куда более хрупки, чем можно себе представить. Мы редко используем их в рабочем коде даже после четырех лет использования в коде экспериментальном.
  • Вы чувствуете, что собственные метаклассы - это больше, чем нужно для выполнения простых задач инициализации класса, и предпочитаете использовать более простое решение.

Другими словами, собственные метаклассы необходимо использовать только тогда, когда у вас действительно есть намерение запускать код на производных классах незаметно для пользователей этих классов. Если это не ваш случай - забудьте про метаклассы и сделайте вашу жизнь (и жизнь ваших пользователей) более счастливой.

Декоратор classinitializer

За оставшуюся часть статьи нас могут обвинить в излишней заумности. Однако эта заумность нагружает только авторов, а не пользователей. Читатели могут создать что-нибудь более близкое к предлагаемому нами гипотетическому (не уродливому) декоратору класса, избежав проблем конфликтов метаклассов и наследования, которые возникают при работе с метаклассами. "Волшебный" декоратор, который мы представим ниже, на самом деле является расширением прямого (но слегка уродливого) императивного подхода и фактически эквивалентен следующему:

Листинг 4. Императивный подход
def Enhance(cls, **kw):
    for k, v in kw.iteritems():
        setattr(cls, k, v)
class ClassToBeInitialized(object):
    pass
Enhance(ClassToBeInitialized, a=1, b=2)

Приведенное выше императивное расширение не заключает в себе ничего плохого. Однако у него есть несколько недостатков: вам нужно повторять название класса; удобство чтения ниже оптимального, поскольку определение класса и его инициализация разделены - при длинных определениях класса вы можете пропустить последнюю строку; и кажется неправильным сначала определять что-то и тут же его менять.

Декоратор classinitializer предоставляет декларативное решение. Декоратор преобразует Enhance(cls,**kw) в метод, который можно использовать в определении класса:

Листинг 5. Декоратор magic является базовой операцией
>>> @classinitializer # add magic to Enhance
... def Enhance(cls, **kw):
...     for k, v in kw.iteritems():
...         setattr(cls, k, v)
>>> class ClassToBeInitialized(object):
...     Enhance(a=1, b=2)
>>> ClassToBeInitialized.a
1
>>> ClassToBeInitialized.b
2

Если вы работали с интерфейсами Zope, вы могли видеть примеры инициализаторов классов (zope.interface.implements). На самом деле classinitializer реализован с помощью приема, позаимствованного из zope.interface.advice, где его предложил Филип Дж. Эби (Phillip J. Eby). В этом приеме используется прерывание "__metaclass__", но не используется собственный метакласс. ClassToBeInitialized сохраняет исходный метакласс, т.е. обычный встроенный метакласс type классов нового стиля:

>>> type(ClassToBeInitialized)
<type 'type'>

В принципе этот же прием работает и с классами старого стиля, и не составит большого труда написать реализацию, которая сохранит старый стиль классов старого стиля. Однако, поскольку классы старого стиля, по мнению Гвидо ван Россума, "морально устарели", в текущей реализации классы старого стиля преобразуются в классы нового стиля:

Листинг 6. Переход на новый стиль
>>> class WasOldStyle:
...     Enhance(a=1, b=2)
>>> WasOldStyle.a, WasOldStyle.b
(1, 2)
>>> type(WasOldStyle)
<type 'type'>

Одна из целей декоратора classinitializer - скрыть сложные конструкции и дать простым смертным возможность с легкостью реализовать собственные инициализаторы класса, без знания подробностей работы создания классов и секретов прерывания _metaclass_. Еще одна мотивация состоит в том, что даже эксперту по Python очень неудобно изменять коды, обрабатывающие прерывание _metaclass_, при написании каждого нового инициализатора класса.

И, наконец, позвольте нам подчеркнуть, что декорированная версия Enhance достаточно умна для того, чтобы работать как недекорированная версия за рамками класса, если вы передаете ей явный аргумент класса:

>>> Enhance(WasOldStyle, a=2)
>>> WasOldStyle.a
2

(Слишком) сильное волшебство

Ниже представлен код classinitializer. Для того чтобы использовать декоратор, не обязательно понимать этот код:

Листинг 7. Декоратор classinitializer
import sys
def classinitializer(proc):
   # basic idea stolen from zope.interface.advice, P.J. Eby
   def newproc(*args, **kw):
       frame = sys._getframe(1)
       if '__module__' in frame.f_locals and not \
           '__module__' in frame.f_code.co_varnames: # we are in a class
           if '__metaclass__' in frame.f_locals:
               raise SyntaxError("Don't use two class initializers or\n"
                 "a class initializer together with a __metaclass__ hook")
           def makecls(name, bases, dic):
               try:
                   cls = type(name, bases, dic)
               except TypeError, e:
                   if "can't have only classic bases" in str(e):
                       cls = type(name, bases + (object,), dic)
                   else:  # other strange errs, e.g. __slots__ conflicts
                       raise
               proc(cls, *args, **kw)
               return cls
           frame.f_locals["__metaclass__"] = makecls
       else:
           proc(*args, **kw)
 newproc.__name__ = proc.__name__
 newproc.__module__ = proc.__module__
 newproc.__doc__ = proc.__doc__
 newproc.__dict__ = proc.__dict__
 return newproc

Из реализации становится понятно, как работает инициализатор класса: когда вы вызываете инициализатор класса внутри класса, вы фактически определяете прерывание _metaclass_, которое будет вызываться метаклассом класса (обычно это type). Метакласс создаст класс (в новом стиле) и передаст его процедуре инициализатора класса.

Сложности и предостережения

Поскольку инициализаторы класса (пере)определяют прерывание _metaclass_, они не очень хорошо работают с классами, которые определяют прерывание _metaclass_ явным образом (в противоположность косвенному наследованию). Если прерывание _metaclass_ определено после инициализатора класса, оно молча переопределяет его.

Листинг 8. Домашняя страница index.html нового проекта
>>> class C:
...     Enhance(a=1)
...     def __metaclass__(name, bases, dic):
...         cls = type(name, bases, dic)
...         print 'Enhance is silently ignored'
...         return cls
...
Enhance is silently ignored
>>> C.a
Traceback (most recent call last):
  ...
AttributeError: type object 'C' has no attribute 'a'

К сожалению, общего решения этой проблемы не существует; мы просто документируем её. С другой стороны, если вы вызываете инициализатор класса после прерывания _metaclass_, вы получите ошибку:

Листинг 9. Локальный метакласс вызывает ошибку
>>> class C:
...     def __metaclass__(name, bases, dic):
...         cls = type(name, bases, dic)
...         print 'calling explicit __metaclass__'
...         return cls
...     Enhance(a=1)
...
Traceback (most recent call last):
   ...
SyntaxError: Don't use two class initializers or
a class initializer together with a __metaclass__ hook

Выдача ошибки лучше, чем тихое переопределение прерывания _metaclass_. Поэтому если вы попробуете использовать два инициализатора класса одновременно, или если вызовете один инициализатор класса дважды, вы получите ошибку:

Листинг 10. Двойное расширение вызывает проблемы
>>> class C:
...     Enhance(a=1)
...     Enhance(b=2)
Traceback (most recent call last):
  ...
SyntaxError: Don't use two class initializers or
a class initializer together with a__metaclass__ hook

Что хорошо, все проблемы наследования прерываний _metaclass_ и собственных метаклассов обрабатываются:

Листинг 11. Удачное расширение наследуемого метакласса
>>> class B: # a base class with a custom metaclass
...     class __metaclass__(type):
...         pass
>>> class C(B): # class with both custom metaclass AND class initializer
...     Enhance(a=1)
>>> C.a
1
>>> type(C)
<class '_main.__metaclass__'>

Инициализатор класса не мешает метаклассу C, который наследуется базовым классом B, и наследуемый метакласс не мешает инициализатору класса, который отлично выполняет свою работу. Вместо этого у вас возникнут проблемы, если вы попытаетесь вызвать Enhance напрямую из базового класса.

Подведение итогов

После того как все механизмы определены, настройка инициализации класса становится простой и элегантной. Это может выглядеть примерно так:

Листинг 12. Простейшая форма расширения
class _XO_plus(gnosis.xml.objectify._XO_):
    Enhance(XPath=XPath, addChild=addChild, is_root=is_root)
gnosis.xml.objectify._XO_ = _XO_plus

В этом примере все еще используется "вставка", которая в общем случае не нужна; т.е. мы вставляем расширенный класс в определенное имя в пространстве имен модуля. Это нужно для данного конкретного модуля, но в большинстве случаев такой необходимости нет. В любом случае аргумент Enhance() не обязательно фиксировать в коде, как это сделано выше, точно так же можно использовать Enhance(**feature_set) для полностью динамического решения.

Еще один момент, который необходимо помнить, состоит в том, что ваша функция Enhance() способна на существенно большее, чем предложенная выше простая версия. Этот же декоратор будет счастлив настроить более сложные функции расширения. Например, вот функция, добавляющая "записи" в класс:

Листинг 13. Изменение расширения класса
@classinitializer
def def_properties(cls, schema):
    """
    Add properties to cls, according to the schema, which is a list
    of pairs (fieldname, typecast). A typecast is a
    callable converting the field value into a Python type.
    The initializer saves the attribute names in a list cls.fields
    and the typecasts in a list cls.types. Instances of cls are expected
    to have private attributes with names determined by the field names.
    """
    cls.fields = []
    cls.types = []
    for name, typecast in schema:
        if hasattr(cls, name): # avoid accidental overriding
            raise AttributeError('You are overriding %s!' % name)
        def getter(self, name=name):
            return getattr(self, '_' + name)
        def setter(self, value, name=name, typecast=typecast):
            setattr(self, '_' + name, typecast(value))
        setattr(cls, name, property(getter, setter))
        cls.fields.append(name)
        cls.types.append(typecast)

Вопросы о том, (a) что расширяется, (b) как работает это волшебство и (c) что делает базовый класс, остаются независимыми друг от друга:

Листинг 14. Настройка класса записи
>>> class Article(object):
...    # fields and types are dynamically set by the initializer
...    def_properties([('title', str), ('author', str), ('date', date)])
...    def __init__(self, values): # add error checking if you like
...        for field, cast, value in zip(self.fields, self.types, values):
...            setattr(self, '_' + field, cast(value))

>>> a=Article(['How to use class initializers', 'M. Simionato', '2006-07-10'])
>>> a.title
'How to use class initializers'
>>> a.author
'M. Simionato'
>>> a.date
datetime.date(2006, 7, 10)

Ресурсы для скачивания


Похожие темы


Комментарии

Войдите или зарегистрируйтесь для того чтобы оставлять комментарии или подписаться на них.

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Linux, Open source
ArticleID=273682
ArticleTitle=Часть 3. Программирование метаклассов в Python
publish-date=12062007