Pull to refresh

Пользовательские атрибуты в Python

Reading time 11 min
Views 183K
Вы когда нибудь задумывались о том, что происходит, когда вы ставите точку в python? Что скрывает за собой символ str(“\u002E”)? Какие тайны он хранит? Если без мистики, вы знаете как происходит поиск и установка значений пользовательских атрибутов в python? Хотели бы узнать? Тогда… добро пожаловать!
Чтобы время, проведённое за чтением прошло легко, приятно и с пользой, было бы неплохо знать несколько базовых понятий языка. В частности, понимание type и object будут исключительно полезны, так же как знание нескольких примеров обеих сущностей. Почитать о них можно, в том числе, здесь.
Немного о терминологии, которую я использую, прежде чем мы приступим к тому, ради чего собрались:
  • Объект есть любая сущность в python (функция, число, строка… словом, всё).
  • Класс это объект, чьим типом является type (тип можно подсмотреть в атрибуте __class__).
  • Экземпляр некоторого класса A — это объект, у которого в атрибуте __class__ есть ссылка на класс A.

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

__dict__


Атрибуты объекта можно условно разделить две группы: определённые python-ом (такие как __class__, __bases__) и определённые пользователем, о них я как раз собираюсь рассказать. __dict__ согласно этой классификации, относится к “системным” (определённым python-ом) атрибутам. Его задача — хранить пользовательские атрибуты. Он представляет собой dictionary, в котором ключом является имя_атрибута, значением, соответственно, значение_атрибута.
Чтобы найти атрибут объекта o, python обыскивает:
  1. Сам объект (o.__dict__ и его системные атрибуты).
  2. Класс объекта (o.__class__.__dict__). Только __dict__ класса, не системные атрибуты.
  3. Классы, от которых унасаледован класс объекта (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__             # <class '__main__.StuffHolder'>
b.__class__             # <class '__main__.StuffHolder'>
(У класса 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     # <function func at 0x8f806ac>
FuncHolder.__dict__ # {...'func': <function func at 0x8f806ac>...}
fh.func             # <bound method FuncHolder.func of <__main__.FuncHolder object at 0x900f08c>>

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

FuncHolder.func.__class__.__get__   # <slot wrapper '__get__' of 'function' objects>

Этот метод переопределяет механизм получения значения атрибута func экземпляра fh, а объект, который реализует этот метод непереводимо называется non-data descriptor.

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

Дескрипторы бывают двух видов:
  1. Data Descriptor (дескриптор данных) — объект, который реализует метод __get__() и __set__()
  2. 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 <class '__main__.DataHolder'>
d.data          # Trying to access from <__main__.DataHolder object at ...> class <class '__main__.DataHolder'>
d.data = 1      # Trying to set 1 for <__main__.DataHolder object at ...>
del(d.data)     # Trying to delete from <__main__.DataHolder object at ...>

Стоит обратить внимание, что вызов DataHolder.data передаёт в метод __get__ None вместо экземпляра класса.
Проверим утверждение о том, что у дата дескрипторов преимущество перед записями в __dict__ экземпляра:

d.__dict__["data"] = "override!"
d.__dict__  # {'data': 'override!'}
d.data      # Trying to access from <__main__.DataHolder object at ...> class <class '__main__.DataHolder'>

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

Ещё один важный момент. Если изменить значение атрибута с дескриптором через класс, никаких методов дескриптора вызвано не будет, значение изменится в __dict__ класса как если бы это был обычный атрибут:

DataHolder.__dict__ # {...'data': <__main__.DataDesc object at ...>...}
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 <class '__main__.NonDataHolder'>
n.non_data              # Trying to access from <__main__.NonDataHolder object at ...> class <class '__main__.NonDataHolder'>
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 _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 (<__main__.StaticAndClassMethodHolder object at ...>,)
s.static()      # _method called with ()
s.cls()         # _method called with (<class '__main__.StaticAndClassMethodHolder'>,)


__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 special\n 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:
  1. Если определён метод a.__class__.__getattribute__(), то вызывается он и возвращается полученное значение.
  2. Если attrname это специальный (определённый python-ом) атрибут, такой как __class__ или __doc__, возвращается его значение.
  3. Проверяется a.__class__.__dict__ на наличие записи с attrname. Если она существует и значением является дескриптор данных, возвращается результат вызова метода __get__() дескриптора. Также проверяются все базовые классы.
  4. Если в a.__dict__ существует запись с именем attrname, возвращается значение этой записи. Если a — это класс, то атрибут ищется и среди его базовых классов и, если там или в __dict__ a дескриптор данных — возвращается результат __get__() дескриптора.
  5. Проверяется a.__class__.__dict__, если в нём существует запись с attrname и это “дескриптор не данных”, возвращается результат __get__() дескриптора, если запись существует и там не дескриптор, возвращается значение записи. Также обыскиваются базовые классы.
  6. Если существует метод a.__class__.__getattr__(), он вызывается и возвращается его результат. Если такого метода нет — выкидывается AttributeError.

Чтобы установить значение value атрибута attrname экземпляра a:
  1. Если существует метод a.__class__.__setattr__(), он вызывается.
  2. Проверяется a.__class__.__dict__, если в нём есть запись с attrname и это дескриптор данных — вызывается метод __set__() дескриптора. Также проверяются базовые классы.
  3. В 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 можно контролировать огромным количеством способов. Каждый из них решает свою задачу, а вместе они подходят практически под любой мыслимый сценарий использования объекта. Эти механизмы — основа гибкости языка, наряду с множественным наследованием, метаклассами и прочими вкусностями. У меня ушло некоторое время на то, чтобы разобраться, понять и, главное, принять это множество вариантов работы атрибутов. На первый взгляд оно показалось слегка избыточным и не особенно логичным, но, учитывая, что в ежедневном программировании это редко пригодиться, приятно иметь в своём арсенале такие мощные инструменты.
Надеюсь, и вам эта статья прояснила парочку моментов, до которых руки не доходили разобраться. И теперь, с огнём в глазах и уверенностью в Точке, вы напишите огромное количество наичистейшего, читаемого и устойчивого к изменениям требований кода! Ну или комментарий.

Спасибо за ваше время.

Ссылки

  1. Shalabh Chaturvedi. Python Attributes and Methods
  2. Guido Van Rossum. The Inside Story on New-Style Classes
  3. Python documentation
UPD: Полезный линк от пользователя leron: Python Data Model
Tags:
Hubs:
+91
Comments 20
Comments Comments 20

Articles