Descriptores: __get__, __set__ y atributos controlados
Los descriptores son el mecanismo que hace posible @property, classmethod y staticmethod. Implementar __get__ y __set__ te da control total sobre cómo se accede y almacena un atributo.
El protocolo descriptor
Un descriptor es cualquier objeto que define al menos uno de: __get__, __set__ o __delete__. Cuando se asigna como atributo de clase, Python lo invoca automáticamente al acceder al atributo desde una instancia.
# Un descriptor básico: loggea cada acceso
class LoggedAttribute:
"""Descriptor que registra lecturas y escrituras."""
def __set_name__(self, owner, name):
# Llamado cuando se asigna el descriptor a una clase
self.name = name
self.private_name = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self # acceso desde la clase (MyClass.attr)
value = getattr(instance, self.private_name, None)
print(f"GET {self.name} = {value!r}")
return value
def __set__(self, instance, value):
print(f"SET {self.name} = {value!r}")
setattr(instance, self.private_name, value)
def __delete__(self, instance):
print(f"DEL {self.name}")
delattr(instance, self.private_name)
class User:
name = LoggedAttribute() # el descriptor se asigna a la CLASE
email = LoggedAttribute()
u = User()
u.name = "Ana" # SET name = 'Ana'
u.email = "ana@x.com" # SET email = 'ana@x.com'
print(u.name) # GET name = 'Ana' → Ana
# El descriptor se comparte: existe UNA instancia del descriptor por clase
# pero los datos se almacenan en CADA instancia (en _name, _email)
u2 = User()
u2.name = "Carlos" # SET name = 'Carlos'
print(u.name) # GET name = 'Ana' — no se mezclanSET name = 'Ana'
SET email = 'ana@x.com'
GET name = 'Ana'
Ana
SET name = 'Carlos'
GET name = 'Ana'
AnaDescriptor de datos vs no-datos
La distinción entre descriptores de datos y no-datos determina la prioridad frente a los atributos de instancia: los de datos tienen mayor prioridad, los no-datos pueden ser "tapados" por atributos de instancia.
# Descriptor de DATOS: tiene __set__ (y/o __delete__)
# → tiene prioridad sobre __dict__ de la instancia
class DataDescriptor:
def __get__(self, obj, objtype=None):
return obj.__dict__.get("_x", 0)
def __set__(self, obj, value):
obj.__dict__["_x"] = value
# Descriptor de NO-DATOS: solo tiene __get__
# → la instancia puede "taparlo" con su propio atributo
class NonDataDescriptor:
def __get__(self, obj, objtype=None):
if obj is None:
return self
return "valor del descriptor"
class MyClass:
data_attr = DataDescriptor()
non_data_attr = NonDataDescriptor()
obj = MyClass()
# Descriptor de datos: siempre gana
obj.__dict__["data_attr"] = "intento de override"
print(obj.data_attr) # 0 — el descriptor gana
# Descriptor de no-datos: la instancia puede taparlo
obj.__dict__["non_data_attr"] = "override de instancia"
print(obj.non_data_attr) # "override de instancia" — la instancia gana
# @property es un descriptor de datos (tiene __set__ aunque sea solo-lectura)
# @staticmethod y @classmethod son descriptores de no-datos
print(type(MyClass.__dict__["data_attr"])) # DataDescriptor
print(type(MyClass.__dict__["non_data_attr"])) # NonDataDescriptor0
override de instancia
<class '__main__.DataDescriptor'>
<class '__main__.NonDataDescriptor'>@property crea un objeto property que implementa __get__, __set__ y __delete__. Cuando haces obj.attr, Python ve que attr en la clase es un descriptor con __get__ y lo llama. Eso es todo el "magic" de @property.
Implementar un validador reutilizable
El poder real de los descriptores es la reutilización: un solo descriptor de validación puede aplicarse a decenas de atributos en cualquier clase, sin duplicar código.
class Validated:
"""Descriptor base para validación reutilizable."""
def __set_name__(self, owner, name):
self.name = name
self.storage_name = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.storage_name, None)
def __set__(self, instance, value):
value = self.validate(value) # template method
setattr(instance, self.storage_name, value)
def validate(self, value):
raise NotImplementedError
class NonNegative(Validated):
def validate(self, value):
if not isinstance(value, (int, float)):
raise TypeError(f"{self.name} debe ser numérico, recibido {type(value).__name__}")
if value < 0:
raise ValueError(f"{self.name} debe ser ≥ 0, recibido {value}")
return value
class MaxLength(Validated):
def __init__(self, max_len: int):
self.max_len = max_len
def validate(self, value: str) -> str:
if len(value) > self.max_len:
raise ValueError(f"{self.name} demasiado largo: {len(value)} > {self.max_len}")
return value
class Product:
price = NonNegative()
stock = NonNegative()
name = MaxLength(50)
def __init__(self, name: str, price: float, stock: int):
self.name = name
self.price = price
self.stock = stock
p = Product("Python Handbook", 29.99, 100)
print(p.price) # 29.99
print(p.stock) # 100
try:
p.price = -5 # ValueError: price debe ser ≥ 0, recibido -5
except ValueError as e:
print(e)
try:
p.name = "A" * 60 # ValueError: name demasiado largo
except ValueError as e:
print(e)29.99
100
price debe ser ≥ 0, recibido -5
name demasiado largo: 60 > 50Cómo funciona @property internamente
property es una clase nativa que implementa el protocolo descriptor. Entender su implementación muestra cómo funciona @property, @attr.setter y @attr.deleter realmente.
# @property es azúcar para:
# temperature = property(fget=get_temp, fset=set_temp)
class Temperature:
def __init__(self, celsius: float):
self._celsius = celsius
# Versión explicita (sin @)
def _get_celsius(self):
return self._celsius
def _set_celsius(self, value: float):
if value < -273.15:
raise ValueError("Temperatura bajo el cero absoluto")
self._celsius = value
celsius = property(_get_celsius, _set_celsius)
# Propiedad derivada (calculada, sin setter)
@property
def fahrenheit(self) -> float:
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, f: float):
self.celsius = (f - 32) * 5/9
t = Temperature(100.0)
print(t.celsius) # 100.0
print(t.fahrenheit) # 212.0
t.fahrenheit = 32.0
print(t.celsius) # 0.0
# Inspeccionar: property es un descriptor
print(type(Temperature.__dict__["celsius"])) # <class 'property'>
print(type(Temperature.__dict__["fahrenheit"])) # <class 'property'>
# property.__get__, property.__set__ existen
p = Temperature.__dict__["fahrenheit"]
print(p.fget) # <function Temperature.fahrenheit at 0x...>
print(p.fset) # <function Temperature.fahrenheit at 0x...>100.0
212.0
0.0
<class 'property'>
<class 'property'>