Descriptores en Python: __get__, __set__, __delete__ y el protocolo que hay detrás de property

Los descriptores en Python son el mecanismo subyacente que hace posibles property, classmethod y staticmethod. Todo acceso a un atributo de clase pasa antes por el protocolo descriptor: si el objeto encontrado en la clase define __get__, Python lo llama en lugar de devolver el objeto directamente. Entender esto te permite crear atributos con validación automática, campos de ORM propios y proxies transparentes.

El protocolo descriptor

Un descriptor es cualquier objeto que implemente al menos uno de estos métodos:

  • __get__(self, obj, objtype=None) — se invoca al leer el atributo
  • __set__(self, obj, value) — se invoca al asignar
  • __delete__(self, obj) — se invoca al ejecutar del

Si el descriptor define tanto __get__ como __set__ (o __delete__), se llama data descriptor y tiene prioridad sobre el __dict__ de la instancia. Si solo define __get__, es un non-data descriptor y la instancia puede sobreescribirlo con una clave en su __dict__.

# Non-data descriptor: solo __get__
class MiDescriptor:
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self          # acceso desde la clase
        return 42

class MiClase:
    valor = MiDescriptor()

mc = MiClase()
print(mc.valor)        # 42  — llama a __get__
mc.__dict__['valor'] = 99
print(mc.valor)        # 99  — el __dict__ tiene prioridad en non-data

Data descriptor: prioridad sobre __dict__

class DataDescriptor:
    def __set_name__(self, owner, name):
        self.nombre_privado = f'_{name}'

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.nombre_privado, None)

    def __set__(self, obj, value):
        setattr(obj, self.nombre_privado, value)

class Punto:
    x = DataDescriptor()
    y = DataDescriptor()

p = Punto()
p.x = 10
p.y = 20
print(p.x, p.y)   # 10 20
print(p.__dict__)  # {'_x': 10, '_y': 20}

__set_name__ (disponible desde Python 3.6) se llama automáticamente cuando la clase se define, pasando el nombre del atributo. Es la forma correcta de que un descriptor sepa cómo se llama sin necesidad de pasárselo manualmente.

Implementar property desde cero

El builtin property es sencillamente un data descriptor. Aquí tienes una implementación mínima fiel al original:

class miproperty:
    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        self.__doc__ = doc or (fget.__doc__ if fget else None)

    def __set_name__(self, owner, name):
        self.atrib = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError(f"'{self.atrib}' no tiene getter")
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError(f"'{self.atrib}' es de solo lectura")
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError(f"'{self.atrib}' no se puede borrar")
        self.fdel(obj)

    def getter(self, fget):
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel, self.__doc__)


class Temperatura:
    def __init__(self, celsius: float):
        self._celsius = celsius

    @miproperty
    def celsius(self) -> float:
        """Temperatura en grados Celsius."""
        return self._celsius

    @celsius.setter
    def celsius(self, valor: float):
        if valor < -273.15:
            raise ValueError(f"Temperatura {valor} por debajo del cero absoluto")
        self._celsius = valor

    @miproperty
    def fahrenheit(self) -> float:
        return self._celsius * 9 / 5 + 32


t = Temperatura(100)
print(t.fahrenheit)   # 212.0
t.celsius = -10
print(t.fahrenheit)   # 14.0

Descriptor de validación reutilizable

Uno de los usos más prácticos de los descriptores es crear campos tipados con validación que se reutilizan en varias clases sin repetir código:

class CampoEntero:
    def __set_name__(self, owner, name):
        self.nombre = name
        self.nombre_privado = f'_campo_{name}'

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.nombre_privado, 0)

    def __set__(self, obj, value):
        if not isinstance(value, int):
            raise TypeError(
                f"'{self.nombre}' requiere int, recibido {type(value).__name__}"
            )
        if value < 0:
            raise ValueError(f"'{self.nombre}' debe ser >= 0, recibido {value}")
        setattr(obj, self.nombre_privado, value)


class Producto:
    stock = CampoEntero()
    precio_centimos = CampoEntero()

    def __init__(self, nombre: str, stock: int, precio_centimos: int):
        self.nombre = nombre
        self.stock = stock
        self.precio_centimos = precio_centimos

    @property
    def precio(self) -> float:
        return self.precio_centimos / 100


p = Producto("Libro Python", 50, 2999)
print(p.precio)      # 29.99
p.stock = -1         # ValueError: 'stock' debe ser >= 0, recibido -1
p.stock = "muchos"   # TypeError: 'stock' requiere int, recibido str

Descriptores en el contexto de clases: objtype

Cuando accedes al descriptor desde la clase (no desde una instancia), obj es None y objtype es la propia clase. Esto permite implementar comportamientos distintos según el contexto:

class Registrador:
    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            # Acceso desde la clase: devuelvo info del descriptor
            return f"Descriptor '{self.name}' en {objtype.__name__}"
        return getattr(obj, f'_{self.name}', None)

    def __set__(self, obj, value):
        print(f"[LOG] Asignando {self.name} = {value!r}")
        setattr(obj, f'_{self.name}', value)


class Usuario:
    email = Registrador()
    nombre = Registrador()

print(Usuario.email)      # Descriptor 'email' en Usuario
u = Usuario()
u.email = "[email protected]"  # [LOG] Asignando email = '[email protected]'
print(u.email)            # [email protected]

Cuándo usar descriptores vs property

Usa property cuando la lógica es específica de una clase concreta. Opta por un descriptor cuando el mismo comportamiento de validación o transformación se repite en varias clases, como ocurre en ORMs (Django Models, SQLAlchemy), frameworks de formularios o sistemas de configuración tipada.

Los descriptores son la base del sistema de atributos de Python. Comprender su funcionamiento hace que herramientas como functools.cached_property, los métodos de instancia, los métodos de clase y los métodos estáticos dejen de ser magia para convertirse en patrones claros y reproducibles.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP