Магические методы __setattr__, __getattribute__, __getattr__ и __delattr__

Курс по Python ООП: https://stepik.org/a/116336

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

class Point:
    MAX_COORD = 100
    MIN_COORD = 0
 
    def __init__(self, x, y):
        self.x = x
        self.y = y
 
    def set_coord(self, x, y):
        self.x = x
        self.y = y

то здесь мы видим определение четырех атрибутов: двух свойств MAX_COORD и MIN_COORD и двух методов __init__ и set_coord. Это атрибуты класса и при создании экземпляров:

pt1 = Point(1, 2)
pt2 = Point(10, 20)

Эти атрибуты остаются в пространстве имен класса, не копируются в экземпляры. Но из экземпляров мы можем совершенно спокойно к ним обращаться, так как пространство имен объектов содержит ссылку на внешнее пространство имен класса. Если какой-либо атрибут не существует в экземпляре, то поиск переходит во внешнее пространство, то есть, в класс и поиск продолжается там. Поэтому мы совершенно спокойно можем через экземпляр обратиться к свойству класса MAX_COORD:

print(pt1.MAX_COORD)

И получается, что атрибуты и методы класса – это общие данные для всех его экземпляров.

Далее, когда мы обращаемся к атрибутам класса внутри методов, объявленных в этом классе, то должны не просто прописать их имена:

    def set_coord(self, x, y):
        if MIN_COORD <= x <= MAX_COORD:
            self.x = x
            self.y = y

а явно указать перед ними ссылку на класс, то есть, на пространство имен. Либо так:

if Point.MIN_COORD <= x <= Point.MAX_COORD:

но лучше через self:

if self.MIN_COORD <= x <= self.MAX_COORD:

Здесь self – это ссылка на экземпляр класса, из которого метод вызывается, поэтому мы можем через этот параметр обращаться к атрибутам класса.

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

    def set_bound(self, left):
        self.MIN_COORD = left

Иногда ошибочно здесь рассуждают так. Мы обращаемся к атрибуту класса MIN_COORD и присваиваем ему новое значение left. Те из вас, кто внимательно смотрел предыдущие занятия, понимают, в чем ошибочность такого рассуждения. Да, когда мы через self (ссылку на объект) записываем имя атрибута и присваиваем ему какое-либо значение, то оператор присваивания создает этот атрибут в локальной области видимости, то есть, в самом объекте. В результате, у нас появляется новое локальное свойство в экземпляре класса:

pt1.set_bound(-100)
print(pt1.__dict__)

А в самом классе одноименный атрибут остается без изменений:

print(Point.__dict__)

Поэтому, правильнее было бы здесь объявить метод уровня класса и через него менять значения атрибутов MIN_COORD и MAX_COORD:

    @classmethod
    def set_bound(cls, left):
        cls.MIN_COORD = left

Тогда в самом объекте не будет создаваться никаких дополнительных свойств, а в классе изменится значение переменной MIN_COORD, так, как мы этого и хотели.

Будем полагать, что мы теперь с вами хорошо понимаем, как обращаться к атрибутам класса и его объектов. Во второй части занятия я хочу вам рассказать о четырех магических методах, которые используются при работе с атрибутами:

  • __setattr__(self, key, value)__ – автоматически вызывается при изменении свойства key класса;
  • __getattribute__(self, item) – автоматически вызывается при получении свойства класса с именем item;
  • __getattr__(self, item) – автоматически вызывается при получении несуществующего свойства item класса;
  • __delattr__(self, item) – автоматически вызывается при удалении свойства item (не важно: существует оно или нет).

Работают они достаточно просто. Начнем с метода __getattribute__ и с его помощью ограничим доступ к приватным свойствам __x и __y экземпляра. Для простоты я переопределю класс Point, следующим образом:

class Point:
    MAX_COORD = 100
    MIN_COORD = 0
 
    def __init__(self, x, y):
        self.__x = x
        self.__y = y
 
    def __getattribute__(self, item):
        print("__getattribute__")
        return object.__getattribute__(self, item)

Здесь добавлен новый магический метод __getattribute__. Он автоматически вызывается, когда идет считывание атрибута через экземпляр класса. Например, при обращении к свойству MIN_COORD:

print(pt1.MIN_COORD)

Или к приватному свойству через специальное имя:

print(pt1._Point__x)

Но раз это так, то давайте явно запретим считывать такой атрибут из экземпляра класса. Для этого пропишем в методе __getattribute__ проверку:

    def __getattribute__(self, item):
        if item == "_Point__x":
            raise ValueError("Private attribute")
        else:
            return object.__getattribute__(self, item)

То есть, мы смотрим, если идет обращение к приватному атрибуту по внешнему имени _Point__x, то генерируем исключение ValueError. И, действительно, после запуска программы видим отображение этой ошибки в консоли. Вот так, через магический метод __getattribute__ можно реализовывать определенную логику при обращении к атрибутам через экземпляр класса.

Следующий магический метод __setattr__ автоматически вызывается в момент присваивания атрибуту нового значения. Пропишем формально этот метод в классе Point:

    def __setattr__(self, key, value):
        print("__setattr__")
        object.__setattr__(self, key, value)

После запуска видим несколько сообщений «__setattr__». Это связано с тем, что в момент создания экземпляров класса в инициализаторе __init__ создавались локальные свойства __x и __y. В этот момент вызывался данный метод. Также в переопределенном методе __setattr__ мы должны вызывать соответствующий метод из базового класса object, иначе, локальные свойства в экземплярах создаваться не будут.

Давайте теперь для примера через этот магический метод запретим создание локального свойства с именем z. Сделаем это следующим образом:

    def __setattr__(self, key, value):
        if key == 'z':
            raise AttributeError("недопустимое имя атрибута")
        else:
            object.__setattr__(self, key, value)

Обратите внимание, что внутри метода __setattr__ нельзя менять свойства напрямую:

    def __setattr__(self, key, value):
        if key == 'z':
            raise AttributeError("недопустимое имя атрибута")
        else:
            self.__x = value

В этом случае метод __setattr__ начнет выполняться по рекурсии, пока не возникнет ошибка достижения максимальной глубины рекурсии. Если нужно сделать что-то подобное, то используйте коллекцию __dict__:

self.__dict__[key] = value

или, если требуется стандартное поведение метода, то вызывайте его из класса object, как это мы прописывали вначале:

object.__setattr__(self, key, value)

Следующий магический метод __getattr__ автоматически вызывается, если идет обращение к несуществующему атрибуту. Добавим его в наш класс:

    def __getattr__(self, item):
        print("__getattr__: " + item)

Если ниже обратиться к несуществующему свойству, например:

print(pt1.a)

то увидим сообщение «__getattr__: a» и значение None, которое вернул данный метод. Если же прописать существующий атрибут:

print(pt1.MAX_COORD)

то этот магический метод уже не вызывается. Зачем он может понадобиться? Например, нам необходимо определить класс, в котором при обращении к несуществующим атрибутам возвращается значение False, а не генерируется исключение. Для этого записывается метод __getattr__ в виде:

    def __getattr__(self, item):
        return False

Наконец, последний магический метод __delattr__ вызывается в момент удаления какого-либо атрибута из экземпляра класса:

    def __delattr__(self, item):
        print("__delattr__: "+item)

Добавим новое локальное свойство в экземпляр pt1:

pt1.a = 10

затем выполним команду его удаления:

del pt1.a

и видим, что действительно был вызван метод __delattr__, правда, сам атрибут удален не был:

print(pt1.__dict__)

Это из-за того, что внутри этого метода нужно вызвать соответствующий метод класса object, который и выполняет непосредственное удаление:

    def __delattr__(self, item):
        object.__delattr__(self, item)

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

Курс по Python ООП: https://stepik.org/a/116336

Видео по теме