Skip to content

Latest commit

 

History

History
429 lines (287 loc) · 37.4 KB

File metadata and controls

429 lines (287 loc) · 37.4 KB

Урок 8. От свойства property() к декоратору @property

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

class Person:
    def __init__(self, name, old):
        self.__name = name
        self.__old = old

    def get_old(self):
        return self.__old

    def set_old(self, old):
        self.__old = old
p = Person("Igor", 20)
p.set_old(35)
print(p.get_old())

С точки зрения контроля данных здесь всё в порядке. Но с точки зрения того, кто пользуется классом Person снаружи, работа выглядит громоздко: чтобы прочитать возраст, нужно вызвать get_old(), а не просто написать p.old, как это было принято во всех предыдущих уроках для публичных атрибутов. Приходится помнить точные имена методов, синтаксис обращения к данным отличается от привычного обращения к атрибуту, а сам код, использующий класс, оказывается перегружен скобками там, где интуитивно ожидается простое присваивание. Возникает вопрос, который и определяет содержание этого урока: можно ли сохранить весь контроль над данными, которого позволяет добиться инкапсуляция, но при этом снаружи работать с этими данными так же просто, как с обычным открытым атрибутом? Ответ — да, и инструмент для этого называется свойством (property).

Функция property()

Начнём с той формы, в которой механизм свойств появился в Python исторически — со встроенной функции property. Возьмём класс Person с уже существующими геттером и сеттером и добавим к нему одну строку:

class Person:
    def __init__(self, name, old):
        self.__name = name
        self.__old = old

    def get_old(self):
        return self.__old

    def set_old(self, old):
        self.__old = old

    old = property(get_old, set_old)

Строка old = property(get_old, set_old) создаёт в пространстве имён класса особый объект — свойство. Ему передаются две ранее объявленные функции: первая, get_old, будет вызываться при чтении атрибута old, вторая, set_old, — при его записи. С этого момента взаимодействие с возрастом снаружи класса меняется полностью:

p = Person("Сергей", 20)

p.old = 35
print(p.old)   # 35

Никаких вызовов методов, скобок и явных имён вроде get_old — обращение выглядит в точности так же, как обращение к обычному публичному атрибуту. Но за этим внешне простым обращением стоит вполне определённый механизм: строка p.old не читает никакого реального атрибута old напрямую — она незаметно вызывает get_old(), а строка p.old = 35 незаметно вызывает set_old(35). Свойство выступает посредником между привычным синтаксисом доступа к атрибуту и реальным вызовом методов, которые продолжают делать всю содержательную работу — включая, если она там есть, проверку данных.

Почему присваивание не создаёт новый атрибут объекта

Здесь стоит остановиться и задать вопрос, напрямую связанный с материалом второго урока. Мы уже разбирали, что присваивание через объект (объект.имя = значение) обычно создаёт (или изменяет) атрибут именно в пространстве имён этого объекта. Почему же в случае с p.old = 35 этого не происходит, а вместо этого вызывается set_old?

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

p.__dict__['old'] = "что-то"
print(p.old)   # всё равно вызовет property и вернёт значение из __old

Хотя p.__dict__ теперь содержит ключ 'old' с произвольным значением, обращение p.old по-прежнему обрабатывается свойством, объявленным в классе, а не тем, что лежит в словаре самого объекта. Свойство относится к особой категории атрибутов класса — так называемым дескрипторам данных, — которые обладают приоритетом над содержимым __dict__ объекта при любом обращении через точку. Подробно природа дескрипторов будет разобрана в одном из следующих уроков; сейчас достаточно запомнить практическое следствие: если в классе объявлено свойство old, никакой отдельный, независимый атрибут old в конкретном объекте существовать не может — все обращения безусловно проходят через свойство.

Проблема дублирования интерфейса

Подход через property(get_old, set_old) работает, но оставляет неприятный побочный эффект: в классе продолжают существовать полноценные, вызываемые напрямую методы get_old и set_old, наряду со свойством old. Получается, что для одной и той же операции — чтения или изменения возраста — существует сразу два независимых интерфейса: p.get_old() и p.old. Это не запрещено синтаксически, но на практике считается нежелательным: наличие двух равноправных способов сделать одно и то же сбивает с толку тех, кто читает и использует класс, и увеличивает риск того, что часть кода начнёт использовать один интерфейс, а часть — другой, без какой-либо логики в этом выборе.

Декоратор @property

Python предлагает более аккуратный способ добиться того же результата, не создавая отдельных, независимо вызываемых методов-геттеров и методов-сеттеров, — декоратор @property.

class Person:
    def __init__(self, name, old):
        self.__name = name
        self.__old = old

    @property
    def old(self):
        return self.__old
p = Person("Igor", 20)
print(p.old)   # 20

Декоратор @property, применённый к методу old, превращает его в свойство только для чтения. Метод объявлен с обычным именем old (а не get_old), но при обращении p.old он вызывается автоматически — точно так же, как это происходило через property(get_old, ...), только теперь без необходимости заводить отдельный метод с другим именем и без строки, явно создающей объект property.

Если попытаться записать значение в такое свойство, Python сообщит об ошибке:

p.old = 30
AttributeError: property 'old' of 'Person' object has no setter

Причина ошибки прямая: свойство, созданное только декоратором @property, поддерживает исключительно чтение — сеттер для него ещё не определён.

Добавление сеттера

Чтобы свойство поддерживало запись, к нему добавляют ещё один метод с тем же именем, но с декоратором @old.setter:

class Person:
    def __init__(self, name, old):
        self.__name = name
        self.__old = old

    @property
    def old(self):
        return self.__old

    @old.setter
    def old(self, value):
        self.__old = value
p = Person("Igor", 20)
p.old = 30
print(p.old)   # 30

Здесь важно обратить внимание на конструкцию @old.setter. Она возможна только потому, что имя old уже существует к этому моменту в теле класса — оно было создано строкой выше декоратором @property. old.setter — это метод самого объекта property, созданного @property, который возвращает новый объект property, дополненный сеттером, и результат вновь присваивается имени old, обновляя (переопределяя) его. Именно поэтому оба метода — и геттер, и сеттер — обязаны называться одинаково: old.setter берёт своё имя от уже существующего свойства old, к которому применяется, и второе определение функции old замещает предыдущее значение этого имени в пространстве имён класса, но уже с добавленным сеттером. Если бы сеттер был назван иначе, например set_old_value, конструкция @old.setter попросту не имела бы смысловой связи с этим методом — декоратор ожидает, что декорируемая функция логически продолжает то же самое свойство, и связывает их именно через совпадение имени.

Добавление делитера

Свойство поддерживает и третье действие — удаление, через декоратор @old.deleter:

class Person:
    def __init__(self, name, old):
        self.__name = name
        self.__old = old

    @property
    def old(self):
        return self.__old

    @old.setter
    def old(self, value):
        self.__old = value

    @old.deleter
    def old(self):
        del self.__old
del p.old

Строка del p.old вызывает метод, помеченный @old.deleter, а не пытается удалить какой-то самостоятельный атрибут old объекта — свойство перехватывает и операцию удаления точно так же, как перехватывает чтение и запись.

Таким образом, полный цикл работы свойства складывается из трёх этапов: @property определяет поведение при чтении, @имя.setter — поведение при записи, @имя.deleter — поведение при удалении. Каждый из трёх декораторов необязателен по отдельности: свойство может быть только для чтения (одно лишь @property), может поддерживать чтение и запись без удаления (что встречается чаще всего), а полный цикл со всеми тремя операциями используется относительно редко.

Когда какой набор декораторов уместен

Свойство только с геттером применяют там, где значение принципиально не должно изменяться извне класса напрямую — например, если оно вычисляется на основе других данных объекта, а не хранится как самостоятельное значение. Классический пример — площадь фигуры, вычисляемая из радиуса, или производная величина, которая всегда должна оставаться согласованной с исходными данными.

Сочетание геттера и сеттера — самый частый сценарий на практике. Он применяется всякий раз, когда значение действительно должно быть изменяемым, но изменение обязано проходить через проверку — то есть именно тогда, когда до этого урока применялась классическая пара «геттер + сеттер», только теперь оформленная так, чтобы синтаксис обращения снаружи не отличался от обращения к обычному атрибуту.

Полный набор из геттера, сеттера и делитера используется реже всего — там, где само удаление значения представляет собой осмысленную операцию, а не просто техническую деталь. Пример — временные или кешированные данные, которые в какой-то момент жизни объекта должны быть явно сброшены, освобождая память или заставляя объект заново вычислить значение при следующем обращении.

В чём принципиальная разница между property и обычным атрибутом

На первый взгляд может показаться, что p.old = 30 для свойства и p.x = 30 для обычного публичного атрибута — это одно и то же с точки зрения того, кто пишет этот код снаружи класса. Разница, однако, принципиальна, и стоит явно её сформулировать.

Во-первых, свойство даёт контроль доступа. За синтаксисом обычного присваивания скрывается вызов сеттера, а значит, и вся логика проверки, которую сеттер может выполнить, — прямой атрибут такой возможности не даёт вообще:

class Person:
    def __init__(self, age):
        self._age = age

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if value < 0:
            raise ValueError("Возраст не может быть отрицательным")
        self._age = value


p = Person(25)
print(p.age)   # 25
p.age = -5     # ValueError: Возраст не может быть отрицательным

Во-вторых, свойство позволяет изменить внутреннюю реализацию класса, никак не затрагивая интерфейс, которым пользуется внешний код. Рассмотрим класс, где одно из «свойств» на самом деле не хранится, а вычисляется заново при каждом обращении:

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value <= 0:
            raise ValueError("Радиус должен быть положительным")
        self._radius = value

    @property
    def area(self):
        return 3.14 * self._radius ** 2


c = Circle(5)
print(c.area)   # 78.5

c.radius = 10
print(c.area)   # 314.0

Для того, кто использует класс Circle, c.area выглядит совершенно так же, как если бы площадь была обычным, заранее сохранённым атрибутом. Но на самом деле она нигде не хранится — метод area, декорированный @property, вычисляет её заново при каждом обращении, беря за основу текущее значение self._radius. Если бы площадь была реализована как обычный сохранённый атрибут self.area, то после изменения c.radius = 10 пришлось бы отдельно вспоминать о необходимости пересчитать и переприсвоить self.area — иначе оно осталось бы устаревшим, рассчитанным по старому радиусу. Свойство избавляет от этого риска полностью: раз area не хранится, а вычисляется, оно физически не может «отстать» от radius и оказаться рассогласованным с ним.

Именно в этом и заключается главная идея, ради которой стоит использовать property: он объединяет привычное, простое обращение к атрибуту с полным контролем над тем, что на самом деле происходит при чтении, записи или удалении — контролем, которым публичный атрибут в принципе обладать не может.

Метод __init__ тоже вызывает сеттер

Есть один момент, который легко упустить из виду, но который прямо вытекает из всего, что было разобрано выше, — и заслуживает того, чтобы быть проговорённым отдельно и явно.

Возьмём предельно простой класс:

class Person:
    def __init__(self, name):
        self.name = name

    @property
    def name(self):
        return self.__name

    @name.setter
    def name(self, value):
        if not isinstance(value, str) or len(value) == 0:
            raise ValueError("Имя должно быть непустой строкой")
        print("Вызван сеттер name")
        self.__name = value

Создадим объект:

p = Person("Игорь")
Вызван сеттер name

Строка "Вызван сеттер name" печатается сразу же, ещё до того, как программист успел хоть раз явно обратиться к p.name снаружи класса. Это не случайность и не побочный эффект — это прямое следствие того же самого правила, которое уже разбиралось в этом уроке: property перехватывает любое присваивание атрибуту с соответствующим именем, а строка self.name = name внутри __init__ — это ровно такое присваивание, ничем принципиально не отличающееся от p.name = "Игорь", написанного где-то вне класса.

Здесь важно не запутаться в двух похожих именах. __init__ получает параметр name (обычный локальный параметр функции) и присваивает его значение атрибуту self.name. Но self.name — это не «сырой» атрибут объекта: класс Person объявляет name как свойство, а значит, любое обращение вида self.name = ..., в каком бы месте класса оно ни находилось — хоть в __init__, хоть в любом другом методе, — Python обрабатывает точно так же, как обращение p.name = ... снаружи: он ищет в классе свойство с этим именем и, найдя его, вызывает name.setter, а не создаёт независимую запись name в self.__dict__. То, что присваивание произошло из «внутреннего» кода класса, а не из внешнего кода, никакой роли не играет — self внутри __init__ — это тот же самый объект, что и p снаружи, и правило приоритета свойства над __dict__ объекта, разобранное выше, действует для него совершенно одинаково.

Из этого вытекает практический вывод, который стоит закрепить как осознанную привычку: если в классе объявлено свойство с сеттером, содержащим проверку, то __init__ должен присваивать значение именно через это свойство (self.name = name), а не напрямую через приватный атрибут (self.__name = name). Во втором случае конструктор в обход сеттера напрямую запишет значение в self.__dict__['_Person__name'], и вся проверка, ради которой сеттер был написан, окажется бесполезной именно в тот момент, когда объект создаётся, — то есть ровно там, где потенциально некорректные данные, пришедшие откуда-то извне (например, от пользователя), впервые попадают в объект. Если же конструктор присваивает через self.name = name, проверка автоматически покрывает и создание объекта, и любое последующее изменение — одним и тем же кодом, без необходимости дублировать логику отдельно для __init__.

Это соображение — не просто удобство, а прямое продолжение той задачи, ради которой в принципе существует инкапсуляция: гарантировать, что объект не может оказаться в состоянии, нарушающем собственную логику.

Подводные камни

Первая распространённая ошибка — присваивать сеттеру и связанному с ним геттеру разные имена. Как было показано выше, @old.setter работает только потому, что декоратор применяется к объекту property, уже существующему под именем old; если геттер называется old, а метод, декорированный @old.setter, назвать, скажем, set_old, то формально это создаст в классе два разных имени — old (только для чтения, без сеттера) и set_old (обычный, никак не связанный с property метод) — и всё удобство синтаксиса, ради которого затевалась вся конструкция, будет потеряно.

Вторая ошибка — попытка присвоить значение свойству, у которого не объявлен сеттер, и удивление возникающей ошибке AttributeError. Такое поведение — не сбой, а осознанное решение: если класс предоставляет только @property без @setter, значит, автор класса намеренно сделал значение доступным исключительно для чтения, и попытка его изменить снаружи справедливо отклоняется.

Третья, менее очевидная ошибка — попытка обратиться к вычисляемому свойству как к обычному хранимому значению, полагая, что оно физически существует в __dict__ объекта. Как было показано на примере с p.__dict__['old'], содержимое словаря объекта не имеет значения, если в классе объявлено свойство с тем же именем, — обращение всё равно пройдёт через геттер (или сеттер) класса, а не через словарь объекта.

Итоги урока

Свойство (property) решает проблему, оставшуюся после классического подхода с раздельными геттерами и сеттерами: оно позволяет сохранить полный контроль над доступом к данным объекта, но при этом обращаться к ним снаружи класса так же просто, как к обычному атрибуту — без явного вызова методов и без дублирования интерфейса. Исторически это достигалось функцией property(геттер, сеттер, делитер), но идиоматический способ в современном Python — это декораторы @property, @имя.setter и @имя.deleter, применяемые к методам с одинаковым именем.

Свойство относится к особой категории атрибутов класса, обладающих приоритетом над содержимым словаря объекта, — если свойство объявлено, оно безусловно перехватывает и чтение, и запись, и удаление атрибута с тем же именем, независимо от того, что находится в __dict__ конкретного объекта. Это делает свойство мощным инструментом для двух практических задач: валидации данных при их изменении и сокрытия внутренней реализации класса — в том числе замены хранимого значения вычисляемым без каких-либо последствий для кода, который этим классом пользуется.

В следующем уроке мы разберём дескрипторы — более общий и низкоуровневый механизм, стоящий за работой property изнутри. Понимание дескрипторов объяснит, почему свойство обладает приоритетом над __dict__ объекта, и откроет возможность переиспользовать одну и ту же логику контроля доступа сразу для нескольких атрибутов и даже нескольких классов, не переписывая одинаковые геттеры и сеттеры заново для каждого из них.


Вопросы для проверки

  1. В чём заключалось главное практическое неудобство подхода с раздельными методами get_old() и set_old() из прошлого урока?
  2. Что физически происходит, когда выполняется p.old, если old объявлен как property?
  3. Почему p.old = 35 не создаёт независимый атрибут old в p.__dict__, хотя обычно присваивание через объект создаёт именно локальный атрибут объекта?
  4. Чем декоратор @property удобнее функции property(get_old, set_old) с точки зрения интерфейса класса?
  5. Почему сеттер, декорированный @old.setter, обязан называться так же, как метод, декорированный @property?
  6. В каком случае у свойства достаточно объявить только @property, без @setter?
  7. Чем принципиально отличается property от обычного публичного атрибута, если снаружи оба выглядят одинаково при обращении через точку?
  8. Почему свойство area, вычисляемое из self._radius, не может «отстать» от текущего значения радиуса, в отличие от отдельно хранимого атрибута self.area?
  9. Что произойдёт при попытке присвоить значение свойству, у которого объявлен только @property, но не объявлен @setter?
  10. Если в классе объявлено свойство с сеттером @name.setter, будет ли он вызываться в __init__ при создании аттрибута self.name = name?

Задачи

Задача 1.

Реализуйте класс User с приватным атрибутом __name и свойством name через @property/@name.setter. При установке значения проверяйте, что оно — непустая строка; иначе выбрасывайте ValueError.

Пример использования:

u = User("Igor")
print(u.name)

u.name = "Bob"
print(u.name)

# Ошибка
# u.name = ""

Задача 2.

Реализуйте класс Car, создаваемый без параметров, с приватным атрибутом __model и свойством model. При установке проверяйте, что значение — строка длиной от 2 до 100 символов включительно.

Пример использования:

car = Car()
car.model = "Toyota"
print(car.model)

# Ошибки
# car.model = 123
# car.model = "A"

Задача 3.

Реализуйте класс WindowDlg, создаваемый как WindowDlg(title, width, height), с приватными __title, __width, __height и методом show(), выводящим <заголовок>: <ширина>, <высота>. Реализуйте свойства width и height с проверкой, что значение — целое число от 0 до 10000 включительно; после успешного изменения любого из них автоматически вызывайте show(). При создании объекта show() вызывать не нужно.

Пример использования:

wnd = WindowDlg("Диалог", 100, 50)
wnd.width = 200    # Диалог: 200, 50
wnd.height = 300   # Диалог: 200, 300

wnd.width = -10    # ValueError: Ширина должна быть целым числом от 0 до 10000

Задача 4.

Реализуйте класс RadiusVector2D с гибким созданием (RadiusVector2D(), RadiusVector2D(x), RadiusVector2D(x, y)), приватными __x, __y, публичными константами MIN_COORD = -100 и MAX_COORD = 1024, свойствами x и y с проверкой диапазона и типа, а также статическим методом norm2(vector), возвращающим x*x + y*y.

Пример использования:

v0 = RadiusVector2D()
print(v0.x, v0.y)   # 0 0

v1 = RadiusVector2D(3)
print(v1.x, v1.y)   # 3 0

v = RadiusVector2D(3, 4)
print(RadiusVector2D.norm2(v))   # 25

v.x = 10
v.y = -5

v.x = 2000   # ValueError

Задача 5.

Реализуйте класс BankAccount, создаваемый как BankAccount(owner, balance), с приватными __owner, __balance. Свойство balance — только для чтения. Изменение баланса — исключительно через deposit(amount) и withdraw(amount) с проверками. Свойство owner — с геттером и сеттером, имя не короче двух символов. Статический метод transfer(from_acc, to_acc, amount) переводит средства между счетами.

Пример использования:

a1 = BankAccount("Ivan", 100)
a2 = BankAccount("Oleg", 50)

a1.deposit(50)
a1.withdraw(30)

BankAccount.transfer(a1, a2, 50)

print(a1.balance)   # 70
print(a2.balance)   # 100

a1.balance = 1000   # AttributeError: у balance нет сеттера

Задача 6.

Реализуйте класс Product с приватными __name (непустая строка) и __price (число больше 0), доступными через свойства name и price. Реализуйте класс Cart с приватным списком __products, методами add_product, remove_product, get_products(), свойством total_price (только для чтения) и статическим методом get_average_price(products).

Пример использования:

p1 = Product("Phone", 1000)
p2 = Product("Laptop", 2000)

cart = Cart()
cart.add_product(p1)
cart.add_product(p2)

print(cart.total_price)                              # 3000
print(Cart.get_average_price(cart.get_products()))   # 1500.0

Предыдущий урок | Следующий урок