Cuando escribes self.nombre = "Ana" en una clase que usa un property, Python no está asignando un valor a un diccionario. Está llamando a un método. Eso ocurre porque property no es azúcar sintáctico especial del intérprete: es un descriptor, un objeto ordinario de Python cuya clase implementa uno o más de los métodos __get__, __set__ o __delete__. Eso es todo el protocolo. Y sobre ese protocolo está construido casi todo lo que parece “mágico” en Python: property, classmethod, staticmethod, los campos de Django y las columnas de SQLAlchemy.
El mecanismo de resolución de atributos
Cuando accedes a instancia.attr, Python no va directamente al __dict__ de la instancia. El orden exacto que sigue object.__getattribute__ es este:
- Busca
attrentype(instancia).__mro__(la clase y sus bases). - Si encuentra el nombre y el objeto que lo ocupa implementa
__get__y además__set__o__delete__, ese objeto es un descriptor de datos. Gana. Se llamadescriptor.__get__(instancia, type(instancia))y se retorna el resultado. - Si no es descriptor de datos, mira
instancia.__dict__. Siattrestá ahí, retorna ese valor. - Si tampoco está en
__dict__, comprueba si el objeto de la clase tiene al menos__get__. Si sí, es un descriptor de no-datos. Se llama__get__y se retorna. - Si no hay nada de eso,
AttributeError.
La distinción crítica es la prioridad. Un descriptor de datos —como property— intercepta tanto lecturas como escrituras incluso cuando la instancia tiene una clave con el mismo nombre en su __dict__. Un descriptor de no-datos —como classmethod o staticmethod— cede el paso al __dict__ de la instancia. Eso no es un detalle: es la razón por la que puedes “sombrear” un método de clase con un atributo de instancia pero no puedes evadir un property asignando directamente.
El otro lado del protocolo es __set_name__(self, owner, name), añadido en Python 3.6. Cuando la metaclase procesa la definición de la clase, llama a este método en cada descriptor que encuentre como valor de clase, pasándole la clase propietaria y el nombre con el que fue asignado. Así el descriptor sabe en qué atributo vive sin que nadie se lo tenga que decir explícitamente.
Cuando un descriptor necesita guardar estado por instancia, no puede usar self.valor = ... en sí mismo (eso crearía un ciclo o contaminaría el namespace del descriptor). La solución idiomática es escribir en instance.__dict__ directamente, o usar un WeakKeyDictionary para no interferir con el ciclo de vida del objeto.
Ejemplo completo
from __future__ import annotations
import weakref
from typing import Any, Callable, TypeVar, overload
T = TypeVar("T")
class Validated:
"""
Descriptor de datos que valida con una función arbitraria y almacena el
valor en el __dict__ de la instancia bajo una clave privada.
"""
def __set_name__(self, owner: type, name: str) -> None:
# El nombre público ("precio") y la clave donde guardamos el valor.
self.public_name = name
self.private_name = f"_validated_{name}"
def __init__(self, validator: Callable[[Any], Any]) -> None:
self.validator = validator
# __set_name__ se llama después de __init__; los nombres se asignan
# allí, pero los inicializamos para que el type checker no se queje.
self.public_name = ""
self.private_name = ""
def __get__(self, instance: Any, owner: type | None = None) -> Any:
if instance is None:
# Acceso desde la clase misma: devolvemos el descriptor.
return self
return instance.__dict__.get(self.private_name)
def __set__(self, instance: Any, value: Any) -> None:
# Validar antes de guardar; si lanza, el atributo no se modifica.
validated = self.validator(value)
instance.__dict__[self.private_name] = validated
def __delete__(self, instance: Any) -> None:
instance.__dict__.pop(self.private_name, None)
class LazyProperty:
"""
Descriptor de NO-datos para lazy loading: calcula el valor la primera vez
y lo guarda en __dict__ de la instancia. Las llamadas posteriores van
directo al dict sin pasar por aquí, porque un descriptor de no-datos
pierde frente al __dict__ de la instancia.
"""
def __init__(self, func: Callable[..., Any]) -> None:
self.func = func
self.attr_name = ""
def __set_name__(self, owner: type, name: str) -> None:
self.attr_name = name
def __get__(self, instance: Any, owner: type | None = None) -> Any:
if instance is None:
return self
# Calculamos y guardamos en __dict__. La próxima vez Python encontrará
# el valor aquí antes de llegar al descriptor (paso 3 del MRO).
value = self.func(instance)
instance.__dict__[self.attr_name] = value
return value
# ── Clases de demostración ───────────────────────────────────────────────
def positive_float(value: Any) -> float:
value = float(value)
if value <= 0:
raise ValueError(f"Debe ser positivo, recibido: {value}")
return value
def non_empty_str(value: Any) -> str:
value = str(value).strip()
if not value:
raise ValueError("No puede estar vacío o en blanco")
return value
class Product:
name: str = Validated(non_empty_str) # descriptor de datos
price: float = Validated(positive_float) # descriptor de datos
def __init__(self, name: str, price: float, cost: float) -> None:
self.name = name # pasa por Validated.__set__
self.price = price # pasa por Validated.__set__
self._cost = cost
@LazyProperty # descriptor de no-datos
def margin(self) -> float:
print(" [calculando margen...]")
return (self.price - self._cost) / self.price
# ── Ejercicio ────────────────────────────────────────────────────────────
p = Product("Teclado mecánico", 120.0, 45.0)
print(p.name) # "Teclado mecánico" — pasa por __get__ del descriptor
print(p.price) # 120.0
print(p.margin) # imprime "[calculando margen...]" y retorna 0.625
print(p.margin) # silencioso: ya está en p.__dict__, no hay __get__ del descriptor
try:
p.price = -5
except ValueError as e:
print(f"Error esperado: {e}")
try:
p.name = " "
except ValueError as e:
print(f"Error esperado: {e}")
# Acceso desde la clase: __get__ retorna el descriptor mismo, no un valor.
assert isinstance(Product.name, Validated)
Lo que cada decisión revela
Validated implementa los tres métodos —__get__, __set__ y __delete__— así que es un descriptor de datos. Eso garantiza que ninguna asignación directa a instance.__dict__ pueda evadir la validación, porque el descriptor tiene prioridad sobre el diccionario. Si solo implementara __get__, bastaría con hacer p.__dict__["price"] = -5 para saltar la guardia.
La clave _validated_price en lugar de price es deliberada. Si guardáramos en instance.__dict__["price"], el descriptor seguiría ganando en las lecturas —porque es descriptor de datos— pero en __set__ entraríamos en recursión infinita si alguna vez intentáramos leer el valor desde ahí. Separar el nombre público del nombre de almacenamiento interno es la convención estándar.
__set_name__ es lo que hace innecesario el viejo patrón de declarar descriptores con nombres explícitos. En Python 3.5 y antes, tenías que escribir name = Validated(non_empty_str, "name") pasando el nombre como argumento. Hoy Python te lo da gratis en el momento de construir la clase.
LazyProperty intencionalmente no implementa __set__. Eso lo convierte en descriptor de no-datos. La primera vez que accedes a p.margin, Python no encuentra "margin" en p.__dict__, llega al descriptor y llama a __get__, que calcula el valor y lo escribe en p.__dict__["margin"]. La segunda vez, en el paso 3 del algoritmo de resolución, Python encuentra "margin" directamente en el diccionario y retorna sin consultar el descriptor. El caching es gratuito y sin locks porque escribe una sola vez. Esto es exactamente lo que hace functools.cached_property internamente.
Lo que hacen Django y SQLAlchemy es el mismo protocolo llevado más lejos. Un Column de SQLAlchemy registrado como atributo de clase es un InstrumentedAttribute, un descriptor de datos que en __get__ no retorna el valor almacenado sino que lo extrae del “state” del objeto ORM, y en __set__ emite eventos que el sistema de tracking de cambios usa para generar SQL. TextField de Django hace algo similar: el descriptor gestiona la diferencia entre acceder al campo desde la clase (para construir queries tipo User.name == "Ana") y desde una instancia (para leer el valor concreto). Toda la magia de filter(name="Ana") empieza en un __get__ que detecta instance is None y retorna el descriptor mismo, sobre el que luego se puede llamar __eq__ para construir expresiones SQL.
Errores que debes conocer
Error: implementar __set__ con recursión al asignar al mismo nombre del atributo dentro del descriptor.
# ❌ Wrong
class Bad:
def __set__(self, instance, value):
instance.price = value # llama de nuevo a __set__ → RecursionError
# ✅ Right
class Good:
def __set_name__(self, owner, name):
self.private_name = f"_{name}"
def __set__(self, instance, value):
instance.__dict__[self.private_name] = value # escritura directa al dict
Ir directamente a instance.__dict__ corta la cadena de dispatch del descriptor.
Error: compartir estado mutable por instancia en el descriptor en lugar de en la instancia.
# ❌ Wrong
class SharedState:
def __init__(self):
self._value = None # un único valor para TODAS las instancias
def __get__(self, instance, owner):
return self._value
def __set__(self, instance, value):
self._value = value # p1.x = 1 también cambia p2.x
# ✅ Right
class PerInstance:
def __set_name__(self, owner, name):
self.private_name = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.private_name)
def __set__(self, instance, value):
instance.__dict__[self.private_name] = value
El descriptor es un objeto de clase, no de instancia; si guardas datos en self, los compartes entre todos los objetos que usen esa clase.
Error: olvidar el guard if instance is None en __get__.
# ❌ Wrong
class NaiveDescriptor:
def __get__(self, instance, owner):
return instance.__dict__.get(self.private_name) # AttributeError si instance es None
# ✅ Right
class SafeDescriptor:
def __get__(self, instance, owner):
if instance is None:
return self # acceso desde la clase: retorna el descriptor mismo
return instance.__dict__.get(self.private_name)
Cuando accedes a MiClase.atributo, instance llega como None; sin el guard obtienes un AttributeError críptico en vez del descriptor, lo que rompe introspección, help() y cualquier framework que inspeccione los atributos de la clase.
N° 133