Python / [Из песочницы] Пользовательские атрибуты в Python

в 11:32, , рубрики: Новости, метки: , ,

Python / [Из песочницы] Пользовательские атрибуты в Python
Вы когда нибудь задумывались о том, что происходит, когда вы ставите точку в python? Что скрывает за собой символ str(“u002E”)? Какие тайны он хранит? Если без мистики, вы знаете как происходит поиск и установка значений пользовательских атрибутов в python? Хотели бы узнать? Тогда… добро пожаловать!
Чтобы время, проведённое за чтением прошло легко, приятно и с пользой, было бы неплохо знать несколько базовых понятий языка. В частности, понимание type и object будут исключительно полезны, так же как знание нескольких примеров обоих сущностей. Почитать о них можно, в том числе, здесь.
Немного о терминологии, которую я использую, прежде чем мы приступим к тому, ради чего собрались:
Объект есть любая сущность в python (функция, число, строка… словом, всё).

Класс это объект, чьим типом является type (тип можно подсмотреть в атрибуте __class__).

Экземпляр некоторого класса A — это объект, у которого в атрибуте __bases__ есть ссылка на класс A.

Ах, да, все примеры в статье написаны на python3! Это определённо следует учесть.
Если ничто из вышесказанного не смогло умерить ваше желание узнать, что там будет дальше, приступим!
__dict__

Атрибуты объекта можно условно разделить две группы: определённые python-ом (такие как __class__, __bases__) и определённые пользователем, о них я как раз собираюсь рассказать. __dict__ согласно этой классификации, относится к “системным” (определённым python-ом) атрибутам. Его задача — хранить пользовательские атрибуты. Он представляет собой dictionary, в котором ключом является имя_атрибута, значением, соответственно, значение_атрибута.
Чтобы найти атрибут объекта o, python обыскивает:
Сам объект (o.__dict__ и его системные атрибуты).

Класс объекта (o.__class__.__dict__). Только __dict__ класса, не системные атрибуты.

Классы, от которых унасаледован класс объекта (o.__class__.__bases__.__dict__).

Таким образом, с помощью __dict__ атрибут может быть определён как для конкретного экземпляра, так и для класса (то есть для всех объектов, которые являются экземплярами данного класса).
class StuffHolder:
stuff = "class stuff"

a = StuffHolder()
b = StuffHolder()
a.stuff # "class stuff"
b.stuff # "class stuff"

b.b_stuff = "b stuff"
b.b_stuff # "b stuff"
a.b_stuff # AttributeError

В примере описан класс StuffHolder с одним атрибутом stuff, который, наследуют оба его экземпляра. Добавление объекту b атрибута b_stuff, никак не отражается на a.
Посмотрим на __dict__ всех действующих лиц:
StuffHolder.__dict__ # {... 'stuff': 'class stuff' ...}
a.__dict__ # {}
b.__dict__ # {'b_stuff': 'b stuff'}

a.__class__ #
b.__class__ #

(У класса StuffHolder в __dict__ хранится объект класса dict_proxy с кучей разного барахла, на которое пока не нужно обращать внимание).
Ни у a ни у b в __dict__ нет атрибута stuff, не найдя его там, механизм поиска ищет его в __dict__ класса (StuffHolder), успешно находит и возвращает значение, присвоенное ему в классе. Ссылка на класс хранится в атрибуте __class__ объекта.
Поиск атрибута происходит во время выполнения, так что даже после создания экземпляров, все изменения в __dict__ класса отразятся в них:
a.new_stuff # AttributeError
b.new_stuff # AttributeError

StuffHolder.new_stuff = "new"
StuffHolder.__dict__ # {... 'stuff': 'class stuff', 'new_stuff': 'new'...}
a.new_stuff # "new"
b.new_stuff # "new"

В случае присваивания значения атрибуту экземпляра, изменяется только __dict__ экземпляра, то есть значение в __dict__ класса остаётся неизменным (в случае, если значением атрибута класса не является data descriptor):
stuffholder.__dict__ # {... 'stuff': 'class stuff' ...}
c = stuffholder()
c.__dict__ # {}

c.stuff = "more c stuff"
c.__dict__ # {'stuff': 'more c stuff'}
stuffholder.__dict__ # {... 'stuff': 'class stuff' ...}

Если имёна атрибутов в классе и экземпляре совпадают, интерпретатор при поиске значения выдаст значение экземпляра (в случае, если значением атрибута класса не является data descriptor):
StuffHolder.__dict__ # {... 'stuff': 'class stuff' ...}
d = StuffHolder()
d.stuff # "class stuff"

d.stuff = "d stuff"
d.stuff # "d stuff"

По большому счёту это всё, что можно сказать про __dict__. Это хранилище атрибутов, определённых пользователем. Поиск в нём производится во время выполнения и при поиске учитывается __dict__ класса объекта и базовых классов. Также важно знать, что есть несколько способов переопределить это поведение. Одним из них является великий и могучий Дескриптор!
Дескрипторы

С простыми типами в качестве значений атрибутов пока всё ясно. Посмотрим, как ведёт себя функция в тех же условиях:
class FuncHolder:
def func(self):
pass
fh = FuncHolder()

FuncHolder.func #
FuncHolder.__dict__ # {...'func': ...}
fh.func # <bound method FuncHolder.func of >

WTF!? Спросите вы… возможно. Я бы спросил. Чем функция в этом случае отличается от того, что мы уже видели? Ответ прост: методом __get__.
FuncHolder.func.__class__.__get__ #

Этот метод переопределяет механизм получения значения атрибута func экземпляра fh, а объект, который реализует этот метод непереводимо называется non-data descriptor.
Из howto:
Дескриптор — это объект, доступ к которому через атрибут переопределён методами в дескриптор протоколе:
descr.__get__(self, obj, type=None) --> value (переопределяет способ получения значения атрибута)
descr.__set__(self, obj, value) --> None (переопределяет способ присваивания значения атрибуту)
descr.__delete__(self, obj) --> None (переопределяет способ удаления атрибута)

Дескрипторы бывают двух видов:Data Descriptor (дескриптор данных) — объект, который реализует метод __get__() и __set__()

Non-data Descriptor (дескриптор не данных?) — объект, который реализует метод __get__()

Отличаются они своим поведением по отношению к записям в __dict__ экземпляра. Если в __dict__ есть запись с тем же именем, что у дескриптора данных, у дескриптора преимущество. Если имя записи совпадает с именем “дескриптора не данных”, приоритет записи в __dict__ выше.
Дескрипторы данных

Рассмотрим повнимательней дескриптор данных:
class DataDesc:

def __get__(self, obj, cls):
print("Trying to access from {0} class {1}".format(obj, cls))

def __set__(self, obj, val):
print("Trying to set {0} for {1}".format(val, obj))

def __delete__(self, obj):
print("Trying to delete from {0}".format(obj))

class DataHolder:
data = DataDesc()
d = DataHolder()

DataHolder.data # Trying to access from None class
d.data # Trying to access from class
d.data = 1 # Trying to set 1 for
del(d.data) # Trying to delete from

Стоит обратить внимание, что вызов DataHolder.data передаёт в метод __get__ None вместо экземпляра класса.
Проверим утверждение о том, что у дата дескрипторов преимущество перед записями в __dict__ экземпляра:
d.__dict__["data"] = "override!"
d.__dict__ # {'data': 'override!'}
d.data # Trying to access from class

Так и есть, запись в __dict__ экземпляра игнорируется, если в __dict__ класса экземпляра (или его базового класса) существует запись с тем же именем и значением — дескриптором данных.
Ещё один важный момент. Если изменить значение атрибута с дескриптором через класс, никаких методов дескриптора вызвано не будет, значение изменится в __dict__ класса как если бы это был обычный атрибут:
DataHolder.__dict__ # {...'data': ...}
DataHolder.data = "kick descriptor out"
DataHolder.__dict__ # {...'data': 'kick descriptor out'...}
DataHolder.data # "kick descriptor out"

Дескрипторы не данных

Пример дескриптора не данных:
class NonDataDesc:

def __get__(self, obj, cls):
print("Trying to access from {0} class {1}".format(obj, cls))

class NonDataHolder:
non_data = NonDataDesc()
n = NonDataHolder()

NonDataHolder.non_data # Trying to access from None class
n.non_data # Trying to access from class
n.non_data = 1
n.non_data # 1
n.__dict__ # {'non_data': 1}

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

Дескрипторы это мощный инструмент, позволяющий контролировать доступ к атрибутам экземпляра класса. Один из примеров их использования — функции, при вызове через экземпляр они становятся методами (см. пример выше). Также распространённый способ применения дескрипторов — создание свойства (property). Под свойством я подразумеваю некое значение, характеризующее состояние объекта, доступ к которому управляется с помощью специальных методов (геттеров, сеттеров). Создать свойство просто с помощью дескриптора:
class Descriptor:
def __get__(self, obj, type):
print("getter used")
def __set__(self, obj, val):
print("setter used")
def __delete__(self, obj):
print("deleter used")

class MyClass:
prop = Descriptor()

Или можно воспользоваться встроенным классом property, он представляет собой дескриптор данных. Код, представленный выше можно переписать следующим образом:
class MyClass:

def __init__(self):
self._prop = None
def _getter(self):
print("getter used")
def _setter(self, val):
print("setter used")
def _deleter(self):
print("deleter used")

prop = property(_getter, _setter, _deleter, "doc string")

В обоих случаях мы получим одинаковое поведение:
m = MyClass()
m.prop # getter used
m.prop = 1 # setter used
del(m.prop) # deleter used

Важно знать, что property всегда является дескриптором данных. Если в его конструктор не передать какую либо из функций (геттер, сеттер или делитер), при попытке выполнить над атрибутом соответствующее действие — выкинется AttributeError.
class MySecondClass:
prop = property()

m2 = MySecondClass()
m2.prop # AttributeError: unreadable attribute
m2.prop = 1 # AttributeError: can't set attribute
del(m2) # AttributeError: can't delete attribute

К встроенным дескрипторам также относятся:
staticmethod — то же, что функция вне класса, в неё не передаётся экземпляр в качестве первого аргумента.

classmethod — то же, что метод класса, только в качестве первого аргумента передаётся класс экземпляра.

class StaticAndClassMethodHolder:

def _method(*args):
print("_method called with ", args)
static = staticmethod(_method)
cls = classmethod(_method)

s = StaticAndClassMethodHolder()
s._method() # _method called with (,)
s.static() # _method called with ()
s.cls() # _method called with (,)

__getattr__(), __setattr__(), __delattr__() и __getattribute__()

Если нужно определить поведение какого-либо объекта как атрибута, следует использовать дескрипторы (например property). Тоже справедливо для семейства объектов (например функций). Ещё один способ повлиять на доступ к атрибутам: методы __getattr__(), __setattr__(), __delattr__() и __getattribute__(). В отличие от дескрипторов их следует определять для объекта, содержащего атрибуты и вызываются они при доступе к любому атрибуту этого объекта.__getattr__(self, name) будет вызван в случае, если запрашиваемый атрибут не найден обычным механизмом (в __dict__ экземпляра, класса и т.д.):
class SmartyPants:
def __getattr__(self, attr):
print("Yep, I know", attr)
tellme = "It's a secret"

smarty = SmartyPants()
smarty.name = "Smartinius Smart"

smarty.quicksort # Yep, I know quicksort
smarty.python # Yep, I know python
smarty.tellme # "It's a secret"
smarty.name # "Smartinius Smart"

__getattribute__(self, name) будет вызван при попытке получить значение атрибута. Если этот метод переопределён, стандартный механизм поиска значения атрибута не будет задействован. Следует иметь ввиду, что вызов специальных методов (например __len__(), __str__()) через встроенные функции или неявный вызов через синтаксис языка осуществляется в обход __getattribute__().
class Optimist:
attr = "class attribute"

def __getattribute__(self, name):
print("{0} is great!".format(name))

def __len__(self):
print("__len__ is special")
return 0

o = Optimist()
o.instance_attr = "instance"

o.attr # attr is great!
o.dark_beer # dark_beer is great!
o.instance_attr # instance_attr is great!
o.__len__ # __len__ is great!
len(o) # __len__ is specialn 0

__setattr__(self, name, value) будет вызван при попытке установить значение атрибута экземпляра. Аналогично __getattribute__(), если этот метод переопределён, стандартный механизм установки значения не будет задействован:
class NoSetters:
attr = "class attribute"
def __setattr__(self, name, val):
print("not setting {0}={1}".format(name,val))

no_setters = NoSetters()
no_setters.a = 1 # not setting a=1
no_setters.attr = 1 # not setting attr=1
no_setters.__dict__ # {}
no_setters.attr # "class attribute"
no_setters.a # AttributeError

__delattr__(self, name) — аналогичен __setattr__(), но используется при удалении атрибута.
При переопределении __getattribute__(), __setattr__() и __delattr__() следует иметь ввиду, что стандартный способ получения доступа к атрибутам можно вызвать через object:
class GentleGuy:
def __getattribute__(self, name):
if name.endswith("_please"):
return object.__getattribute__(self, name.replace("_please", ""))
raise AttributeError("And the magic word!?")

gentle = GentleGuy()

gentle.coffee = "some coffee"
gentle.coffee # AttributeError
gentle.coffee_please # "some coffee"

Соль

Итак, чтобы получить значение атрибута attrname экземпляра a в python:Если определён метод a.__class__.__getattribute__(), то вызывается он и возвращается полученное значение.

Если attrname это специальный (определённый python-ом) атрибут, такой как __class__ или __doc__, возвращается его значение.

Проверяется a.__class__.__dict__ на наличие записи с attrname. Если она существует и значением является дескриптор данных, возвращается результат вызова метода __get__() дескриптора. Также проверяются все базовые классы.

Если в a.__dict__ существует запись с именем attrname, возвращается значение этой записи. Если a — это класс, то атрибут ищется и среди его базовых классов и, если там или в __dict__ a дескриптор данных — возвращается результат __get__() дескриптора.

Проверяется a.__class__.__dict__, если в нём существует запись с attrname и это “дескриптор не данных”, возвращается результат __get__() дескриптора, если запись существует и там не дескриптор, возвращается значение записи. Также обыскиваются базовые классы.

Если существует метод a.__class__.__getattr__(), он вызывается и возвращается его результат. Если такого метода нет — выкидывается AttributeError.

Чтобы установить значение value атрибута attrname экземпляра a:Если существует метод a.__class__.__setattr__(), он вызывается.

Проверяется a.__class__.__dict__, если в нём есть запись с attrname и это дескриптор данных — вызывается метод __set__() дескриптора. Также проверяются базовые классы.

В a.__dict__ добавляется запись value с ключом attrname.

__slots__

Как пишет Guido в своей истории python о том, как изобретались new-style classes:
… Я боялся что изменения в системе классов плохо повлияют на производительность. В частности, чтобы дескрипторы данных работали корректно, все манипуляции атрибутами объекта начинались с проверки __dict__ класса на то, что этот атрибут является дескриптором данных…
На случай, если пользователи разочаруются ухудшением производительности, заботливые разработчики python придумали __slots__.
Наличие __slots__ ограничивает возможные имена атрибутов объекта теми, которые там указаны. Также, так как все имена атрибутов теперь заранее известны, снимает необходимость создавать __dict__ экземпляра.
class Slotter:
__slots__ = ["a", "b"]

s = Slotter()
s.__dict__ # AttributeError
s.c = 1 # AttributeError
s.a = 1
s.a # 1
s.b = 1
s.b # 1
dir(s) # [ ... 'a', 'b' ... ]

Оказалось, что опасения Guido не оправдались, но к тому времени, как это стало ясно, было уже слишком поздно. К тому же, использование __slots__ действительно может увеличить производительность, особенно уменьшив количество используемой памяти при создании множества небольших объектов.
Заключение

Доступ к атрибутом в python можно контролировать огромным количеством способов. Каждый из них решает свою задачу, а вместе они подходят практически под любой мыслимый сценарий использования объекта. Эти механизмы — основа гибкости языка, наряду с множественным наследованием, метаклассами и прочими вкусностями. У меня ушло некоторое время на то, чтобы разобраться, понять и, главное, принять это множество вариантов работы атрибутов. На первый взгляд оно показалось слегка избыточным и не особенно логичным, но, учитывая, что в ежедневном программировании это редко пригодиться, приятно иметь в своём арсенале такие мощные инструменты.
Надеюсь, и вам эта статья прояснила парочку моментов, до которых руки не доходили разобраться. И теперь, с огнём в глазах и уверенностью в Точке, вы напишите огромное количество наичистейшего, читаемого и устойчивого к изменениям требований кода! Ну или комментарий.
Спасибо за ваше время.
Ссылки

Shalabh Chaturvedi. Python Attributes and Methods

Guido Van Rossum. The Inside Story on New-Style Classes

Python documentation


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js