Атрибуты и протокол дескриптора в Python
- пятница, 13 декабря 2019 г. в 00:31:41
Рассмотрим такой код:
class Foo:
def __init__(self):
self.bar = 'hello!'
foo = Foo()
print(foo.bar)
Сегодня мы разберём ответ на вопрос: «Что именно происходит, когда мы пишем foo.bar?»
Вы, возможно, уже знаете, что у большинства объектов есть внутренний словарь __dict__, содержащий все их аттрибуты. И что особенно радует, как легко можно изучать такие низкоуровневые детали в Питоне:
>>> foo = Foo()
>>> foo.__dict__
{'bar': 'hello!'}
Давайте начнём с попытки сформулировать такую (неполную) гипотезу:
foo.bar эквивалентно foo.__dict__['bar'] .
Пока звучит похоже на правду:
>>> foo = Foo()
>>> foo.__dict__['bar']
'hello!'
Теперь предположим, что вы уже в курсе, что в классах можно объявлять динамические аттрибуты:
>>> class Foo:
... def __init__(self):
... self.bar = 'hello!'
...
... def __getattr__(self, item):
... return 'goodbye!'
...
... foo = Foo()
>>> foo.bar
'hello!'
>>> foo.baz
'goodbye!'
>>> foo.__dict__
{'bar': 'hello!'}
Хм… ну ладно. Видно что __getattr__ может эмулировать доступ к «ненастоящим» атрибутам, но не будет работать, если уже есть объявленная переменная (такая, как foo.bar, возвращающая 'hello!', а не 'goodbye!'). Похоже, всё немного сложнее, чем казалось вначале.
И действительно: существует магический метод, который вызывается всякий раз, когда мы пытаемся получить атрибут, но, как продемонстрировал пример выше, это не __getattr__. Вызываемый метод называется __getattribute__, и мы попробуем понять, как в точности он работает, наблюдая различные ситуации.
Пока что модифицируем нашу гипотезу так:
foo.bar эквивалентно foo.__getattribute__('bar'), что примерно работает так:
def __getattribute__(self, item): if item in self.__dict__: return self.__dict__[item] return self.__getattr__(item)
Проверим практикой, реализовав этот метод (под другим именем) и вызывая его напрямую:
>>> class Foo:
... def __init__(self):
... self.bar = 'hello!'
...
... def __getattr__(self, item):
... return 'goodbye!'
...
... def my_getattribute(self, item):
... if item in self.__dict__:
... return self.__dict__[item]
... return self.__getattr__(item)
>>> foo = Foo()
>>> foo.bar
'hello!'
>>> foo.baz
'goodbye!'
>>> foo.my_getattribute('bar')
'hello!'
>>> foo.my_getattribute('baz')
'goodbye!'
Выглядит корректно, верно?
Отлично, осталось лишь проверить, что поддерживается присвоение переменных, после чего можно расходиться по дом… —
>>> foo.baz = 1337
>>> foo.baz
1337
>>> foo.my_getattribute('baz') = 'h4x0r'
SyntaxError: can't assign to function call
Чёрт.
my_getattribute возвращает некий объект. Мы можем изменить его, если он мутабелен, но мы не можем заменить его на другой с помощью оператора присвоения. Что же делать? Ведь если foo.baz это эквивалент вызова функции, как мы можем присвоить новое значение атрибуту в принципе?
Когда мы смотрим на выражение типа foo.bar = 1, происходит что-то больше, чем просто вызов функции для получения значения foo.bar. Похоже, что присвоение значения атрибуту фундаментально отличается от получения значения атрибута. И правда: мы может реализовать __setattr__, чтобы убедиться в этом:
>>> class Foo:
... def __init__(self):
... self.__dict__['my_dunder_dict'] = {}
... self.bar = 'hello!'
...
... def __setattr__(self, item, value):
... self.my_dunder_dict[item] = value
...
... def __getattr__(self, item):
... return self.my_dunder_dict[item]
>>> foo = Foo()
>>> foo.bar
'hello!'
>>> foo.bar = 'goodbye!'
>>> foo.bar
'goodbye!'
>>> foo.baz
Traceback (most recent call last):
File "<pyshell#75>", line 1, in <module>
foo.baz
File "<pyshell#70>", line 10, in __getattr__
return self.my_dunder_dict[item]
KeyError: 'baz'
>>> foo.baz = 1337
>>> foo.baz
1337
>>> foo.__dict__
{'my_dunder_dict': {'bar': 'goodbye!', 'baz': 1337}}
Пара вещей на заметку относительно этого кода:
А ведь у нас есть ещё и property (и его друзья). Декоратор, который позволяет методам выступать в роли атрибутов.
Давайте постараемся понять, как это происходит.
>>> class Foo(object):
... def __getattribute__(self, item):
... print('__getattribute__ was called')
... return super().__getattribute__(item)
...
... def __getattr__(self, item):
... print('__getattr__ was called')
... return super().__getattr__(item)
...
... @property
... def bar(self):
... print('bar property was called')
... return 100
>>> f = Foo()
>>> f.bar
__getattribute__ was called
bar property was called
Просто ради интереса, а что у нас в f.__dict__?
>>> f.__dict__
__getattribute__ was called
{}
В __dict__ нет ключа bar, но __getattr__ почему-то не вызывается. WAT?
bar — метод, да ещё и принимающий в качестве параметра self, вот только это метод находится в классе, а не в экземпляре класса. И в этом легко убедиться:
>>> Foo.__dict__
mappingproxy({'__dict__': <attribute '__dict__' of 'Foo' objects>,
'__doc__': None,
'__getattr__': <function Foo.__getattr__ at 0x038308A0>,
'__getattribute__': <function Foo.__getattribute__ at 0x038308E8>,
'__module__': '__main__',
'__weakref__': <attribute '__weakref__' of 'Foo' objects>,
'bar': <property object at 0x0381EC30>})
Ключ bar действительно находится в словаре атрибутов класса. Чтобы понять работу __getattribute__, нам нужно ответить на вопрос: чей __getattribute__ вызывается раньше — класса или экземпляра?
>>> f.__dict__['bar'] = 'will we see this printed?'
__getattribute__ was called
>>> f.bar
__getattribute__ was called
bar property was called
100
Видно, что первым делом проверка идёт в __dict__ класса, т.е. у него приоритет перед экземпляром.
Погодите-ка, а когда мы вызывали метод bar? Я имею в виду, что наш псевдокод для __getattribute__ никогда не вызывает объект. Что же происходит?
Встречайте протокол дескриптора:
descr.__get__(self, obj, type=None) -> value
descr.__set__(self, obj, value) -> None
descr.__delete__(self, obj) -> None
Вся суть тут. Реализуйте любой из этих трёх методов, чтобы объект стал дескриптором и мог менять дефолтное поведение, когда с ним работают как с атрибутом.
Если объект объявляет и __get__(), и __set__(), то его называют дескриптором данных («data descriptors»). Дескрипторы реализующие лишь __get__() называются дескрипторами без данных («non-data descriptors»).
Оба вида дескрипторов отличаются тем, как происходит перезапись элементов словаря атрибутов объекта. Если словарь содержит ключ с тем же именем, что и у дескриптора данных, то дескриптор данных имеет приоритет (т.е. вызывается __set__()). Если словарь содержит ключ с тем же именем, что у дескриптора без данных, то приоритет имеет словарь (т.е. перезаписывается элемент словаря).
Чтобы создать дескриптор данных доступный только для чтения, объявите и __get__(), и __set__(), где __set__() кидает AttributeError при вызове. Реализации такого __set__() достаточно для создания дескриптора данных.
Короче говоря, если вы объявили любой из этих методов — __get__, __set__ или __delete__, вы реализовали поддержку протокола дескриптора. А это именно то, чем занимается декоратор property: он объявляет доступный только для чтения дескриптор, который будет вызываться в __getattribute__.
Последнее изменение нашей реализации:
foo.bar эквивалентно foo.__getattribute__('bar'), что примерно работает так:
def __getattribute__(self, item): if item in self.__class__.__dict__: v = self.__class__.__dict__[item] elif item in self.__dict__: v = self.__dict__[item] else: v = self.__getattr__(item) if hasattr(v, '__get__'): v = v.__get__(self, type(self)) return v
Попробуем продемонстрировать на практике:
class Foo:
class_attr = "I'm a class attribute!"
def __init__(self):
self.dict_attr = "I'm in a dict!"
@property
def property_attr(self):
return "I'm a read-only property!"
def __getattr__(self, item):
return "I'm dynamically returned!"
def my_getattribute(self, item):
if item in self.__class__.__dict__:
print('Retrieving from self.__class__.__dict__')
v = self.__class__.__dict__[item]
elif item in self.__dict__:
print('Retrieving from self.__dict__')
v = self.__dict__[item]
else:
print('Retrieving from self.__getattr__')
v = self.__getattr__(item)
if hasattr(v, '__get__'):
print("Invoking descriptor's __get__")
v = v.__get__(self, type(self))
return v
>>> foo = Foo()
...
... print(foo.class_attr)
... print(foo.dict_attr)
... print(foo.property_attr)
... print(foo.dynamic_attr)
...
... print()
...
... print(foo.my_getattribute('class_attr'))
... print(foo.my_getattribute('dict_attr'))
... print(foo.my_getattribute('property_attr'))
... print(foo.my_getattribute('dynamic_attr'))
I'm a class attribute!
I'm in a dict!
I'm a read-only property!
I'm dynamically returned!
Retrieving from self.__class__.__dict__
I'm a class attribute!
Retrieving from self.__dict__
I'm in a dict!
Retrieving from self.__class__.__dict__
Invoking descriptor's __get__
I'm a read-only property!
Retrieving from self.__getattr__
I'm dynamically returned!
Мы лишь немного поскребли поверхность реализации атрибутов в Python. Хотя наша последняя попытка эмулировать foo.bar в целом корректна, учтите, что всегда могут найтись небольшие детали, реализованные по-другому.
Надеюсь, что помимо знаний о том, как работают атрибуты, мне так же удалось передать красоту языка, который поощряет вас к экспериментам. Погасите часть долга знаний сегодня.