Метаклассы вчера и сегодня

В статье описываются приемы, позволяющие получить частично или полностью функциональность метаклассов в старых версиях Python, и о встроенной поддержке метаклассов в Python 2.2.

Что такое метаклассы?

Если при истанциировании классов вы получаете экземпляр этого класса, то при инстанциировании метакласса вы получаете класс. То есть, классы можно рассматривать как экземпляры метаклассов. Метаклассы совмещают в себе две функции. Во-первых, они являются конструкторами классов, во-вторых, существует возможность определять производые метаклассы, дополняя и/или переопределяя функциональность базового. Более развернутое объяснение, что же такое метаклассы, вы можете найти в письме Владимира Марангозова.

Вчера (Python 1.5–2.1)

Python, начиная с версии 1.5, также позволяет создавать метаклассы. В качестве псевдоклассов могут выступать экземпляры классов, метод __init__ которых имеет строго определенный интерфейс:

class MetaClass:
def __init__(self, name, bases, dict):
# ...

Однако, чтобы реазизовать на практике эту возможность, прийдется потрудиться и обойти множество острых углов. Пожалеем вас и не будем описывать все те ухищрения, на которые нужно идти для получения нужного результата, об этом вы можете прочитать в статье Гвидо ван Россума Metaclasses in Python 1.5 или сразу взглянуть на результат — пример реализации метаклассов вы найдете в каталоге Demo/metaclasses/ пакета с исходными кодами Python — скорее всего, это сразу отобъет желание вникать в подробности. Следует отметить, скорость работы классов, созданных на базе экземплров таких метаклассов, значительно уступает скорости работы обычных классов.

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

CLASS_TEMPLATE = '''
class %(name)s(%(bases)s):
# шаблон реализация класса
'''
params = {
'name': name,
'bases': string.join(bases, ', '),
# другие подстановки для шаблона
}
exec CLASS_TEMPLATE % params

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

Рассмотрим другой вариант, основанный на той же особенности языка:

def createClass(name, bases=(), dict={}):

class NewClass:
# определения класса

NewClass.__name__ = name
NewClass.__bases__ = bases
NewClass.__dict__.update(dict)

return NewClass

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

Пусть у нас будет класс, на который возложена обязанность вытаскивать список документов из базы данных. SQL запрос констуируется на основе данных (о таблицах, полях, условии и др.), возвращаемых методом queryParts:

class Documents(SomeBaseClass):

# ...

def queryParts(self):
# ...
return tables, fields, condition

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

class DocumentsInSection(Documents):

def queryParts(self):
tables, fields, condition = Documents.queryParts(self)
tables = '%s NATURAL JOIN %s' % (tables, 'documents_sections')
condition = '%s AND section="%s"' % (condition, self.section)
return tables, fields, condition

А теперь представьте, что таких сущностей "привязанных" к разделам достаточно много, и в каждом случае нужно сделать аналогичное переопределение. Самое время воспользоваться конструктором классов.

def createInSection(base_class, link_table):

class InSection:
def queryParts(self):
tables, fields, condition = self.base_class.queryParts(self)
tables = '%s NATURAL JOIN %s' % (tables, self.link_table)
condition = '%s AND section="%s"' % (condition, self.section)
return tables, fields, condition

InSection.base_class = base_class
InSection.__bases__ = (base_class,)
InSection.link_table = link_table
InSection.__name__ = base_class.__name__+'InSection'
return InSection

Следует заметить, что мы вынуждены запоминать базовый класс в качестве атрибута, так как поиск имени базового класса при наследовании производится в глобальном пространстве имен. Точно также базовый класс не доступен внутри методов. Воспользовавшись таким новшеством Python 2.1, как вложенные области видимости (в Python 2.1 для их использования нужно в начале модуля выполнить from __future__ import nested_scopes, начиная с версии 2.2 области видимости всегда вложенные), код можно сделать чуть более красивым:

def createInSection(base_class, link_table):

class InSection(base_class):
def queryParts(self):
tables, fields, condition = base_class.queryParts(self)
tables = '%s NATURAL JOIN %s' % (tables, self.link_table)
condition = '%s AND section="%s"' % (condition, self.section)
return tables, fields, condition

InSection.link_table = link_table
InSection.__name__ = base_class.__name__+'InSection'
return InSection

Описанный выше метод обладает одним недостатком: он не будет работать с классами нового типа, появившимися в версии 2.2, так как атрибуты __name__, __bases__ и __dict__ в них доступны только для чтения. Для создания любых классов вы можете воспользоваться функцией classobj из модуля new или, начиная с версии 2.2, воспользоваться одним из конструкторов types.ClassType или type (в будущем они, скорее всего, будут синонимами), которые, в отличие от classobj, можно использовать и в качестве базового класса (чем мы и воспользуемся чуть позже). Перепишем наш пример с использованием new.classobj, по-прежнему для удобства пользуясь вложенными областями видимости:

from new import classobj

def createInSection(base_class, link_table):

def queryParts(self):
tables, fields, condition = base_class.queryParts(self)
tables = '%s NATURAL JOIN %s' % (tables, self.link_table)
condition = '%s AND section="%s"' % (condition, self.section)
return tables, fields, condition

return return classobj(base_class.__name__+InSection, (base_class,),
{'queryParts': queryParts})

Следует заметить, что конструктор type (в отличие от new.classobj и types.ClassType) требует, чтобы в качестве "базы" выступал класс нового типа. Это можно гарантировать добавив в список базовых классов тип object, то есть используя в качестве "базы" (base_class, object) вместо (base_class,).

Сегодня (Python 2.2)

А теперь взгляните на встроенную поддержку полноценных метаклассов в Python 2.2. Как и в самом первом варианте, метаклассом может быть любой класс, для которого методы __new__ и __init__ воспринимают четыре аргумента: создаваемый класс, имя класса, кортеж базовых классов и словарь пространства имен класса (чтобы не определять сразу оба метода, достаточно унаследовать метакласс от type). Но теперь не нужно никаких ухищрений, а чтобы воспользоваться метаклассом достаточно определить __metaclass__ в пространстве имен имен класса или глобальном пространстве имен модуля, указывая таким образом альтернативный конструктор для конкретного класса или всех классов модуля (точнее, "пострадают" только те классы, в момент выполнения которых глобальное имя __metaclass__ будет определено) соответственно. Приведем пример метакласса, который автоматически создает свойство name, если определен хотябы один из методов _get_name, _set_name или _del_name.

class AutoProperties(type):

def __new__(cls, name, bases, dict):
properties = {}
for name in dict.keys():
if name[:5] in ('_get_', '_set_', '_del_'):
properties[name[5:]] = 1
for name in properties.keys():
fget = dict.get('_get_'+name)
fset = dict.get('_set_'+name)
fdel = dict.get('_del_'+name)
dict[name] = property(fget, fset, fdel)
return type.__new__(cls, name, bases, dict)

Покажем работу этого метакласса на примере класса, представляющего химический элемент.

PeriodicTable = {
1: ('H', 1.0079, 'hydrogen'),
2: ('He', 4.0026, 'helium'),
3: ('Li', 6.9410, 'lithium'),
4: ('Be', 9.0122, 'berillium'),
# Информация о других элементах
}

_symbol2number = {}
for number, (symbol, mass, name) in PeriodicTable.iteritems():
_symbol2number[symbol.lower()] = number

class Element:

__metaclass__ = AutoProperties

def __init__(self, element):
if isinstance(element, Element):
self.number = element.number
elif isinstance(element, int):
self.number = element
else:
self.symbol = element

def __repr__(self):
return "Element('%s')" % self.symbol

def _get_number(self):
return self.__number

def _set_number(self, number):
self.__number = number

def _get_symbol(self):
return PeriodicTable[self.__number][0]

def _set_symbol(self, symbol):
self.__number = _symbol2number[symbol.lower()]

def _get_mass(self):
return PeriodicTable[self.__number][1]

def _get_name(self):
return PeriodicTable[self.__number][2]

Попробуем теперь воспользоваться классом Element (если сохранить приведенный выше код, включая определение класса AutoProperties, в файле Element.py, то достаточно набрать комманду python -i Element.py). Атрибуты number и symbol можно менять:

>>> el = Element('H')
>>> el
Element('H')
>>> el.number = 2
>>> el
Element('He')
>>> el.symbol = 'li'
Element('Li')

А атрибуты mass и name доступны только для чтения:

>>> el.mass
6.9409999999999998
>>> el.mass = 8.0
Traceback (most recent call last):
File "<stdin>", line 1, in ?
AttributeError: can't set attribute
>>> el.name
'lithium'
>>> el.name = 'hydrogen'
Traceback (most recent call last):
File "<stdin>", line 1, in ?
AttributeError: can't set attribute