property() в Python

С помощью property() в Python вы можете создавать управляемые атрибуты в своих классах. Управляемые атрибуты, также известные как свойства (англ. properties), используются, если вам нужно изменять их внутреннюю реализацию без изменения общедоступного API класса. Когда пользователи полагаются на ваши классы и объекты, важно, чтобы ваш API был стабильным.

Свойства, вероятно, самый популярный способ быстрого создания управляемых атрибутов в абсолютно питоническом стиле.

Начало работы с property() в Python

property() позволяет вам превращать атрибуты класса в свойства или управляемые атрибуты. Поскольку это встроенная функция, вы можете использовать ее, ничего не импортируя. Для обеспечения оптимальной производительности property() была реализована на языке C .

С помощью property() вы можете присоединить геттеры и сеттеры к атрибутам класса. Это даст возможность обрабатывать внутреннюю реализацию этого атрибута, не раскрывая геттеры и сеттеры в вашем API. Вы также можете указать способ управления удалением атрибута и предоставить соответствующую строку документации для ваших свойств.

Вот полная сигнатура property:

property(fget=None, fset=None, fdel=None, doc=None)

Первые два аргумента принимают объекты функции, которые будут играть роль геттера (fget) и сеттера (fset). Вот краткое описание того, что делает каждый аргумент:

АргументОписание
fgetФункция, возвращающая значение управляемого атрибута
fsetФункция, позволяющая установить значение управляемого атрибута
fdelФункция для определения того, как управляемый атрибут обрабатывает удаление
docСтрока, представляющая docstring свойства

Возвращаемое значение property() – это сам управляемый атрибут. Если вы обращаетесь к управляемому атрибуту, как в obj.attr, Python автоматически вызывает fget(). Если вы присваиваете атрибуту новое значение, как в obj.attr = value, Python вызывает fset(), используя входное значение в качестве аргумента. Наконец, если вы запустите оператор del obj.attr, Python автоматически вызовет fdel().

Вы можете использовать doc, чтобы предоставить соответствующую строку документации для ваших свойств. Вы и ваши коллеги-программисты сможете прочитать ее с помощью help(). Аргумент doc также полезен, когда вы работаете с редакторами кода и IDE, которые поддерживают доступ к строкам документации.

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

Создание атрибутов с помощью property()

Вы можете создать свойство, вызвав property() с соответствующим набором аргументов и присвоив возвращаемое значение атрибуту класса. Все аргументы для property() необязательны.

В следующем примере показано, как создать класс Circle с удобным свойством для управления его радиусом radius:

# circle.py

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. Затем вы определяете три закрытых метода:

  • ._get_radius() возвращает текущее значение ._radius
  • ._set_radius() принимает значение в качестве аргумента и присваивает его ._radius
  • ._del_radius() удаляет атрибут экземпляра ._radius

Чтобы инициализировать свойство, вы передаете три метода в качестве аргументов в property(). Вы также передаете соответствующую строку документации.

Запустим следующий код, чтобы посмотреть, как работает Circle:

>>> from circle 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.

Свойства имеют приоритет над дескрипторами. Если вы используете dir() для проверки внутренних членов данного свойства, вы найдете в списке .__set__() и .__get__(). Эти методы обеспечивают реализацию протокола дескриптора по умолчанию.

Использование property() в качестве декоратора

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

property() также может работать как декоратор, поэтому вы можете использовать синтаксис @property для быстрого создания свойств:

# circle.py

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

Теперь код выглядит более питоническим и чистым. Вам больше не нужно использовать такие имена методов, как ._get_radius(), ._set_radius() и ._del_radius(). И у вас есть три метода с одинаковым понятным и описательным именем, похожим на атрибут.

Подход декоратора для создания свойств требует определения первого метода, использующего публичное имя для нижележащего управляемого атрибута, которым в данном случае является .radius. Этот метод должен реализовывать логику получения – геттер. В приведенном выше примере этот метод реализуется в строках с 7 по 11.

Строки с 13 по 16 определяют сеттер для .radius. В этом случае синтаксис сильно отличается. Вместо того, чтобы снова использовать @property, вы используете @radius.setter. Почему? Посмотрим на вывод dir():

>>> dir(Circle.radius)
[..., 'deleter', ..., 'getter', 'setter']

Помимо .fget, .fset, .fdel и множества других специальных атрибутов и методов, property также предоставляет .deleter(), .getter() и .setter(). Каждый из этих трех методов возвращает новое свойство.

Когда вы декорируете второй метод .radius() с помощью @radius.setter, вы создаете новое свойство и переназначаете имя уровня класса .radius для его хранения. Это новое свойство содержит тот же набор методов, что и начальное, с добавлением нового сеттера, представленного в строке 14. Наконец, синтаксис декоратора переназначает новое свойство на имя уровня класса .radius. Механизм определения метода удаления аналогичен.

Новая реализация Circle работает так же, как пример в разделе выше:

>>> from circle 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() – предоставление атрибутов только для чтения. Допустим, вам нужен неизменяемый класс Point, который не позволяет пользователю изменять исходное значение его координат x и y. Для этого вы можете создать Point, как в следующем примере:

# point.py

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. Кроме того, вы определяете два метода-геттера и декорируете их с помощью @property.

Теперь у вас есть два свойства только для чтения, .x и .y, в качестве ваших координат:

>>> from point import Point

>>> point = Point(12, 5)

>>> # Read coordinates
>>> point.x
12
>>> point.y
5

>>> # Write coordinates
>>> point.x = 42
Traceback (most recent call last):
    ...
AttributeError: can't set attribute

Здесь point.x и point.y – простые примеры свойств, доступных только для чтения. Их поведение зависит от базового дескриптора, который предоставляет свойство. Реализация же по умолчанию .__set__() вызывает AttributeError, если вы не определяете правильный метод-сеттер.

Вы можете пойти дальше этой реализации Point и предоставить явные сеттеры, которые вызывают настраиваемое исключение с более сложными и конкретными сообщениями:

# point.py

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. Теперь оба сеттера вызывают ваше настраиваемое исключение с более явным сообщением.

Создание атрибутов, доступных для чтения и записи

Вы также можете использовать property() для предоставления управляемых атрибутов с возможностью чтения и записи. На практике вам просто нужно предоставить соответствующие геттер (чтение) и сеттер (запись) для ваших свойств, чтобы создать управляемые атрибуты чтения-записи.

Допустим, вы хотите, чтобы ваш класс Circle имел атрибут .diameter. Однако использование и радиуса, и диаметра в инициализаторе класса кажется ненужным, потому что вы можете вычислить одно, используя другое. Вот Circle, который управляет .radius и .diameter как атрибутами чтения и записи:

# circle.py

import math

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 напрямую, вместо того, чтобы сохранять его в выделенном непубличном атрибуте, таком как ._radius.

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

Circle также реализует атрибут .diameter как свойство. Геттер рассчитывает диаметр, используя радиус. Метод-сеттер делает кое-что любопытное. Вместо того, чтобы сохранять входное значение диаметра в специальном атрибуте, он вычисляет радиус и записывает результат в .radius.

Вот как работает наш Circle:

>>> from circle 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.

Предоставление атрибутов только для записи

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

Вот пример обработки паролей со свойством только для записи:

# users.py

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 соответственно. Вы используете свойство для управления тем, как ваш класс обрабатывает входной пароль. Геттер вызывает ошибку 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 – использовать logging. Этот модуль предоставляет все функции, которые могут потребоваться для логирования вашего кода. Это позволит вам постоянно следить за кодом и генерировать полезную информацию о том, как он работает.

Если вам когда-либо понадобится отслеживать, как и когда вы получаете доступ к данному атрибуту и ​​изменяете его, то вы также можете воспользоваться для этого функцией property():

# circle.py

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):
        """The radius property."""
        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 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 в Python – это особый тип члена класса, обеспечивающий функциональность, занимающую место где-то между обычными атрибутами и методами. Более того, свойства позволяют изменять реализацию атрибутов экземпляра без изменения общедоступного API класса.

В этом руководстве мы разобрали, что такое property() и как эта функция работает. Кроме того, мы рассмотрели на примерах, как использовать property в Python в качестве декоратора.

Надеемся данная статья была вам полезна! Успехов в написании кода!

Сокращенный перевод статьи «Python’s property(): Add Managed Attributes to Your Classes».