python

Атрибуты и протокол дескриптора в Python

  • пятница, 13 декабря 2019 г. в 00:31:41
https://habr.com/ru/post/479824/
  • Python
  • Программирование


Рассмотрим такой код:


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}}

Пара вещей на заметку относительно этого кода:


  1. __setattr__  не имеет своего аналога __getattribute__  (т.е. магического метода __setattribute__ не существует).
  2. __setattr__  вызывается внутри __init__, именно поэтому мы  вынуждены делать self.__dict__['my_dunder_dict'] = {} вместо self.my_dunder_dict = {}. В противном случае мы столкнулись бы с бесконечной рекурсией.


А ведь у нас есть ещё и 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 в целом корректна, учтите, что всегда могут найтись небольшие детали, реализованные по-другому.


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