Журнал ВРМ World

Мировая история развития технологий управления эффективностью бизнеса – обзоры зарубежных публикаций

Типы и классы в Python 2.2

В декабре 2001 года вышел Python 2.2. Первый номер Журнала, который
публикуется вслед этому событию, целиком посвящен новой версии языка.
Открывает рубрику статья нашего постоянного автора и ведущего рубрики "Язык
программирования Python" Якова Марковича. Автор рассказывает о самых
существенных нововведениях, которые затронули ядро Python.

Введение

Язык Python отличается исключительно последовательным и ясным объектным дизайном. Но одним из самых серьезных изъянов в дизайне Python всегда были различия между встроенными типами и классами. Хотя это и не достигало такой степени, как в раннем C++[1] , но все же было достаточно серьезно. Главных различий было два:

  • От встроенных типов нельзя было наследоваться;
  • Встроенные типы и классы имели разный интерфейс интроспекции. Так, например, не существовало описательных атрибутов, которые бы гарантированно присутствовали и в объектах классов, и в объектах типов.

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

Следует сказать, что в Python 2.2 существует два вида классов - "старые", поведение которых осталось неизменным, и "новые", свойства которых и будут рассмотрены. "Новые" классы - это те, корнем иерархии у которых служит специальный тип object. "Старые" классы - те, которые не базируются (прямо или косвенно) на этом типе.

Наследование классов от встроенных типов

В корне иерархии всех встроенных типов Python, типов расширения и "новых" классов находится тип object:

  >>> type(2).__bases__
  (<type 'object'>,)
  >>> type("").__bases__
  (<type 'object'>,)

Таким образом, теперь можно наследовать классы от встроенных типов и типов расширения (в дальнейшем все типы, "написанные на C" - как встроенные в интерпретатор, так и типы расширения - будем называть встроенными типами). При этом любой класс, прямо или косвенно базирующийся на любом встроенном типе - "новый" класс[2].

Рассмотрим пример:

# Объект списка с контролем типа элементов
# Наследник типа list. 
# В "классическом" Python для этого пришлось бы использовать
# класс-обертку наподобие UserList
from types import ClassType
class StrictlyTypedList(list):
    """Список, контролирующий тип своих элементов"""
    def __init__(self, value_type):
        if not isinstance(value_type, (type, ClassType)):
            raise TypeError, \
                  'неправильный тип параметра конструктора \
                  StrictlyTypedList: %s' % \
                  type(value_type)
        self.value_type = value_type
        list.__init__(self)
    def insert(self, index, object):
        list.insert(self, index, self.__checktype(object))
    def append(self, object):
        list.append(self, self.__checktype(object))
    def __iadd__(self, other_list): 
        # Обратите внимание, что функция __iadd__ принимает _список_
        # объектов для добавления, поэтому мы должны проверить \
        # все его элементы
        list.__iadd__(self, map(self.__checktype, other_list))
    extend = __iadd__
    def __checktype(self, object):
        if not isinstance(object, self.value_type):
            raise TypeError, '%s' % type(object)
        return object

Конструктор класса StrictlyTypedList принимает тип элемента списка . Обратите внимание на то, что перегруженные методы класса-наследника вызывают методы базового (встроенного) типа, среди которых есть __iadd__ (специальный метод, реализующий оператор +=). Это показывает, что в Python 2.2 встроенные типы приобрели именованные специальные методы, ранее существовавшие только в классах.

Посмотрим на то, как работает наш класс:

>>> type(StrictlyTypedList)
<type 'type'>

Типом "новых" классов является 'type', в отличие от "старых", тип которых - 'class'.

>>> lst = StrictlyTypedList(str)
>>> lst.append('Hello')
>>> lst += (',', 'world!')
>>> print lst
['Hello', ',', 'world!']
>>> lst.append(1000)
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
  File "f:/TEMP/python-960_ic", line 17, in append
  File "f:/TEMP/python-960_ic", line 28, in __checktype
TypeError: <type 'int'>

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

# Определим два "строчных" класса: наследник типа встроенного типа строки
# и наследник класса-обертки UserString. Объекты обоих классов приводят
# "свою" строку к нижнему регистру
class LCaseString(str):
    """Класс-наследник встроенного типа строки, приводящий строку к 
       нижнему регистру"""
    def __new__(cls, src = ""):
        return str.__new__(cls, src.lower())
from UserString import UserString
class WrapperLCaseString(UserString):
    """Строкоподобный класс, приводящий строку к нижнему регистру"""
    def __init__(self, src = ""):
        return UserString.__init__(self, src.lower())

Наверное, вы обратили внимание на специальный метод __new__, перегруженный в классе LСaseString. Немного далее мы рассмотрим метод __new__ и его применение для конструирования классов, производных от неизменяемых типов (чисел, строк и кортежей). А пока попробуем использовать вышеприведенные "строки" в качестве параметра функции __import__:

>>> s = WrapperLCaseString("SYS")
>>> print s
sys
>>> __import__(s)
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
TypeError: __import__() argument 1 must be string, not instance
>>> s = LCaseString("SYS")
>>> s
sys
>>> __import__(s)
<module 'sys' (built-in)>

Как можно видеть, LСaseString, будучи наследником встроенной строки, рассматривается как "настоящая" строка, а WrapperLCaseString, будучи оберткой - нет.

Использование встроенных типов вместо классов-оберток в качестве базовых классов гораздо эффективнее как по скорости выполнения, так и по объему и ясности кода. Но, к сожалению, есть несколько встроенных функций, не принимающих вместо встроенных типов их наследников. Так, например, встроенная функция open, создающая файловый объект, почему-то принимает в качестве параметров только строки per se[3].

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

>>> print isinstance(0, int)
1
>>> print isinstance(0, object)
1
>>> print isinstance(0, type(""))
0
>>> print isinstance(0, (type(""), StrictlyTypedList))
0
>>> obj = StrictlyTypedList(int)
>>> print isinstance(obj, (type(""), list))
1
>>> obj = LCaseString("SYS")
>>> print isinstance(obj, str)1

Следует отметить, что выражение isinstance(x, y) не эквивалентно type(x) is y, поскольку isinstance учитывает иерархию наследования. Так, например, isinstance(LCaseString(), str) вернет 1, потому что объект класса LCaseString есть строка, но type(LCaseString()) is str вернет 0, потому что тип LCaseString не есть в точности тип str. В большинстве случаев требуется именно способ проверки, предоставляемый isinstance.

Новые имена и конструкторы встроенных типов

Вероятно вы обратили внимание, что в качестве имени типа списка используется list и заметили вызов list.__init__(self) в конструкторе StrictlyTypedList. Дело в том, что в Python 2.2 имена встроенных "функций-конструкторов" типов стали именами типов. При этом эти имена имеют тип 'type' и могут вызываться как конструкторы соответствующих типов (эта логика привычна для программистов на классическом Python; в частности, имена "старых" классов имеют тип 'class', а их вызов приводит к созданию экземпляра соответствующего класса). Поскольку параметры "новых" конструкторов и "старых" встроенных функци в точности совпадают, проблема обратной совместимости не возникает. Следующие функции стали типами:

  • int([число_или_строка[, основание_системы_счисления]])
  • long([число_или_строка])
  • float([число_или_строка])
  • complex([число_или_строка [, мнимая_часть]])
  • str([объект])
  • unicode([строка[, кодировка]])
  • tuple([итератор])
  • list([итератор])
  • type(объект) или
    type(имя, кортеж_баз, словарь_методов)

Кроме того, были добавлены новые имена типов (и несколько собственно новых типов):

  • dict([отображение_или_итератор]) - создает новый словарь; необязательный аргумент может быть либо словарем, который копируется, либо итератором по последовательности пар (кортежей) вида (ключ, значение)
  • object([...]) - создает экземпляр "самого базового" класса object; аргументы игнорируются
  • classmethod(вызываемый_объект) - статический метод класса, см. ниже
  • staticmethod(function) - статический метод класса, см. ниже
  • super(класс_или_тип[, обьект]) - один из базовых классов объекта (или единственный); будет рассмотрен в части, посвященной множественному наследованию
  • property([getter[, setter[, deleter[, строка_документации]]]]) - объект-свойство, наподобие property в Delphi - см. ниже

Следует подчеркнуть, что объект типа 'type' - вызываемый (callable) объект, конструктор соответствующего типа. Поэтому, например, следующие конструкции:

list((1, 2, 3))
type([])((1, 2, 3))
types.ListType((1, 2, 3))
абсолютно эквивалентны и создают список [1, 2, 3].

Особенности наследования от "неизменяемых" (immutable) типов. Метод __new__.

В примере с классом-наследником строки LCaseString вместо определения конструктора - метода __init__ - был определен метод __new__. Почему мы не определили __init__? Дело в том, что __init__ вызывается уже после того, как объект создан; поскольку LCaseString базируется на строке, а строка в Python - неизменяемый объект, то к моменту вызова __init__ наш объект уже представляет собой строку, инициализированную по умолчанию (пустую), с которой мы ничего не можем сделать! Так же ведут себя числовые типы (int, long, float) и кортежи:

# Объект этого класса, будучи сконструирован без параметров,
# вроде бы должен иметь значение 1000, но почему-то имеет 0
# и должен принимать значение, равное удвоенному параметру
# (чего тоже не делает)
class Int1000Wrong(int):
    def __init__(self, value = 500):
        int.__init__(self, value * 2)
# А этот класс конструируется, как ожидалось
class Int1000Right(int):
    def __new__(selfclass, value = 500):
        # Не забудьте return, а то объект будет иметь значение None!
        return int.__new__(selfclass, value * 2)
        

>>> i = Int1000Wrong()
>>> print i
0
>>> i = Int1000Wrong(200)
>>> print i
200
>>> i = Int1000Right()
>>> print i
1000
>>> i = Int1000Right(200)
>>> print i
400
>>>

Чтобы решить эту и некоторые другие проблемы, в Python введен новый специальный метод - __new__.

Задача __new__ заключается в том, чтобы создать экземпляр, который затем будет инициализирован __init__. __new__ вызывается с первым параметром - классом (не экземпляром! Его еще нет!), остальные параметры - те же, с которыми вызван конструктор (т.е. те же, что передаются в __init__ после self). __new__ должен вернуть экземпляр того класса, для которого вызывается конструктор (именно этот класс передается ему первым параметром). Таким образом, псевдокод "универсального конструктора" мог бы выглядеть так:

def constructor(type, *args, **kwargs):
    new_obj = type.__new__(type, *args, **kwargs)
    #__init__ ничего не возвращает (или его возврат игнорируется)
    new_obj.__init__(*args, **kwargs)
    return new_obj

Обратите внимание на то, что __new__ вызывается как статический метод. Он и является полноправным статическим методом, поэтому может быть определен с помощью любого callable-объекта:

>>> print(Int1000Right.__dict__["__new__"])|
<staticmethod object at 0x0087E8B0>

Следует подчеркнуть, что создать новый экземпляр класса может только __new__ встроенного типа. Поэтому __new__ производных классов либо возвращают уже существующий объект, либо вызывают __new__ базового класса (возможно, с модифицированными параметрами). Т.е. для __new__, переопределенного в производном классе, единственный способ создать новый экземпляр класса - вызвать __new__ базового класса. Если __new__ не применяется для возврата уже существующего объекта, он должен обязательно вызвать __new__ базового класса. Эта цепочка закончится вызовом <встроенный тип>.__new__, который и создаст реальный объект.

Вызывая __new__ базового класса первым параметром следует передавать класс, для которого вызывается исходный конструктор, а не базовый класс: в противном случае будет создан экземпляр базового класса! Имейте в виду, что в Python 2.2 тип возврата __new__ не контролируется, поэтому, вернув объект неправильного типа (или вообще забыв вернуть значение, что эквивалентно возврату None), вы можете получить весьма странные результаты. __new__ не обязан возвращать вновь созданный объект; он вполне может вернуть уже существующий. Это крайне полезно для создания кешей и реализации объектов-одиночек (singleton objects). Ниже иллюстрируются оба подхода:

# Singleton
# Пример взят из статьи Гвидо ван Россума (Guido van Rossum)
# и немного модифицирован
class Singleton(object):
    def __new__(selfclass, *args, **kwds):
        try:
            it = selfclass.__it__
        except AttributeError:
            selfclass.__it__ = it = object.__new__(selfclass)
            it.init(*args, **kwds)
        return it
    # Поскольку __init__ автоматически вызывается всякий раз
    # при конструировании объекта (даже если объект один и тот же),
    # для инициализации объектов производных от Singleton
    # классов надо переопределять init, а не __init__
    def init(self, *args, **kwds):
        pass
class MySingleton(Singleton):
    def init(self):
        print "Вызов init"
    def __init__(self):
        print "Вызов __init__"


>>> s1 = MySingleton()
Вызов init
Вызов __init__
>>> s2 = MySingleton()
Вызов __init__
>>> print s1 is s2


# Кеш. Кеширует все сконструированные объекты;
# Если объект с заданным параметрами уже конструировался, возвращает
# его из кеша. Предполагается, что параметры конструктора хешируемы.
# Пример очень примитивен, так что не стоит использовать его как полное
# решение.
class Cached(object):
    def __new__(selfclass, *args):
        try:
            d = selfclass.__cache__
        except AttributeError:
            selfclass.__cache__ = d = {}
        try:
            value = d[args]
        except KeyError:
            d[args] = value = object.__new__(selfclass)
            value.init(*args)
        return value
class CachedValue(Cached):
    def init(self, val1, val2):
        print "init(%r, %r)" % (val1, val2)


>>> cv1 = CachedValue("Hello", "world")
init('Hello', 'world')
>>> cv2 = CachedValue("Bye", "world")
init('Bye', 'world')
>>> cv3 = CachedValue("Hello", "world")
>>> print cv1 is cv2
0
>>> print cv1 is cv3
1

Не следует также забывать, что __new__ передаются те же параметры, что и __init__, поэтому при изменении спецификации __init__ следует синхронно менять спецификацию __new__, и наоборот. Правда, это становится ненужным, если __new__ игнорирует свои параметры (или, по крайней мере, принимает только переменный набор параметров и самостоятельно их анализирует). Но вообще, общий подход при перегрузке __new__ следующий: объект, по возможности, должен конструироваться или методом __new__, или __init__, но не тем и другим сразу, иначе вам придется синхронно перегружать оба метода во всех производных классах, меняющих спецификацию хотя бы одного из них!

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

Статические методы.

Одна из новых давно ожидавшихся возможностей в Python 2.2 - возможность создавать статические методы и методы класса.

Статический метод (statictmethod) - прямой аналог static member function в C++ - метод, определенный в классе и вызываемый как обычная функция (т.е. без дополнительного первого параметра и вообще без любых дополнительных параметров кроме тех, которые явно указаны в вызове) как относительно класса, так и относительно экземпляра этого или производного класса. В сущности, это обычная функция, лежащая в словаре класса (но обратите внимание, что если мы поместим обычную функцию в словарь класса, она превратится в метод, а не в статическую функцию! Это причина того, почему в классическом Python статические методы невозможны). Метод класса (classmethod) - это метод, всегда привязывающийся к классу, а не к экземпляру, вне зависимости от того, относительно класса или экземпляра он вызывается. Т.е. первым параметром в метод класса всегда передается класс, относительно которого он вызван. Метод класса можно рассматривать как обычный метод экземпляра метакласса (если рассматривать сам класс как экземпляр метакласса).

# Пример, показывающий отличие обычного метода
# от статического метода и от метода класса
class SimpleClass(object):
    def normal_method(first, second = "default value"):
        print "first==%r\nsecond==%r" % (first, second)
    print "normal_method==%r" % normal_method
    def static_method(first, second = "default value"):
        print "first==%r\nsecond==%r" % (first, second)
    static_method = staticmethod(static_method)
    print "static_method==%r" % static_method
    def class_method(first, second = "default value"):
        print "first==%r\nsecond==%r" % (first, second)
    class_method = classmethod(class_method)
    print "class_method==%r" % class_method

Вообще говоря, в примере достаточно было определить одну функцию для normal_method, а потом передавать ее в качестве параметра в вызовы staticmethod и classmethod, но для наглядности для каждого метода определена отдельная функции (хотя все они идентичны).

Вышеприведенный пример при выполнении выдает следующий вывод:

normal_method==<function normal_method at 0x00861CF0>
static_method==<staticmethod object at 0x00837A38>
class_method==<classmethod object at 0x00837A20>

Вы видите, что вызов staticmethod создает экземпляр типа staticmethod, a classmethod создает экземпляр типа classmethod. Таким образом, staticmethod и classmethod - типы, и их вызов конструирует объекты соответствующих типов. Эти объекты помещаются в словарь класса и представляют собой дескрипторы атрибутов (рассматриваются далее).

Рассмотрим теперь разницу между вызовами разных методов:

>>> instance = SimpleClass()
>>> instance.normal_method("parameter")
first==<__main__.SimpleClass object at 0x00831E40>
second=='parameter'
>>> instance.static_method("parameter")
first=='parameter'
second=='default value'
>>> SimpleClass.static_method("parameter")
first=='parameter'
second=='default value'
>>> instance.class_method("parameter")
first==<class '__main__.SimpleClass'>
second=='parameter'
>>> SimpleClass.class_method("parameter")
first==<class '__main__.SimpleClass'>
second=='parameter'

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

>>> class Derived(SimpleClass):
...     pass
... 
>>> Derived.class_method("parameter")
first==<class '__main__.Derived'>
second=='parameter'
>>> derived_instance = Derived()
>>> derived_instance.class_method("parameter")
first==<class '__main__.Derived'>
second=='parameter'

Мы видели, экземпляры каких типов создают вызовы staticmethod и classmethod:

>>> print SimpleClass.__dict__["static_method"]

>>> print SimpleClass.__dict__["class_method"]
<classmethod object at 0x00864AC0>

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

>>> print SimpleClass.static_method
<function static_method at 0x00855800>
>>> print SimpleClass.class_method
<bound method type.class_method of <class '__main__.SimpleClass'>>

Совершенно другие типы! Для статического метода возвращается обычная функция, что вполне естественно; для метода класса вызывается связанный с классом метод, т.е. вызываемый объект с уже заданным первым параметром, и этот параметр - класс, к атрибуту которого происходит обращение.

Это связано с тем, что типы staticmethod и classmethod - дескрипторы атрибутов. Понятие дескриптора атрибута рассматривается ниже.

Контроль доступа к атрибутам объекта с помощью property

Механизм свойств, или управляемых атрибутов (properties) предоставляют возможность реализации доступа к атрибуту экземпляра через вызов функции. Этот механизм позволяет привязать к атрибуту функции чтения (fget), присваивания (fset) и удаления (fdel), напоминая тем самым конструкцию property в Delphi, VB и подобных системах:

import operator
class AbsVal(object):
    def __init__(self):
        self.__value = 0
    def __get_value(self):
        return self.__value
    def __set_value(self, value):
        if not operator.isNumberType(value):
            raise TypeError, 'attribute "value" must be numeric'
        self.__value = value < 0 and -value or value
    value = property(__get_value, __set_value)


>>> a = AbsVal()
>>> a.value = -20
>>> a.value
20
>>> a.value = ""
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
  File "f:/TEMP/python-960X8O", line 13, in __set_value
TypeError: attribute "value" must be numeric
>>> del a.value
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
AttributeError: can't delete attribute

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

  • Код, обрабатывающий один атрибут, оказывается нелокальным, т.е. он встраивается в код методов __getattr__ и __setattr__, которые могут выполнять и другие функции; в результате далеко не всегда очевидно, как именно осуществляется доступ к атрибуту. Кроме того, знание о способе доступа к атрибуту само по себе оказывается встроенным в код __getattr__ и __setattr__, т.е. не существует явной конструкции объявления доступа, что делает его неочевидным и недекларативным. Помимо этого, правильная реализация __settattr__ и __getattr__, особенно если требуется контролировать доступ ко множеству атрибутов, становится нетривиальной.
  • Возникает проблема производительности: __setattr__ вызывается при каждом присваивании каждому атрибуту, что крайне невыгодно, если большинство атрибутов не требует контроля доступа. В отличие от этого, функция fset из объявления property вызывается только для этого атрибута.

Полная спецификация property выглядит следующим образом:
property(fget=None, fset=None, fdel=None, doc=None)

  • fget - функция, вызываемая при чтении атрибута. Принимает один параметр - экземпляр, у которого запрашивается атрибут, и должна возвращать значение атрибута или возбуждать исключение.
  • fset - функция, вызываемая при присваивании атрибуту. Принимает два параметра - экземпляр, атрибут которого устанавливается, и значение атрибута. Возвращаемое значение игнорируется.
  • fdel - функция, вызываемая при удалении атрибута. Принимает один параметр - экземпляр, для которого запрошено удаление. Возвращаемое значение игнорируется.
  • doc - строка документации, описывающая атрибут.

Если какая-то из функций не задана или равна None, при соответствующем доступе к атрибуту возбуждается AttributeError (это уже было проиллюстрировано - см. пример выше, попытка удалить атрибут value). Интересно, что это позволяет определять атрибуты "только для записи":

# В этом примере атрибут seed задает начальное число генератора случайных чисел.
# Он может быть установлен, но не может быть прочитан или удален
import random
class RandomNum(object, random.Random):
    def __seed(self, value):
        random.Random.seed(self, value)
    seed = property(fset = __seed, 
                    doc = "write-only seed for random number generator")


>>> r = RandomNum()
>>> r.seed
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
AttributeError: unreadable attribute
>>> r.seed = 10
>>> print r.random()
0.0734243483352

Доступ к строке документации может быть получен следующим образом:

>>> print RandomNum.seed.__doc__
write-only seed for random number generator

Несколько существенных моментов, касающихся property:

  • property - имя специального типа и, соответственно, конструктор этого типа. Т.е. атрибуты класса, созданные с помощью вызова property, имеют тип property:
    class C(object):
        def getx(self):
            return 8
        x = property(getx)
    
    
    >>> c = C()
    >>> print c.x
    8
    >>> print C.x
    <property object at 0x00819030>
  • Функции fget, fset и fdel вызываются только при доступе к атрибуту экземпляра, но не к атрибуту класса. Соответствующий атрибут класса имеет тип property и может быть обычным способом прочитан, установлен и удален [4].
  • Если в классе определен метод __setattr__, он вызывается перед функциями fset/fdel при попытке присвоить значение атрибуту или удалить его и, тем самым, маскирует обработку, заданную с помощью property. В этом случае единственный способ заставить вызваться соответствeтствующую обработку - вызвать __setattr__ базового класса:
    class C(object):
        def __setattr__(self, attr, val):
            print "__setattr__(%r, %r)" % (attr, val)
            object.__setattr__(self, attr, val)
        def seta(self, val):
            print "seta(%r)" % val
        a = property(fset = seta)
        
    
    >>> c = C()
    >>> c.a = 6
    __setattr__('a', 6)
    seta(6)
  • Встроенная функция hasattr определяет наличие атрибута, пытаясь его прочесть; поэтому вызов hasattr для write-only атрибута вернет 0[5]:
    >>> r = RandomNum()
    >>> print hasattr(r, 'seed')
    0
  • fget, fset и fdel рассматриваются именно как функции, первым параметром в которые явно передается экземпляр, а не как методы, связанные с экземпляром. Это можно проиллюстрировать на примере того же генератора случайных чисел:
    # Использование обычной функции (не метода) в качестве функции 
    #fset для property
    import random
    def set_seed(generator, value):
        random.Random.seed(generator, value)
    class RandomNum(object, random.Random):
        seed = property(fset = set_seed, 
                        doc = "write-only seed for random number generator")
    Поскольку внутри определения класса методы рассматриваются как обычные функции, они могут быть использованы для реализации fset, fget и fdel.
  • Вызовы fset, fget и fdel невиртуальны, т.е. если в качестве этих функций при определении property-атрибута использованы методы базового класса, а в производном классе эти методы переписаны, при доступе к атрибуту будут использоваться методы базового класса:
    class CBase(object):
        def getx(self):
            return 'CBase.getx()'
        x = property(getx)
    class CDerived(CBase):
        def getx(self):
            return 'CDerived.getx()'
    
    
    >>> base = CBase()
    >>> derived = CDerived()
    >>> print base.x
    CBase.getx()
    >>> print derived.x
    CBase.getx()
    Это происходит потому, что, как уже говорилось, в объекте property запоминаются функции, не связанные ни с классом, ни с экземпляром, и при доступе к атрибуту они вызываются непосредственно, без какого-либо поиска в словаре экземпляра или класса.
    Ситуацию можно разрешить следующим образом:
    class CBase(object):
        def getx(self):
            return self._getx()
        x = property(getx)
        def _getx(self):
            return 'CBase.getx()'
    class CDerived(CBase):
        def _getx(self):
            return 'CDerived.getx()'
    
    
    >>> base = CBase() ; derived = CDerived()
    >>> print base.x, derived.x
    CBase.getx() CDerived.getx()

Определение property напоминает определение staticmethod или classmethod. В частности, сами property-объекты определяется на уровне класса, а не экземпляра. Это не случайно - property также является дескриптором атрибута.

Поскольку property - встроенный тип, от него можно наследоваться. Проиллюстрируем это на примере класса DelphiProperty. В Delphi необязательно задавать функцию чтения и/или установки атрибута; можно вместо этого задать имя переменной-члена класса. Если имя переменной задано вместо функции чтения, то при обращении к атрибуту будет возвращаться значение заданной переменной; если вместо функции записи - при присваивании атрибуту значения оно будет присваиваться заданной переменной. Класс DelphiProperty реализует эту логику в Python:

class DelphiProperty(property):
    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        property.__init__(
            self,
            isinstance(fget, str) and 
            (lambda obj: getattr(obj, fget)) or fget,
            isinstance(fset, str) and (lambda obj, 
            val: setattr(obj, fset, val)) or fset, fdel, doc)
class C(object):
    def __init__(self):
        self._s = ''
    def __sets(self, val):
        self._s = val.lower()
    s = DelphiProperty('_s', __sets)


>>> c = C()
>>> c.s
''
>>> c.s = 'HELLO'
>>> c.s
'hello'

Дескрипторы атрибутов

В Python 2.2 введено понятие дескриптора атрибута. Дескриптор атрибута - специальный объект, лежащий в словаре класса (не экземпляра!), и определяющий, как интерпретировать обращение к атрибуту, т.е. что делать, когда к атрибуту обратились по имени для того, чтобы его прочесть, записать или удалить. Для этого дескриптор может определить специальные методы __get__, __set__ и __delete__. В некотором смысле это напоминает property; и действительно, механизм property, описанный выше, реализован на этом механизме. Но назначение и возможности механизма дескрипторов много шире: в частности, на нем реализованы статические методы и методы класса.

Как мы знаем, при непосредственном обращении к атрибуту класса Python извлекает атрибут из словаря этого или базового класса [6]; при обращении к атрибуту экземпляра атрибут исходно ищется в словаре экземпляра; если он там не найден, он ищется так же, как атрибут класса (т.е. в словаре класса, etc.). В Python 2.2 предполагается, что любой атрибут класса может быть дескриптором. При обращении к атрибуту экземпляра Python 2.2 применяет следующую логику:

  1. Атрибут ищется в словаре класса и базовых классов (обратите внимание, это радикально отличается от логики "классического" Python, который сначала всегда сначала ищет в словаре экземпляра).
  2. Если в словаре класса или в каком-либо из базовых классов найден атрибут с заданным именем, Python проверяет, является ли он дескриптором, т.е. определен ли в нем специальный метод, соответствующий запрошенному доступу к атрибуту. Если запрошено значение атрибута, то ищется метод __get__; если запрошено удаление атрибута - __delete__; присваивание значения атрибуту - __set__.
  3. Если соответствующий метод найден, он вызывается.
  4. Если у атрибута нет соответствующего специального метода, или если в словаре класса вообще не найден атрибут с заданным именем - происходит возврат к "классической" логике поиска (т.е. попытка извлечь атрибут из словаря экземпляра, затем из словаря класса/базовых классов, затем вызов __getattr__). При этом уже не делается никаких попыток вызова специальных методов атрибута: будучи найден, он возвращается "как есть". Такая логика обеспечивает полную совместимость с "классическим" Python.

Следует подчеркнуть, что специальные методы __get__, __set__ и __delete__ должны быть методами атрибута, т.е. того класса, экземпляром которого является атрибут.

Рассмотрим пример:

# Пример класса-дескриптора.
# Атрибут, являющийся экземпляром этого класса, при каждом обращении возвращает
# тип объекта, относительно которого он запрошен.
# Это класс задает read-only атрибут: при попытке удалить или присвоить значение
# возбуждается AttributeError
class DescriptorSample(object):
    def __get__(self, instance, classobject):
        # Если значение атрибута запрашивается для класса, а не для экземпляра,
        # параметр instance будет равен None
        return instance or classobject
    def __set__(self, instance, value):
        print "__set__(self=%r,\ninstance=%r,\nvalue=%r)\n" % \
        (self, instance, value)
        raise AttributeError, "attempt to set a read-only attribute"
    def __delete__(self, instance):
        print "__delete__(self=%r,\ninstance=%r)\n" % (self, instance)
        raise AttributeError, " attempt to delete a read-only attribute"
class CDesc(object):
    desc = DescriptorSample()

  
>>> c = CDesc()
>>> print c.desc
<__main__.CDesc object at 0x008389D8>
>>> print CDesc.desc
<class '__main__.CDesc'>
>>> # Несмотря на то, что мы сейчас явно присвоим значение элементу
... # словаря экземпляра, соответствующий атрибут все равно будет
... # извлекаться в соответствии с дескриптором, находящимся в словаре класса
...
>>> c.__dict__["desc"] = 5
>>> print c.__dict__["desc"]
5
>>> print c.desc
<__main__.CDesc object at 0x00868850>

Как видите, __get__ вызывается вне зависимости от того, запрашивается значение атрибута относительно экземпляра или класса; меняется только параметр instance (в последнем случае он равен None). Но с методами __set__ и __delete__ это не так: они вызываются только в том случае, если действие над атрибутом (присваивание и удаление соответственно) запрашиваются для экземпляра, а не для класса:

>>> c = CDesc()
>>> c.desc = 5
__set__(self=<__main__.DescriptorSample object at 0x00846560>,
instance=<__main__.CDesc object at 0x0084E048>, value=5)
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
  File "f:/TEMP/python-448_PW", line 8, in __set__
AttributeError: attempt to set a read-only attribute
>>> print c.desc
<__main__.CDesc object at 0x0084E048>
>>> CDesc.desc = 5
>>> print c.desc
5

Как видите, при попытке присвоить значение атрибуту экземпляра был вызван __set__, а при присваивании атрибуту класса - нет. То же самое для __delete__:

>>> c = CDesc()
>>> del c.desc
__delete__(self=<__main__.DescriptorSample object at 0x00822850>,
instance=<__main__.CDesc object at 0x00850078>)
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
  File "f:/TEMP/python-448Zki", line 12, in __delete__
AttributeError: attempt to delete a read-only attribute
>>> print c.desc
<__main__.CDesc object at 0x00850078>
>>> del CDesc.desc
>>> print c.desc
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
AttributeError: 'CDesc' object has no attribute 'desc'

Если вдуматься, это естественно: для корректной реализации __set__ и __delete__ для атрибутов класса, потребовалось бы определить их для атрибутов метакласса!

Дескриптор атрибута может определять следующие методы:

  • __get__ - вызывается при чтении атрибута экземпляра или класса. Должен возвратить значение атрибута (или возбудить исключение). Принимает, помимо self, два параметра. Первый параметр - экземпляр, для которого запрашивается атрибут. Если атрибут запрашивается относительно класса, этот параметр равен None. Второй параметр - класс, для которого запрашивается атрибут. Если атрибут запрашивается для экземпляра, то класс экземпляра. Таким образом, псевдокод чтения атрибута через дескриптор выглядит примерно так:
    def get_attribute_by_descriptor(descriptor, object):
        if type(object) is type:
            return descriptor.__get__(None, object)
        else:
            return descriptor.__get__(object, object.__class__)
  • __set__ - вызывается при присваивании значения атрибуту экземпляра. Возвращаемое значение игнорируется. Принимает, помимо self, два параметра. Первый параметр - экземпляр, атрибуту которого присваивается значение. Второй - значение, присваиваемое атрибуту.
  • __delete__ - вызывается при удалении атрибута экземпляра. Возвращаемое значение игнорируется. Принимает, помимо self, один параметр - экземпляр, атрибут которого удаляется.

Теперь понятно, что

  • staticmethod - дескриптор, метод __get__ которого игнорирует параметры и всегда возвращает в неизменном виде функцию, переданную в конструктор;
  • classmethod - дескриптор, метод __get__ которого создает из функции, переданной в конструктор, связанный метод (bound method), первый (неявный) параметр которого - класс, переданный последним параметром в __get__;
  • property - дескриптор, у которого __get__ вызывает fget, __set__ - fset, __delete__ - fdel.

Контроль доступа к атрибутам объекта с помощью __getattribute__

Если метод __getattr__ вызывается только в том случае, если запрашивается значение атрибута, который отсутствует как в экземпляре, так и в классе, то новый метод __getattribute__ вызывается при запросе значения любого атрибута, в том числе и существующего. __getattribute__ вызывается до __getattr__ или функции fget из property, перехватывая все запросы значения атрибутов:


class A(object):
    def __getattribute__(self, attr):
        print "__getattribute__(%r)" % attr
        return object.__getattribute__(self, attr)
    def __getattr__(self, attr):
        print "__getattr__(%r)" % attr
        return attr
    def geta(self):
        print "geta()"
        return 8
    a = property(geta)


>>> c = A()
>>> # Атрибут 'a' существует (задан как property)
... 
>>> # Атрибут 'a' существует (задан как property)
... print c.a
__getattribute__('a')
geta()
8
>>> # Запрос несуществующего атрибута показывает, что __getattribute__
... # вызывается перед __getattr__
... print c.b
__getattribute__('b')
__getattr__('b')
b
>>> c.x = 20
>>> # Атрибут 'x' в словаре экземпляра; __getattribute__ вызывается все равно
... print c.x
__getattribute__('x')
20

Следует быть очень аккуратным в реализации __getattribute__: любое обращение к атрибуту экземпляра, включая __dict__, приведет к бесконечной рекурсии. Единственный правильный метод обращения к атрибутам экземпляра изнутри __getattribute__ - вызов __getattribute__ базового класса. Кроме того, обращение к атрибутам через __dict__ в новых классах может быть некорректным и потому, что не все атрибуты экземпляра могут находиться в словаре экземпляра; некоторые могут быть заданы как дескрипторы/property, часть - как слоты. Поэтому в новых классах не только в реализации __getattribute__, но и в реализации __getattr__ имеет смысл пользоваться вызовом метода базового класса для доступа к существующему атрибуту вместо использования словаря экземпляра.

Не стоит злоупотреблять __getattribute__: из-за вызова при каждом чтении каждого атрибута серьезно страдает производительность.

Заключение

В следующей части статьи будут рассмотрены вопросы множественного наследования в Python 2.2

Список литературы

van Rossum, Guido. 2001. Unifying types and classes in Python 2.2.
http://www.python.org/2.2/descrintro.html

van Rossum, Guido. 2001. PEP 252 - Making Types Look More Like Classes.
http://www.python.org/peps/pep-0252.html

van Rossum, Guido. 2001. PEP 253 - Subtyping Built-in Types.
http://www.python.org/peps/pep-0253.html
[1] В современном C++ достаточно последовательно соблюдается подход "класс – это тип", и разницы в применении встроенных типов и классов в большинстве случаев нет совсем; но использовать встроенные типы в качестве базовых классов все же нельзя.

[2] К сожалению, типы расширения, написанные до Python 2.2, не могут служить базовыми типами без (правда минимальных) изменений в коде, хотя сами также считаются базирующимися на object.

[3] Похоже, это просто ошибка в коде функции, разбирающей строку параметров.

[4] Если не принимать во внимание, что доступ к атрибуту класса может быть переопределен в метаклассе; но здесь мы этого не касаемся.

[5] Таким образом, функция hasattr отвечает скорее не на вопрос "есть ли заданный атрибут", а на вопрос "может ли быть прочитано значение заданного атрибута".

[6] Точнее, вызывает метод __getattribute__ метакласса, который по умолчанию ищет атрибут в словаре класса; но мы не будем вдаваться в такие тонкости.


Автор: Яков Маркович, ведущий инженер-исследователь отдела разработки компании Intersoft Lab