Привет, Хабр!

Дескриптор — это объектовый атрибут с поведением, определяемым методами в его классе. Если просто — это способ, с помощью которого объект может контролировать доступ к его атрибутам, используя специально определенные методы __get__, __set__, и __delete__. Если говорить еще проще — дескрипторы позволяют задавать точки доступа к атрибутам объекта, добавляя дополнительную логику, когда атрибут читается, записывается или удаляется.

В этой статье поговорим подробней про дескрпиторы.

Основные методы

Протокол дескриптора в Python определяется наличием методов __get__, __set__ и __delete__ в классе. Эти методы позволяют объектам управлять тем, как значения атрибутов извлекаются, устанавливаются или удаляются. Также существует необязательный метод __set_name__, который позволяет дескриптору узнавать имя атрибута, к которому он присвоен в классе.

Метод __get__ вызывается, когда значение атрибута извлекается, он принимает два аргумента: self и instance. instance - это экземпляр объекта, через который доступен дескриптор, или None, если обращение идет через класс. Возвращаемое значение этого метода будет значением указанного атрибута:

class Descriptor:
    def __get__(self, instance, owner):
        return 'значение'

class MyClass:
    attr = Descriptor()

my_object = MyClass()
print(my_object.attr)  # выведет 'значение'

__set__ позволяет управлять изменением значения атрибута. Он принимает три аргумента: self, instance и value, где value - это новое значение атрибута:

class Descriptor:
    def __set__(self, instance, value):
        print(f"Установка значения {value}")
        self.__value = value

class MyClass:
    attr = Descriptor()

my_object = MyClass()
my_object.attr = 10  # выведет 'Установка значения 10'

__delete__ вызывается при удалении атрибута с использованием оператора del. Он принимает два аргумента: self и instance:

class Descriptor:
    def __delete__(self, instance):
        print("Удаление атрибута")
        del self.__value

class MyClass:
    attr = Descriptor()

my_object = MyClass()
del my_object.attr  # выведет 'Удаление атрибута'

Необязательный метод __set_name__ вызывается в момент создания класса для каждого дескриптора, что позволяет дескриптору знать имя атрибута, к которому он привязан. Этот метод принимает два аргумента: self и name, где name - это имя атрибута:

class Descriptor:
    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name

    def __get__(self, instance, owner):
        return getattr(instance, self.private_name, 'еще не установлено')

    def __set__(self, instance, value):
        setattr(instance, self.private_name, value)

class MyClass:
    attr = Descriptor()

my_object = MyClass()
print(my_object.attr)  # выведет 'еще не установлено'
my_object.attr = 99
print(my_object.attr)  # выведет 99

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

Создадим дескриптор для валидации данных, который будет проверять, что возраст пользователя не может быть отрицательным числом и не может превышать 100 лет:

class ValidateAge:
    def __set_name__(self, owner, name):
        self.private_name = '_' + name

    def __get__(self, instance, owner):
        return getattr(instance, self.private_name, None)

    def __set__(self, instance, value):
        if not (0 <= value <= 100):
            raise ValueError("Возраст должен быть между 0 и 100 годами")
        setattr(instance, self.private_name, value)

class Person:
    age = ValidateAge()

    def __init__(self, name, age):
        self.name = name
        self.age = age

try:
    p = Person("Kolya", 30)  # валидный возраст
    print(p.age)
    p.age = -5  # невалидный возраст, будет вызвано исключение ValueError
except ValueError as e:
    print(e)

Теперь создадим дескриптор для кэширования результатов тяжелых вычислений. Предположим, есть функция, выполнение которой занимает значительное время, и ye;yj кэшировать ее результат для одних и тех же входных данных:

import time

class CachedAttribute:
    def __init__(self, method):
        self.method = method
        self.cache = {}

    def __get__(self, instance, owner):
        if instance not in self.cache:
            self.cache[instance] = self.method(instance)
        return self.cache[instance]

class HeavyComputation:
    @CachedAttribute
    def compute(self):
        # имитация длительного вычисления
        time.sleep(2)
        return "Результат вычисления"

hc = HeavyComputation()
start_time = time.time()
print(hc.compute)  # первый вызов занимает время
print(f"Выполнено за {time.time() - start_time} секунд")

start_time = time.time()
print(hc.compute)  # второй вызов мгновенный, использует кэшированный результат
print(f"Выполнено за {time.time() - start_time} секунд")

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

class LoggedAttribute:
    def __set_name__(self, owner, name):
        self.private_name = '_' + name

    def __get__(self, instance, owner):
        return getattr(instance, self.private_name, None)

    def __set__(self, instance, value):
        print(f"Установка {self.private_name} в {value}")
        setattr(instance, self.private_name, value)

class User:
    name = LoggedAttribute()
    age = LoggedAttribute()

    def __init__(self, name, age):
        self.name = name
        self.age = age

u = User("Katya", 30)
u.name = "Katyuha"  # Логируется изменение
u.age = 31  # Логируется изменение

Реализация паттернов Singleton и Factory

Не будем объяснять в контексте этой статьи о том, зачем нужен паттерн Singleton. Но если коротко, то он гарантирует, что класс имеет только один экземпляр и предоставляет глобальную точку доступа к этому экземпляру.

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

class Singleton:
    def __init__(self, cls):
        self.cls = cls
        self.instance = None

    def __get__(self, instance, owner):
        if self.instance is None:
            self.instance = self.cls()
        return self.instance

class Database:
    def __init__(self):
        print("Создание базы данных")

# применение дескриптора Singleton
class AppConfig:
    db = Singleton(Database)

# тестирование паттерна Singleton
config1 = AppConfig()
config2 = AppConfig()
db1 = config1.db  # создание БД
db2 = config2.db  # не создает новый экземпляр, использует существующий

print(db1 is db2)  # выведет True, подтверждая, что db1 и db2 - один и тот же объект

Factory — это паттерн проектирования, который используется для создания объектов без указания конкретных классов объектов.

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

class VehicleFactory:
    def __init__(self, cls):
        self.cls = cls

    def __get__(self, instance, owner):
        return self.cls()

class Car:
    def drive(self):
        print("Вождение автомобиля")

class Bike:
    def ride(self):
        print("Езда на велосипеде")

# фабрика, создающая автомобили
class AppConfigCar:
    vehicle = VehicleFactory(Car)

# фабрика, создающая велосипеды
class AppConfigBike:
    vehicle = VehicleFactory(Bike)

# создание и использование автомобиля
car_config = AppConfigCar()
car = car_config.vehicle  # создает объект Car
car.drive()

# создание и использование велосипеда
bike_config = AppConfigBike()
bike = bike_config.vehicle  # создает объект Bike
bike.ride()

Встроенные функции property, classmethod и staticmethod

property - это встроенная функция Python, с ее помощью можно создать атрибут, значение которого генерируется динамически через методы геттер и сеттер. Юзабельно, когда надо добавить логику валидации при присвоении значения атрибуту или когда значение атрибута зависит от других атрибутов.

property работает как дескриптор, используя методы __get__, __set__ и __delete__ для управления доступом к атрибуту:

class Celsius:
    def __init__(self, temperature=0):
        self.temperature = temperature

    def to_fahrenheit(self):
        return (self.temperature * 1.8) + 32

    def get_temperature(self):
        print("Получение значения")
        return self._temperature

    def set_temperature(self, value):
        if value < -273.15:
            raise ValueError("Температура не может быть ниже -273.15 градусов Цельсия")
        print("Установка значения")
        self._temperature = value

    temperature = property(get_temperature, set_temperature)

c = Celsius(37)
print(c.temperature)
c.temperature = -300  # вызовет исключение

classmethod - это декоратор, который изменяет метод так, что он получает класс (а не экземпляр класса) в качестве первого аргумента.

Внутренне classmethod реализован как дескриптор. Когда метод декорирован как classmethod, его вызов приводит к вызову метода __get__ дескриптора, который возвращает привязанный метод - функцию, первым аргументом которой автоматически становится класс:

class A:
    @classmethod
    def method(cls):
        return f"вызван classmethod класса {cls}"

print(A.method())  # вызван classmethod класса <class '__main__.A'>

staticmethod - это декоратор, который изменяет метод класса так, что он ведет себя как обычная функция, не принимающая ни self, ни cls в качестве первого аргумента.

staticmethod также реализован как дескриптор. При его использовании метод __get__ дескриптора просто возвращает функцию без привязки к экземпляру или классу:

class Math:
    @staticmethod
    def add(x, y):
        return x + y

print(Math.add(5, 7))  # 12


Несколько особенностей

Дескрипторы данных __get__ и __set__ имеют приоритет над атрибутами экземпляра, тогда как дескрипторы без данных __get__ без __set__ уступают им. Это важно учитывать при проектировании классов и их поведения​​.

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

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

При реализации метода __get__ важно помнить, что он должен корректно обрабатывать ситуацию, когда instance аргумент равен None. Это случается, когда доступ к атрибуту осуществляется через сам класс, а не его экземпляр. Обычно в таком случае рекомендуется возвращать сам дескриптор (т.е., self).

Дескрипторы в Python — это удобный способ добавления логики к доступу к атрибутам.

Статья подготовлена в преддверии запуска нового потока специализации Python Developer.

Комментарии (1)


  1. nameisBegemot
    30.03.2024 09:42
    +1

    Питон простой язык, говорили они. Главное не обращать внимание на механизмы языка, даже смысл которых понять не просто. И дескрипторы не самое страшное.

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