С помощью функции property()
в Python можно создавать управляемые атрибуты в классах. Управляемые атрибуты полезны, когда необходимо изменить внутреннюю реализацию атрибута, не изменяя публичный API класса. Поддержание стабильного API помогает избежать ошибок в коде ваших пользователей, который зависит от вашего кода.
Свойства, вероятно, являются самым популярным способом создания управляемых атрибутов быстро и в наиболее «питоничном» стиле.
Из этого руководства вы узнаете, как:
Создавать управляемые атрибуты или свойства в классах.
Выполнять ленивую оценку атрибутов и создавать вычисляемые атрибуты.
Делать классы более «питоничными», используя свойства вместо методов getter и setter.
Создавать свойства только для чтения и с возможностью записи.
Создавать согласованные и обратно совместимые API для классов.
Мы также рассмотрим практические примеры, в которых property()
используется для валидации входных данных, динамического вычисления значений атрибутов, логирования и других целей. Получить максимальную пользу от этого руководства вам поможет знание основ ООП, классов и декораторов в Python.
Оглавление
Управление атрибутами в классах
Когда вы определяете класс в объектно-ориентированном языке программирования, в нём, вероятно, появятся как атрибуты экземпляра, так и атрибуты класса. Иными словами, у вас будут переменные, доступные либо через экземпляр, либо через сам класс, а в некоторых языках — через оба сразу. Атрибуты представляют и сохраняют внутреннее состояние конкретного объекта, к которому зачастую нужно иметь доступ и возможность его изменять.
Обычно есть как минимум два способа получить доступ и изменить значения атрибута: напрямую или через методы. Методы — это функции, прикреплённые к классу, которые предоставляют возможные действия и поведение, с помощью которых объект взаимодействует со своими внутренними данными и атрибутами.
Если вы делаете атрибуты доступными для пользователя, они становятся частью публичного API — то есть пользователи смогут обращаться к ним напрямую и изменять их значения в своём коде. Проблема возникает, когда вам необходимо изменить внутреннюю реализацию атрибута.
Допустим, вы работаете над классом Circle
и добавили в него атрибут .radius
, сделав его публичным. Вы завершаете разработку класса и открываете его для конечных пользователей. Они начинают использовать Circle
в своём коде для создания проектов и приложений. Отличная работа!
Теперь предположим, что к вам обратился важный пользователь с новым требованием: он больше не хочет, чтобы Circle
хранил радиус. Вместо этого ему нужен публичный атрибут .diameter
.
В этот момент, удалив .radius
и начав использовать .diameter
, вы можете нарушить работу кода других пользователей. Необходимо разозбраться с этой ситуацией, избежав удаления .radius
.
В таких языках программирования, как Java и C++, рекомендуется вообще не делать атрибуты доступными для внешнего использования, чтобы избежать подобных проблем. Вместо этого следует предоставлять методы доступа (accessors) и модификации (mutators), также известные как геттеры и сеттеры. Эти методы позволяют изменить внутреннюю реализацию атрибутов, не затрагивая публичный API.
Примечание:
Использование методов геттер и сеттер часто рассматривается как антипаттерн и свидетельство слабого объектно-ориентированного проектирования. Основной аргумент против их использования заключается в том, что они нарушают инкапсуляцию, позволяя внешнему коду изменять и получать доступ к компонентам объектов.
Эти языки программирования требуют геттеров и сеттеров, потому что в случае возникновения новых требований у них нет подходящих механизмов для изменения внутренней реализации атрибута. Изменение внутренней реализации потребовало бы модификации API, что могло бы нарушить работу кода конечных пользователей.
Подход с геттерами и сеттерами в Python
С технической точки зрения, ничто не мешает вам использовать геттеры и сеттеры в Python. Вот краткий пример того, как выглядит такой подход:
class Point:
def __init__(self, x, y):
self._x = x
self._y = y
def get_x(self):
return self._x
def set_x(self, value):
self._x = value
def get_y(self):
return self._y
def set_y(self, value):
self._y = value
В этом примере вы создаёте класс Point
с двумя непубличными атрибутами _x
и _y
, чтобы хранить декартовы координаты данной точки.
Примечание:
В Python отсутствуют модификаторы доступа, такие как private
, protected
и public
, которые ограничивают доступ к атрибутам и методам. В Python различие проводится между публичными и непубличными элементами класса.
Чтобы указать, что атрибут или метод является непубличным, необходимо использовать распространённое соглашение Python — добавлять к имени атрибута или метода символ нижнего подчёркивания (_) в начале. Именно поэтому атрибуты называются _x
и _y
.
Отметим, что это всего лишь соглашение. Оно не мешает вам и другим разработчикам обращаться к атрибутам с помощью точечной нотации, как в obj._attr
. Однако нарушение этого соглашения считается плохой практикой.
Для доступа к значениям _x
или _y
, а также для их изменения можно использовать соответствующие геттер и сеттер методы. Сохраните приведённое выше определение класса Point
в модуле Python и импортируйте класс в интерактивной сессии. После этого выполните следующий код:
>>> from point_v1 import Point
>>> point = Point(12, 5)
>>> point.get_x()
12
>>> point.get_y()
5
>>> point.set_x(42)
>>> point.get_x()
42
>>> # Non-public attributes are still accessible
>>> point._x
42
>>> point._y
5
С помощью методов .get_x()
и .get_y()
можно получить текущие значения атрибутов ._x
и ._y
. Метод сеттер позволяет сохранить новое значение в соответствующем управляемом атрибуте. Из последних двух примеров можно понять, что Python не ограничивает доступ к непубличным атрибутам. Использовать их напрямую или нет — решать вам.
Питоничный подход
Хотя приведённый выше пример соответствует стилю кодирования Python, его нельзя назвать «питоничным». В этом примере геттеры и сеттеры не выполняют дополнительных операций с значениями ._x
и ._y
, так что вместо методов можно было бы использовать обычные атрибуты.
Класс Point можно переписать, сделав его более лаконичным и «питоничным»:
>>> class Point:
... def __init__(self, x, y):
... self.x = x
... self.y = y
...
>>> point = Point(12, 5)
>>> point.x
12
>>> point.y
5
>>> point.x = 42
>>> point.x
42
Этот код демонстрирует основной принцип Python: предоставление атрибутов пользователям напрямую — это нормально и вполне типично. Это здорово, так как вам не нужно загромождать свои классы геттер и сеттер методами.
Вопрос в том, как справляться с изменениями требований, которые подразумевают модификацию реализации атрибутов, не изменяя при этом API? Предположим, вам нужно добавить валидацию для атрибута. Как это сделать, если у вашего атрибута нет геттер и сеттер методов, где можно разместить эту функциональность?
В отличие от Java и C++, Python предоставляет удобные инструменты, позволяющие изменить реализацию атрибутов без изменений публичного API. Наиболее популярный подход — преобразовать атрибуты в свойства.
Примечание:
Другой распространённый способ работы с управляемыми атрибутами — это использование дескрипторов. В этом руководстве, однако, мы успеем рассмотреть только свойства.
Свойства представляют собой промежуточный функционал между обычным атрибутом (полем) и методом. Другими словами, они позволяют создавать методы, которые ведут себя как атрибуты. С помощью свойств можно изменить способ вычисления значения целевого атрибута в любое время.
Например, вы можете преобразовать атрибуты .x
и .y
в свойства. Благодаря этому они останутся доступными в качестве атрибутов, но смогут выполнять определённые действия при доступе к ним и их изменении.
Примечание:
Свойства не являются уникальной особенностью Python. Такие языки, как JavaScript, C# и Kotlin также предоставляют инструменты и методы для создания свойств в качестве членов классов.
Свойства Python позволяют включить атрибуты в публичный API ваших классов. Если вам когда-нибудь потребуется изменить внутреннюю реализацию атрибута, вы легко сможете преобразовать его в свойство. В следующих разделах вы узнаете, как создавать свойства в Python.
Начало работы с property() в Python
Использование функции property()
в Python — это «питоничный» способ избежать геттеров и сеттеров в ваших классах. Эта встроенная функция позволяет превращать атрибуты класса в свойства или управляемые атрибуты. Поскольку property()
— встроенная функция, её можно использовать без дополнительных импортов. К тому же, property()
реализована на C, что обеспечивает оптимизированную производительность.
Примечание:
property()
часто называют встроенной функцией. Однако property на самом деле является классом с названием, похожим на функцию, поэтому большинство разработчиков Python называют её функцией.
В этом руководстве мы будем следовать общепринятой практике, называя property()
функцией, а не классом. Однако в некоторых разделах вы увидите, что она названа классом для облегчения объяснений.
С помощью property()
можно привязать неявные геттеры и сеттеры к атрибутам класса. Также можно задать способ удаления атрибута и добавить подходящую строку документации (docstring) для своих свойств.
Полная сигнатура property()
:
property([fget=None, fset=None, fdel=None, doc=None])
Первые два аргумента принимают объекты функций, которые будут выполнять роль геттеров (fget) и сеттеров (fset). Python автоматически вызывает эти функции при доступе или изменении атрибута.
Вот краткое описание каждого аргумента:
Аргумент |
Описание |
fget |
Функция, которая возвращает значение управляемого атрибута |
fset |
Функция, которая позволяет установить значение управляемого атрибута |
fdel |
Функция, которая определяет, как управляемый атрибут будет удаляться |
doc |
Строка, представляющая строку документации (docstring) свойства |
Возвращаемое значение property()
— это сам управляемый атрибут. Если вы обращаетесь к управляемому атрибуту, как в obj.attr
, Python автоматически вызывает fget()
. Если вы присваиваете новое значение атрибуту, например obj.attr = value
, Python вызывает fset()
, используя входное value в качестве аргумента. Наконец, если вы используете оператор del obj.attr
, Python автоматически вызывает fdel()
.
Примечание:
Первые три аргумента property()
принимают объекты функций. Объект функции можно представить как имя функции без пары круглых скобок для вызова.
Аргумент doc
можно использовать для добавления соответствующей строки документации к свойствам. Вы и ваши коллеги сможете просмотреть этот докстринг, используя функцию help()
Python. Аргумент doc
также полезен при работе с редакторами кода и IDE, поддерживающими доступ к строкам документации.
Вы можете использовать property()
как функцию или как декоратор для создания своих свойств. В следующих двух разделах вы узнаете, как использовать оба подхода. Однако в сообществе Python более популярен подход с декоратором.
Создание атрибутов с использованием property()
Вы можете создать свойство, вызвав функцию property()
с нужным набором аргументов и присвоив её возвращаемое значение атрибуту класса. Все аргументы для property()
являются необязательными, но обычно указывается хотя бы функция-геттер.
В следующем примере показано, как создать класс Circle
со свойством, которое управляет значением радиуса:
class Circle:
def __init__(self, radius):
self._radius = radius
def _get_radius(self):
print("Get radius")
return self._radius
def _set_radius(self, value):
print("Set radius")
self._radius = value
def _del_radius(self):
print("Delete radius")
del self._radius
radius = property(
fget=_get_radius,
fset=_set_radius,
fdel=_del_radius,
doc="The radius property."
)
В этом коде создаётся класс Circle
. Инициализатор класса .__init__()
принимает radius
в качестве аргумента и сохраняет его в непубличном атрибуте ._radius
. Затем определяются три непубличных метода:
._get_radius()
возвращает текущее значение._radius
.._set_radius()
принимает значение value в качестве аргумента и присваивает его._radius
.._del_radius()
удаляет атрибут экземпляра._radius
.
После создания этих трёх методов вы создаёте атрибут класса radius
для хранения объекта свойства. Чтобы инициализировать свойство, передайте эти три метода в качестве аргументов функции property()
. Также укажите подходящую строку документации для вашего свойства.
В этом примере используются именованные аргументы, чтобы улучшить читаемость и избежать путаницы. Это позволяет точно знать, какой метод назначен каждому аргументу.
Чтобы попробовать класс Circle
в действии, выполните следующий код в интерактивной оболочке REPL:
>>> from circle_v1 import Circle
>>> circle = Circle(42.0)
>>> circle.radius
Get radius
42.0
>>> circle.radius = 100.0
Set radius
>>> circle.radius
Get radius
100.0
>>> del circle.radius
Delete radius
>>> circle.radius
Get radius
Traceback (most recent call last):
...
AttributeError: 'Circle' object has no attribute '_radius'
>>> help(circle)
Help on Circle in module __main__ object:
class Circle(builtins.object)
...
| radius
| The radius property.
Свойство .radius
скрывает непубличный атрибут экземпляра ._radius
, который в этом примере становится управляемым атрибутом. Вы можете напрямую получить доступ к .radius
и присвоить ему значение. Внутренне Python автоматически вызывает ._get_radius()
и ._set_radius()
по мере необходимости. Когда выполняется del circle.radius
, Python вызывает ._del_radius()
, который удаляет базовый атрибут ._radius
.
Использование лямбда-функций в качестве геттер-методов
Свойства — это атрибуты класса, которые управляют атрибутами экземпляра. Свойство можно представить как набор связанных методов. Если вы внимательно посмотрите на .radius
, то обнаружите методы, которые вы передали в качестве аргументов fget
, fset
и fdel
.
>>> Circle.radius.fget
<function Circle._get_radius at 0x7fba7e1d7d30>
>>> Circle.radius.fset
<function Circle._set_radius at 0x7fba7e1d78b0>
>>> Circle.radius.fdel
<function Circle._del_radius at 0x7fba7e1d7040>
>>> dir(Circle.radius)
[..., '__get__', ..., '__set__', ...]
Вы можете получить доступ к методам геттер, сеттер и удаления (deleter) в свойстве через соответствующие атрибуты .fget
, .fset
и .fdel
.
Свойства также являются дескрипторами. Если вы используете dir()
для проверки внутренних элементов свойства, то найдёте в списке методы .__set__()
и .__get__()
. Эти методы предоставляют стандартную реализацию протокола дескрипторов.
Примечание:
Если вы хотите лучше понять внутреннюю реализацию property
как класса, изучите описание в документации Python.
Например, стандартная реализация метода .__set__()
запускается, если вы не предоставили пользовательский метод сеттер. При попытке установить значение атрибута это приведёт к возникновению исключения AttributeError
.
Использование property() в качестве декоратора
Декораторы часто используются в Python. Обычно это функции, которые принимают другую функцию в качестве аргумента и возвращают новую функцию с добавленной функциональностью. С помощью декоратора можно подключить операции предобработки и постобработки к существующей функции.
Примечание:
В Python декораторы можно определять как с помощью функции, так и с помощью класса. Поэтому декораторы могут быть как на основе функций, так и на основе классов.
Синтаксис декоратора состоит в том, что имя функции-декоратора ставится с символом @
перед определением функции, которую вы хотите декорировать:
@decorator
def function():
...
В этом коде @decorator
может быть как функцией, так и классом, предназначенным для декорирования function()
. Этот синтаксис эквивалентен следующему:
def function():
...
function = decorator(function)
Последняя строка кода переназначает имя function
для хранения результата вызова decorator(function)
. Обратите внимание, что это тот же синтаксис, который использовался для создания свойства в предыдущем разделе.
В Python property()
может работать как декоратор, поэтому, чтобы быстро создавать свои свойства, можно использовать синтаксис @property
:
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
"""The radius property."""
print("Get radius")
return self._radius
@radius.setter
def radius(self, value):
print("Set radius")
self._radius = value
@radius.deleter
def radius(self):
print("Delete radius")
del self._radius
Теперь Circle стал более питоничным и лаконичным. Вам больше не нужно использовать такие имена методов, как ._get_radius()
, ._set_radius()
и ._del_radius()
. Теперь у вас есть три метода с одинаковым описательным именем, похожим на имя атрибута. Как это возможно?
Подход с декоратором для создания свойств требует определения первого метода с использованием публичного имени для управляемого атрибута, в данном примере — .radius
. Этот метод должен реализовать логику геттера. В примере выше строки с 5 по 9 реализуют этот метод.
Строки с 11 по 14 определяют метод сеттер для .radius
. Синтаксис отличается. Вместо повторного использования @property
используется @radius.setter
. Почему это необходимо? Посмотрите на результат dir()
:
>>> from circle_v2 import Circle
>>> dir(Circle.radius)
[..., 'deleter', ..., 'getter', 'setter']
Помимо .fget
, .fset
, .fdel
и ряда других специальных атрибутов и методов, property
также предоставляет .deleter()
, .getter()
и .setter()
. Эти три метода возвращают новое свойство.
Когда вы декорируете второй метод .radius()
с помощью @radius.setter
на строке 11, создаётся новое свойство и переназначается имя уровня класса .radius
из строки 6. Это новое свойство содержит тот же набор методов, что и начальное свойство на строке 6, с добавлением нового метода сеттер, указанного на строке 12. Наконец, синтаксис декоратора переназначает новое свойство имени класса .radius
.
Механизм определения метода удаления (deleter) аналогичен. В этот раз нужно использовать декоратор @radius.deleter
. В конце процесса вы получаете полноценное свойство с методами геттер, сеттер и удаления.
Теперь, как добавить подходящую строку документации к вашим свойствам при использовании подхода с декоратором? Если снова посмотреть на класс Circle
, вы заметите, что это уже сделано путём добавления строки документации к методу геттер на строке 7.
Новая реализация Circle
работает так же, как пример в предыдущем разделе:
>>> from circle_v2 import Circle
>>> circle = Circle(42.0)
>>> circle.radius
Get radius
42.0
>>> circle.radius = 100.0
Set radius
>>> circle.radius
Get radius
100.0
>>> del circle.radius
Delete radius
>>> circle.radius
Get radius
Traceback (most recent call last):
...
AttributeError: 'Circle' object has no attribute '_radius'
>>> help(circle)
Help on Circle in module __main__ object:
class Circle(builtins.object)
...
| radius
| The radius property.
Во-первых, обратите внимание, что для вызова .radius()
как метода не требуются скобки. Вместо этого можно обращаться к .radius
так же, как к обычному атрибуту. Это и есть основная цель свойств — возможность обращения к методам как к атрибутам.
Основные моменты, которые следует помнить при создании свойств с помощью декораторов:
Декоратор
@property
должен использоваться для метода геттер.Строка документации должна находиться в методе геттер.
Методы сеттер и удаления (deleter) должны быть декорированы именем геттер-метода плюс
.setter
и.deleter
, соответственно.
До этого момента вы узнали, как создавать управляемые атрибуты, используя property()
как функцию и декоратор. Теперь стоит подумать, когда следует использовать свойства.
Когда использовать свойства
Если вы посмотрите на реализацию класса Circle, то заметите, что геттер и сеттер не добавляют никакой дополнительной функциональности к атрибутам.
Говоря в общем, следует избегать использования свойств для атрибутов, которые не требуют дополнительной функциональности или обработки. Использование свойств в таком случае делает код:
излишне многословным;
запутанным для других разработчиков;
менее производительным по сравнению с кодом, основанным на обычных атрибутах.
Если вам не требуется ничего, кроме простого доступа к атрибуту и его изменения, не используйте свойства. Они будут тратить процессорное время и, что важнее, ваше время.
Наконец, не рекомендуется писать явный геттер и сеттер и затем оборачивать их в property. Вместо этого используйте декоратор @property.
Это сейчас наиболее «питоничный» подход.
Создание атрибутов только для чтения
Пожалуй, самый простой и распространённый случай использования property()
— создание атрибутов только для чтения. Допустим, вам нужен неизменяемый класс Point
, который не позволяет пользователю изменять исходные значения его координат x и y. Для этого можно создать Point
, как показано в следующем примере:
class Point:
def __init__(self, x, y):
self._x = x
self._y = y
@property
def x(self):
return self._x
@property
def y(self):
return self._y
Здесь входные аргументы сохраняются в атрибутах ._x
и ._y
. Как вы уже узнали, добавление нижнего подчёркивания (_) в начале имени сообщает другим разработчикам, что это непубличные атрибуты и не должны использоваться напрямую, например point._x
. Наконец, определяются два геттер-метода, декорированные @property
.
Теперь у вас есть два свойства только для чтения: .x
и .y
в качестве координат:
>>> from point_v2 import Point
>>> point = Point(12, 5)
>>> # Read coordinates
>>> point.x
12
>>> point.y
5
>>> point.x = 42
Traceback (most recent call last):
...
AttributeError: can't set attribute
Здесь .x
и .y
являются свойствами только для чтения, так как вы не можете присвоить им новые значения. Их поведение зависит от встроенного дескриптора, который предоставляет property
. Стандартная реализация .__set__()
этого дескриптора вызывает AttributeError
, если вы не определили сеттер-метод.
Если вам требуется особое поведение для свойства только для чтения, вы можете предоставить явный метод сеттер, который выбрасывает исключение с более подробным и конкретным сообщением:
class WriteCoordinateError(Exception):
pass
class Point:
def __init__(self, x, y):
self._x = x
self._y = y
@property
def x(self):
return self._x
@x.setter
def x(self, value):
raise WriteCoordinateError("x coordinate is read-only")
@property
def y(self):
return self._y
@y.setter
def y(self, value):
raise WriteCoordinateError("y coordinate is read-only")
В этом примере вы определяете собственное исключение WriteCoordinateError
. Это исключение позволяет настроить реализацию вашего неизменяемого класса Point
. Теперь оба сеттер-метода выбрасывают исключение с более понятным сообщением. Попробуйте этот улучшенный Point!
Создание атрибутов с возможностью чтения и записи
С помощью property()
можно создавать управляемые атрибуты с возможностью чтения и записи. Для этого достаточно реализовать методы геттер (чтение) и сеттер (запись) для создания управляемых атрибутов.
Допустим, вы хотите, чтобы в классе Circle
появился атрибут .diameter
. Передавать и радиус, и диаметр в инициализаторе класса кажется избыточным, поскольку одно значение можно вычислить через другое.
Вот реализация Circle
, в которой .radius и .diameter являются атрибутами с возможностью чтения и записи, но при создании экземпляра класса указывается только радиус:
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
self._radius = float(value)
@property
def diameter(self):
return self.radius * 2
@diameter.setter
def diameter(self, value):
self.radius = value / 2
Здесь вы создаёте класс Circle
со свойством с возможностью чтения и записи .radius
. Геттер просто возвращает значение радиуса. Сеттер преобразует радиус в число с плавающей точкой и присваивает его непубличному атрибуту ._radius
, который хранит окончательные данные.
В этой реализации класса Circle есть тонкий момент, на который стоит обратить внимание. В данном случае инициализатор класса присваивает входное значение свойству .radius
напрямую, вместо того чтобы сохранять его в отдельном непубличном атрибуте, таком как ._radius
. Почему так? Потому что нужно убедиться, что каждое значение радиуса, включая начальное, проходит через сеттер и преобразуется в число с плавающей точкой.
Класс Circle
также включает атрибут .diameter
в виде свойства. Геттер вычисляет диаметр, используя радиус. Сеттер рассчитывает радиус и сохраняет результат в .radius
, вместо того чтобы хранить введённое значение диаметра в отдельном атрибуте. Такой подход делает класс более эффективным с точки зрения памяти, поскольку хранится только радиус.
Вот как работает класс Circle
:
>>> from circle_v3 import Circle
>>> circle = Circle(42)
>>> circle.radius
42.0
>>> circle.diameter
84.0
>>> circle.diameter = 100
>>> circle.diameter
100.0
>>> circle.radius
50.0
В этом примере .radius
и .diameter
работают как обычные атрибуты, предоставляя чистый и «питоничный» публичный API для класса Circle
.
Создание атрибутов только для записи
Вы также можете создать атрибуты только для записи, изменив геттер у свойств. Например, можно сделать так, чтобы геттер выбрасывал исключение каждый раз, когда пользователь пытается получить доступ к атрибуту.
Вот гипотетический пример обработки паролей с использованием свойства только для записи:
import hashlib
import os
class User:
def __init__(self, name, password):
self.name = name
self.password = password
@property
def password(self):
raise AttributeError("Password is write-only")
@password.setter
def password(self, plaintext):
salt = os.urandom(32)
self._hashed_password = hashlib.pbkdf2_hmac(
"sha256", plaintext.encode("utf-8"), salt, 100_000
)
Инициализатор User
принимает имя пользователя и пароль в качестве аргументов и сохраняет их в .name
и .password
соответственно.
Примечание:
Пример выше приводится в учебных целях. Он не является рецептом для безопасной работы с паролями.
Свойство password
управляет тем, как класс обрабатывает введённый пароль. Геттер выбрасывает AttributeError
каждый раз, когда пользователь пытается получить текущий пароль. Это превращает .password
в атрибут «только для записи»:
>>> from users import User
>>> john = User("John", "secret")
>>> john._hashed_password
b'b\xc7^ai\x9f3\xd2g ... \x89^-\x92\xbe\xe6'
>>> john.password
Traceback (most recent call last):
...
AttributeError: Password is write-only
>>> john.password = "supersecret"
>>> john._hashed_password
b'\xe9l$\x9f\xaf\x9d ... b\xe8\xc8\xfcaU\r_'
В этом примере вы создаёте john
в качестве экземпляра User
с начальным паролем. Сеттер хеширует пароль и сохраняет его в ._hashed_password
. Обратите внимание, что если вы попытаетесь напрямую получить доступ к .password
, возникнет AttributeError
. Наконец, присвоение нового значения .password
вызывает сеттер и создаёт новый хешированный пароль.
В сеттере для .password
используется os.urandom()
для генерации 32-байтовой случайной строки в качестве соли для хеш-функции. Для генерации хешированного пароля используется hashlib.pbkdf2_hmac()
. Затем результат сохраняется в непубличном атрибуте ._hashed_password
. Это гарантирует, что текстовый пароль не будет сохранён в каком-либо доступном виде.
Применение property() в Python на практике
На данный момент вы узнали, как использовать property()
в Python для создания управляемых атрибутов в классах. Мы применяли property()
как функцию и как декоратор и изучили различия между этими подходами. Вы также узнали, как создавать атрибуты только для чтения, для чтения и записи, а также только для записи.
В следующих разделах рассмотрим примеры кода, которые помогут вам лучше понять практическое использование property()
.
Валидация входных значений
Валидация входных данных — один из наиболее распространённых случаев использования property()
и управляемых атрибутов. Валидация данных необходима в коде, который принимает данные от пользователей или из других источников, которым нельзя полностью доверять. property()
в Python предоставляет быстрый и надёжный инструмент для выполнения такой валидации.
Например, вернёмся к классу Point
. Возможно, вам потребуется, чтобы значения .x
и .y
были допустимыми числами. Поскольку пользователи могут вводить данные любого типа, необходимо убедиться, что points
принимают только числовые значения.
Вот реализация класса Point
, которая справляется с этим требованием:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
@property
def x(self):
return self._x
@x.setter
def x(self, value):
try:
self._x = float(value)
print("Validated!")
except ValueError:
raise ValueError('"x" must be a number') from None
@property
def y(self):
return self._y
@y.setter
def y(self, value):
try:
self._y = float(value)
print("Validated!")
except ValueError:
raise ValueError('"y" must be a number') from None
Сеттеры для .x
и .y
используют блоки try ... except
, которые валидируют входные данные с использованием стиля EAFP в Python. Если вызов float()
выполняется успешно, данные считаются допустимыми, и вы увидите на экране сообщение «Валидировано!». Если float()
вызывает ValueError
, пользователь получает ValueError
с более точным сообщением.
Примечание:
В приведённом выше примере используется синтаксис raise ... from None
, чтобы скрыть внутренние детали, связанные с контекстом, в котором вызывается исключение. С точки зрения конечного пользователя, эти детали могут сбить с толку и сделать класс менее аккуратным.
Чтобы узнать больше, посмотрите раздел об операторе raise в документации.
Важно отметить, что присвоение значений свойствам .x
и .y
напрямую в .__init__()
гарантирует, что валидация будет выполнена и при инициализации объекта. Если этого не делать, могут возникнуть проблемы при использовании property()
для валидации данных.
Вот как теперь работает класс Point
:
>>> from point_v4 import Point
>>> point = Point(12, 5)
Validated!
Validated!
>>> point.x
12.0
>>> point.y
5.0
>>> point.x = 42
Validated!
>>> point.x
42.0
>>> point.y = 100.0
Validated!
>>> point.y
100.0
>>> point.x = "one"
Traceback (most recent call last):
...
ValueError: "x" must be a number
>>> point.y = "1o"
Traceback (most recent call last):
...
ValueError: "y" must be a number
Если вы присваиваете .x
и .y
значения, которые float()
может преобразовать в числа с плавающей точкой, то валидация проходит успешно, и значение принимается. В противном случае возникает ValueError
.
Эта реализация класса Point
выявляет основную слабость property()
. Заметили? Именно так! У вас есть повторяющийся код, который следует определённым шаблонам. Это нарушение принципа DRY, и, чтобы этого избежать, следует провести рефакторинг кода. Для этого вы можете вынести повторяющуюся логику в дескриптор, который можно назвать Coordinate.
class Coordinate:
def __set_name__(self, owner, name):
self._name = name
def __get__(self, instance, owner):
return instance.__dict__[self._name]
def __set__(self, instance, value):
try:
instance.__dict__[self._name] = float(value)
print("Validated!")
except ValueError:
raise ValueError(f'"{self._name}" must be a number') from None
class Point:
x = Coordinate()
y = Coordinate()
def __init__(self, x, y):
self.x = x
self.y = y
Теперь код стал короче и гораздо менее повторяющимся. Мы определили Coordinate как дескриптор, чтобы управлять валидацией данных в одном месте. Затем мы создали .x
и .y
как атрибуты класса, хранящие экземпляры этого дескриптора. Код работает так же, как и его предыдущая реализация. Попробуйте его в деле!
В целом, если вы замечаете, что копируете и вставляете определения свойств по всему коду или видите повторяющийся код, как в примере выше, стоит подумать об использовании дескрипторов.
Создание вычисляемых атрибутов
Если вам нужен атрибут, который динамически формирует своё значение каждый раз при обращении к нему, то использование свойства может быть отличным выбором. Такие атрибуты называют вычисляемыми. Они удобны, когда вам нужно что-то вроде обычного атрибута, но с возможностью отложенного («ленивого») вычисления.
Главная причина создания «ленивых» атрибутов — возможность отложить их вычисление до момента, когда они понадобятся, что может сделать ваш код более эффективным.
Вот пример того, как использовать property()
для создания вычисляемого атрибута .area
в классе Rectangle
:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
return self.width * self.height
В этом примере инициализатор Rectangle
принимает width
и height
в качестве аргументов и сохраняет их в соответствующих атрибутах экземпляра. Свойство только для чтения .area
вычисляет и возвращает площадь текущего прямоугольника каждый раз при доступе.
Ещё один интересный пример использования свойств — это форматирование значения для определённого атрибута:
class Product:
def __init__(self, name, price):
self._name = name
self._price = float(price)
@property
def price(self):
return f"${self._price:,.2f}"
В этом примере .price
— это свойство, которое форматирует и возвращает цену конкретного продукта. Для форматирования в стиле валюты используется f-строка с подходящим спецификатором формата.
Примечание:
В этом примере используются числа с плавающей точкой для представления валюты, что является плохой практикой. Вместо этого следует использовать decimal.Decimal из стандартной библиотеки.
И, наконец, в качестве примера вычисляемых атрибутов, предположим, у вас есть класс Point
, который использует .x
и .y
в качестве декартовых координат. Вы хотите задать полярные координаты для вашей точки, чтобы использовать их в нескольких вычислениях. Полярная система координат представляет каждую точку с помощью расстояния до начала координат и угла с горизонтальной осью координат.
Вот класс Point
для декартовых координат, который также предоставляет вычисляемые полярные координаты:
import math
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
@property
def distance(self):
return math.dist((0, 0), (self.x, self.y))
@property
def angle(self):
return math.degrees(math.atan2(self.y, self.x))
def as_cartesian(self):
return self.x, self.y
def as_polar(self):
return self.distance, self.angle
В этом примере определены два свойства для вычисления полярных координат — distance и angle — объекта Point
, используя его декартовы координаты .x и .y. Также добавлены два метода экземпляра, которые возвращают декартовы и полярные координаты в виде кортежей.
Как этот класс работает на практике:
>>> from point_v6 import Point
>>> point = Point(12, 5)
>>> point.x
12
>>> point.y
5
>>> point.distance
13.0
>>> point.angle
22.619864948040426
>>> point.as_cartesian()
(12, 5)
>>> point.as_polar()
(13.0, 22.619864948040426)
Свойства — полезный инструмент для создания вычисляемых атрибутов. Однако, если вы часто используете какой-либо атрибут, его повторное вычисление может быть затратным и неэффективным. Чтобы избежать этих затрат, можно кэшировать вычисленное значение после завершения вычисления. Именно это мы и рассмотрим в следующем разделе.
Кэширование вычисляемых атрибутов
Иногда бывает, что вы часто используете вычисляемый атрибут. Постоянное выполнение одного и того же вычисления может быть излишним и затратным. Чтобы обойти эту проблему, можно кэшировать вычисленное значение для последующего использования.
Если у вас есть свойство, которое вычисляет значение на основе постоянных входных данных, то результат не изменится. В таком случае можно вычислить значение только один раз:
from time import sleep
class Circle:
def __init__(self, radius):
self.radius = radius
self._diameter = None
@property
def diameter(self):
if self._diameter is None:
sleep(0.5) # Simulate a costly computation
self._diameter = self.radius * 2
return self._diameter
В этой реализации Circle кэширует вычисленное значение диаметра, используя специальный непубличный атрибут. Код работает, но есть недостаток: если вы когда-либо измените значение .radius
, то .diameter
больше не будет возвращать корректное значение:
>>> from circle_v4 import Circle
>>> circle = Circle(42.0)
>>> circle.radius
42.0
>>> circle.diameter # With delay
84.0
>>> circle.diameter # Without delay
84.0
>>> circle.radius = 100.0
>>> circle.diameter # Wrong diameter
84.0
В этом примере создаётся круг с радиусом 42.0. Свойство .diameter
вычисляет своё значение только при первом доступе, поэтому при первом выполнении видно задержку, а при втором — нет. Когда вы меняете значение радиуса, диаметр остается неизменным, что является проблемой.
Если входные данные для вычисляемого атрибута меняются, необходимо пересчитывать его значение:
from time import sleep
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
self._diameter = None
self._radius = value
@property
def diameter(self):
if self._diameter is None:
sleep(0.5) # Simulate a costly computation
self._diameter = self._radius * 2
return self._diameter
Сеттер для свойства .radius
сбрасывает значение ._diameter
на None каждый раз при изменении радиуса. С этим небольшим обновлением .diameter
пересчитывает своё значение при первом доступе после каждого изменения .radius
:
>>> from circle_v5 import Circle
>>> circle = Circle(42.0)
>>> circle.radius
42.0
>>> circle.diameter # With delay
84.0
>>> circle.diameter # Without delay
84.0
>>> circle.radius = 100.0
>>> circle.diameter # With delay
200.0
>>> circle.diameter # Without delay
200.0
Отлично, теперь Circle
работает правильно! Он вычисляет диаметр при первом доступе и при каждом изменении радиуса.
Другой способ создания кэшируемых свойств — использовать functools.cached_property()
из стандартной библиотеки. Эта функция работает как декоратор и позволяет преобразовать метод в кэшируемое свойство. Свойство вычисляет своё значение только один раз и кэширует его как обычный атрибут в течение всего срока существования экземпляра:
from functools import cached_property
from time import sleep
class Circle:
def __init__(self, radius):
self.radius = radius
@cached_property
def diameter(self):
sleep(0.5) # Simulate a costly computation
return self.radius * 2
Здесь .diameter
вычисляет и кэширует своё значение при первом доступе. Этот тип реализации подходит для тех значений, которые не изменяются. Вот как это работает:
>>> from circle_v6 import Circle
>>> circle = Circle(42.0)
>>> circle.diameter # With delay
84.0
>>> circle.diameter # Without delay
84.0
>>> circle.radius = 100
>>> circle.diameter # Wrong diameter
84.0
>>> # Allow direct assignment
>>> circle.diameter = 200
>>> circle.diameter # Cached value
200
При обращении к .diameter
вы получаете вычисленное значение. Это значение остаётся неизменным с этого момента. Однако, в отличие от property()
, cached_property()
не блокирует обновления атрибутов, если только вы не предоставите сеттер. Именно поэтому в последних строках можно установить значение diameter равным 200.
Если вы хотите создать кэшируемое свойство, не допускающее изменений, вы можете использовать property()
и functools.cache(), как в следующем примере:
from functools import cache
from time import sleep
class Circle:
def __init__(self, radius):
self.radius = radius
@property
@cache
def diameter(self):
sleep(0.5) # Simulate a costly computation
return self.radius * 2
Этот код комбинирует декораторы @property
и @cache
. Такое сочетание декораторов позволяет создать кэшируемое свойство, которое предотвращает изменения:
>>> from circle_v7 import Circle
>>> circle = Circle(42.0)
>>> circle.diameter # With delay
84.0
>>> circle.diameter # Without delay
84.0
>>> circle.radius = 100
>>> circle.diameter # Wrong diameter
84.0
>>> circle.diameter = 200
Traceback (most recent call last):
...
AttributeError: can't set attribute
В этом примере, если вы попробуете присвоить новое значение .diameter
, возникнет AttributeError
, поскольку функциональность сеттера берётся из внутреннего дескриптора свойства.
Логирование доступа и изменения атрибутов
Иногда вам нужно отслеживать действия вашего кода и то, как он выполняется. В Python для этого можно использовать модуль logging, который предоставляет весь необходимый функционал для ведения логов. Логирование позволяет наблюдать за кодом и генерировать полезную информацию о его работе.
Если вам нужно отслеживать, как и когда вы получаете доступ к атрибуту или изменяете его, для этого тоже можно использовать property()
:
import logging
logging.basicConfig(
format="%(asctime)s: %(message)s",
level=logging.INFO,
datefmt="%H:%M:%S"
)
class Circle:
def __init__(self, radius):
self._msg = '"radius" was %s. Current value: %s'
self.radius = radius
@property
def radius(self):
logging.info(self._msg % ("accessed", str(self._radius)))
return self._radius
@radius.setter
def radius(self, value):
try:
self._radius = float(value)
logging.info(self._msg % ("mutated", str(self._radius)))
except ValueError:
logging.info('validation error while mutating "radius"')
Здесь сначала импортируется logging и задаётся базовая конфигурация. Затем реализуется Circle
с управляемым атрибутом .radius
. Геттер каждый раз при обращении к .radius
в коде генерирует информацию в логе. Сеттер логирует каждое изменение, осуществляемое в .radius
. Он также логирует ситуации, когда возникает ошибка из-за некорректных входных данных.
Вот как можно использовать класс Circle
в коде:
>>> from circle_v8 import Circle
>>> circle = Circle(42.0)
>>> circle.radius
14:48:59: "radius" was accessed. Current value: 42.0
42.0
>>> circle.radius = 100
14:49:15: "radius" was mutated. Current value: 100
>>> circle.radius
14:49:24: "radius" was accessed. Current value: 100
100
>>> circle.radius = "value"
15:04:51: validation error while mutating "radius"
Логирование данных, связанных с доступом и изменением атрибутов, может помочь в отладке кода. Оно также помогает выявить источники проблемных данных, проанализировать производительность кода, определить шаблоны использования и многое другое.
Управление удалением атрибутов
Вы можете создать свойства, которые реализуют функциональность удаления. Это может быть редким случаем использования property()
, но в некоторых ситуациях возможность удаления атрибута может быть полезной.
Допустим, вы реализуете собственный древовидный тип данных. Дерево — это абстрактный тип данных, который хранит элементы в иерархии. Компоненты дерева обычно называют узлами. Каждый узел дерева, кроме корневого, имеет родительский узел. Узлы могут иметь ноль или более дочерних элементов.
Теперь предположим, что вам нужно удалить или очистить список дочерних элементов данного узла. Вот пример реализации узла дерева, который использует property()
для управления большинством функциональных возможностей, включая возможность очистки списка дочерних элементов узла:
class TreeNode:
def __init__(self, data):
self._data = data
self._children = []
@property
def children(self):
return self._children
@children.setter
def children(self, value):
if isinstance(value, list):
self._children = value
else:
del self.children
self._children.append(value)
@children.deleter
def children(self):
self._children.clear()
def __repr__(self):
return f'{self.__class__.__name__}("{self._data}")'
В этом примере TreeNode представляет узел в пользовательском древовидном типе данных. Каждый узел хранит свои дочерние элементы в виде Python списка. Затем вы реализуете .children
как свойство для управления базовым списком дочерних элементов. Метод удаления (deleter) вызывает .clear()
для списка дочерних элементов, чтобы удалить их все.
Вот как работает класс:
>>> from tree import TreeNode
>>> root = TreeNode("root")
>>> child1 = TreeNode("child 1")
>>> child2 = TreeNode("child 2")
>>> root.children = [child1, child2]
>>> root.children
[TreeNode("child 1"), TreeNode("child 2")]
>>> del root.children
>>> root.children
[]
В этом примере вы сначала создаёте узел root для инициализации дерева. Затем создаёте два новых узла и присваиваете их .children
, используя список. Оператор del вызывает внутренний метод удаления для .children
и очищает список узлов.
Создание обратной совместимости для API классов
Как вам уже известно, свойства превращают прямой доступ к атрибутам в вызовы методов. Эта особенность позволяет создавать чистые и питоничные API для классов. Можно публично открывать атрибуты без использования геттеров и сеттеров.
Если вам когда-нибудь понадобится изменить способ вычисления публичного атрибута, вы можете преобразовать его в свойство. Свойства позволяют выполнять дополнительную обработку, такую как проверка данных, не изменяя при этом публичный API.
Предположим, вы создаёте приложение для бухгалтерского учёта, и вам нужен базовый класс для управления валютой. Для этого вы создаёте класс Currency
, который предоставляет два атрибута: .units
и .cents
:
class Currency:
def __init__(self, units, cents):
self.units = units
self.cents = cents
# Currency implementation...
Этот класс выглядит аккуратно и соответствует стилю Python. Однако представим, что требования изменились, и вы решили хранить общее количество центов вместо отдельных значений для единиц и центов. Удаление .units
и .cents
из публичного API и использование, например, .total_cents
, может привести к тому, что код пользователей перестанет работать.
В такой ситуации property()
— отличный способ сохранить текущий API без изменений. Вот как можно обойти эту проблему и избежать поломки кода пользователей:
CENTS_PER_UNIT = 100
class Currency:
def __init__(self, units, cents):
self._total_cents = units * CENTS_PER_UNIT + cents
@property
def units(self):
return self._total_cents // CENTS_PER_UNIT
@units.setter
def units(self, value):
self._total_cents = self.cents + value * CENTS_PER_UNIT
@property
def cents(self):
return self._total_cents % CENTS_PER_UNIT
@cents.setter
def cents(self, value):
self._total_cents = self.units * CENTS_PER_UNIT + value
# Currency implementation...
Теперь ваш класс хранит общее количество центов вместо отдельных единиц и центов. Благодаря новым свойствам ваши пользователи по-прежнему могут получать доступ к .units
и .cents
и изменять их в своем коде, получая тот же результат, что и прежде. Попробуйте этот подход!
Когда вы пишете код, на основе которого будут работать другие, важно гарантировать, что изменения внутренней реализации не повлияют на взаимодействие конечных пользователей с ним.
Переопределение свойств в подклассах
Когда вы создаёте классы в Python, которые включают свойства и распространяете их в виде пакета или библиотеки, стоит иметь в виду, что пользователи могут применять их неожиданным образом — например, создавать подклассы с целью изменения функциональности. В таких случаях пользователям следует обратить внимание на одну тонкость: если вы частично переопределяете свойство, то потеряете непереопределённую функциональность.
Предположим, что вам нужен класс Employee
для управления информацией о сотрудниках. У вас уже есть другой класс под названием Person, и вы решаете создать подкласс, чтобы переиспользовать его функциональность.
У класса Person
есть атрибут .name
, реализованный как свойство. Текущая реализация .name
не подходит для Employee
, так как вы хотите, чтобы имя сотрудника было написано капслоком. Вот как вы можете написать Employee
, используя наследование:
class Person:
def __init__(self, name):
self._name = name
@property
def name(self):
return self._name
@name.setter
def name(self, value):
self._name = value
# Person implementation...
class Employee(Person):
@property
def name(self):
return super().name.upper()
# Employee implementation...
В классе Employee
вы переопределяете .name, чтобы при обращении к атрибуту имя сотрудника возвращалось в верхнем регистре:
>>> from persons import Employee, Person
>>> person = Person("John")
>>> person.name
'John'
>>> person.name = "John Doe"
>>> person.name
'John Doe'
>>> employee = Employee("John")
>>> employee.name
'JOHN'
Отлично! Employee
работает так, как нужно, и возвращает имя в верхнем регистре. Однако последующие тесты выявляют неожиданную проблему:
>>> employee.name = "John Doe"
Traceback (most recent call last):
...
AttributeError: can't set attribute
Что произошло? Переопределяя существующее свойство из родительского класса, вы переопределяете всю функциональность этого свойства. В этом примере был переопределён только геттер, из-за чего .name потерял оставшуюся унаследованную функциональность. Теперь у вас больше нет сеттера.
Вывод: если вам нужно переопределить свойство в подклассе, необходимо предоставить всю функциональность, которая вам требуется, в новой версии свойства.
Заключение
Свойство — это особый тип члена класса, который предоставляет функциональность, находящуюся где-то между обычными атрибутами и методами. Свойства позволяют изменять реализацию атрибутов экземпляра без необходимости менять публичный API класса. Возможность сохранять API без изменений помогает избежать поломок в коде, который пользователи написали на основе более старых версий ваших классов.
Свойства являются питоничным способом создания управляемых атрибутов в классах. Они имеют множество вариантов использования в реальных задачах программирования, делая их отличным дополнением к вашему набору навыков как Python-разработчика.
Освоить все необходимые навыки и инструменты для программирования на Python c нуля и до middle-уровня можно в рамках специализации Python-разработчик. На странице курса можно ознакомиться с программой, а также посмотреть записи открытых уроков.