Handles in Python
Basic methods
The handle protocol in Python is defined by the presence of methods __get__
, __set__
And __delete__
in class. These methods allow objects to control how attribute values are retrieved, set, or removed. There is also an optional method __set_name__
which allows the handle to recognize the name of the attribute to which it is assigned in the class.
Method __get__
called when the attribute's value is retrieved, it takes two arguments: self
And instance
. instance
is the instance of the object through which the handle is accessible, or None
, if the call goes through the class. The return value of this method will be the value of the specified attribute:
class Descriptor:
def __get__(self, instance, owner):
return 'значение'
class MyClass:
attr = Descriptor()
my_object = MyClass()
print(my_object.attr) # выведет 'значение'
__set__
allows you to control changes in the attribute value. It takes three arguments: self
, instance
And value
Where value
is the new attribute 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__
called when an attribute is removed using the operator del
. It takes two arguments: self
And instance
:
class Descriptor:
def __delete__(self, instance):
print("Удаление атрибута")
del self.__value
class MyClass:
attr = Descriptor()
my_object = MyClass()
del my_object.attr # выведет 'Удаление атрибута'
Optional method __set_name__
called at class creation time for each descriptor, allowing the descriptor to know the name of the attribute it is bound to. This method takes two arguments: self
And name
Where name
is the attribute 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
Examples of using
Let's create a data validation descriptor that will check that the user's age cannot be a negative number and cannot exceed 100 years:
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)
Now let's create a descriptor for caching the results of heavy calculations. Suppose there is a function that takes a significant amount of time to execute, and ye;yj caches its result for the same input data:
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} секунд")
Let's create a descriptor that will log any changes to attribute values:
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 # Логируется изменение
Implementation of Singleton and Factory patterns
We will not explain in the context of this article why the Singleton pattern is needed. But in short, it ensures that a class has only one instance and provides a global access point to that instance.
Let's create a descriptor Singleton
which will manage the creation of instances of another class, ensuring that only one instance is created:
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 is a design pattern that is used to create objects without specifying specific object classes.
To implement this pattern, you can create a handle that will dynamically determine which object to create based on some condition or configuration:
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()
Built-in functions property, classmethod and staticmethod
property
is a built-in Python function that can be used to create an attribute whose value is generated dynamically through the getter and setter methods. Usable when you need to add validation logic when assigning a value to an attribute or when the attribute value depends on other attributes.
property
works as a handle using methods __get__
, __set__
And __delete__
to control access to an attribute:
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
is a decorator that modifies a method so that it receives a class (rather than an instance of the class) as its first argument.
Internally classmethod
implemented as a handle. When a method is decorated like classmethod
calling it results in calling the method __get__
handle which returns bound method – a function whose first argument automatically becomes a class:
class A:
@classmethod
def method(cls):
return f"вызван classmethod класса {cls}"
print(A.method()) # вызван classmethod класса <class '__main__.A'>
staticmethod
is a decorator that changes a class method so that it behaves like a regular function that does not accept any self
neither cls
as the first argument.
staticmethod
also implemented as a handle. When using this method __get__
handle simply returns a function without being bound to an instance or class:
class Math:
@staticmethod
def add(x, y):
return x + y
print(Math.add(5, 7)) # 12
Several features
Data Descriptors __get__
And __set__
take precedence over instance attributes, whereas descriptors without data __get__
without __set__
inferior to them. This is important to consider when designing classes and their behavior.
__set__
will not be called if you try to change the attribute through a direct call to __dict__
copy. To avoid this error, it is important to always use normal assignment to set attribute values, thereby ensuring that the method is called __set__
.
If a class defines a handle and an instance attribute with the same name, this may result in unexpected behavior. In such a case, the handle will take precedence when accessed through the class, but the instance attribute may peculiar hide handle when accessed directly. To avoid this situation, you should avoid naming instance attributes and descriptors with the same name.
When implementing the method __get__
It is important to remember that it must correctly handle the situation when instance
argument is equal to None
. This happens when the attribute is accessed through the class itself rather than an instance of it. In this case it is usually recommended to return the handle itself (i.e. self
).
Handles in Python are a convenient way to add logic to attribute access.
The article was prepared in anticipation of the launch of a new stream of Python Developer specialization.