Si alguna vez usaste @property en Python y te preguntaste cómo funciona mágicamente, la respuesta está en los descriptores (descriptors). Los descriptores son el mecanismo subyacente que hace posible property, staticmethod, classmethod e incluso el ORM de Django. Entender este concepto avanzado llevará tu comprensión de Python a un nuevo nivel.

En esta guía completa, aprenderás qué son los descriptores, cómo implementarlos, las diferencias entre data y non-data descriptors, casos de uso prácticos y cómo se aplican en frameworks reales. Nos sumergiremos en el protocolo descriptor y entenderemos cómo Python gestiona los atributos internamente.

¿Qué son los Descriptores en Python?

Un descriptor es cualquier objeto de Python que implemente al menos uno de los métodos del protocolo descriptor: __get__, __set__ o __delete__. Cuando un atributo de una clase es un descriptor, Python redirige el comportamiento predeterminado de acceso, asignación y eliminación de atributos hacia estos métodos especiales.

En la práctica, los descriptores te permiten crear atributos con comportamiento personalizado. En lugar de simplemente almacenar y recuperar un valor, puedes ejecutar lógica de validación, transformación, caché o cualquier otra operación cada vez que se acceda o modifique el atributo.

Según la documentación oficial sobre descriptores, este mecanismo es uno de los pilares del modelo de objetos de Python y controla cómo se resuelven los atributos en prácticamente todas las clases que escribes.

Para entender los descriptores, primero debemos repasar cómo Python resuelve los atributos. El método __getattribute__ se llama en cada acceso a atributo, y es donde Python verifica si el atributo es un descriptor. Consulta la documentación de __getattribute__ para una visión detallada de este proceso.

El Protocolo Descriptor

El protocolo descriptor consta de tres métodos mágicos:

  • __get__(self, obj, objtype=None) — se llama cuando se accede al atributo
  • __set__(self, obj, value) — se llama cuando se modifica el atributo
  • __delete__(self, obj) — se llama cuando se elimina el atributo con del

Un objeto que implementa __set__ o __delete__ se llama data descriptor. Un objeto que implementa solo __get__ se llama non-data descriptor. Esta distinción es crucial para entender la precedencia de resolución de atributos en Python.

Observa la implementación más simple posible de un descriptor:

class MiDescriptor:
    def __get__(self, obj, objtype=None):
        return 42

class MiClase: atributo = MiDescriptor()

obj = MiClase() print(obj.atributo) # 42

Cuando accedemos a obj.atributo, Python llama a MiDescriptor.__get__, que retorna 42. Ese es el principio básico. La sección de descriptores del modelo de datos aclara todos los detalles del protocolo.

Data Descriptors vs Non-Data Descriptors

La diferencia entre data y non-data descriptors determina la precedencia en la búsqueda de atributos. Cuando accedes a obj.atributo, Python sigue este orden:

  1. Data descriptors definidos en la clase del objeto
  2. Atributos de la instancia (el __dict__ del objeto)
  3. Non-data descriptors y otros atributos de la clase

Esto significa que un data descriptor siempre tiene prioridad sobre el diccionario de la instancia. Un non-data descriptor, en cambio, puede ser "sobrescrito" por un atributo de instancia con el mismo nombre.

class DataDescriptor:
    def __get__(self, obj, objtype=None):
        return "DATA DESCRIPTOR"
    def __set__(self, obj, value):
        print(f"Estableciendo {value}")

class NonDataDescriptor: def get(self, obj, objtype=None): return "NON-DATA DESCRIPTOR"

class Prueba: data = DataDescriptor() non_data = NonDataDescriptor()

def __init__(self):
    self.data = "instancia"      # ¡Ignorado! Data descriptor gana
    self.non_data = "instancia"  # Funciona! Sobrescribe non-data

t = Prueba() print(t.data) # "DATA DESCRIPTOR" print(t.non_data) # "instancia"

La guía de descriptores de Real Python explica esta jerarquía con ejemplos didácticos que ayudan a fijar el concepto.

Cómo Funciona Property Internamente

La función incorporada property es el ejemplo más conocido de data descriptor. Implementa __get__, __set__ y __delete__ para conectar getters, setters y deleters definidos por el usuario.

Puedes recrear una versión simplificada de property para entender el mecanismo:

class Property:
    def __init__(self, fget=None, fset=None, fdel=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
def __get__(self, obj, objtype=None):
    if obj is None:
        return self
    if self.fget is None:
        raise AttributeError("atributo no legible")
    return self.fget(obj)

def __set__(self, obj, value):
    if self.fset is None:
        raise AttributeError("atributo no modificable")
    self.fset(obj, value)

def __delete__(self, obj):
    if self.fdel is None:
        raise AttributeError("atributo no eliminable")
    self.fdel(obj)

class Persona: def init(self, nombre): self._nombre = nombre

@Property
def nombre(self):
    return self._nombre

@nombre.setter
def nombre(self, valor):
    self._nombre = valor.upper()

La implementación oficial de la property en la biblioteca estándar es más robusta, pero el principio es exactamente este: un data descriptor que delega llamadas a funciones personalizadas.

Casos de Uso Prácticos de Descriptores

Los descriptores resuelven problemas reales de forma elegante. Exploremos aplicaciones prácticas que puedes usar hoy.

1. Validación de Atributos

Validar datos en la asignación es una de las aplicaciones más comunes de los descriptores:

class Validado:
    def __init__(self, tipo, minimo=None, maximo=None):
        self.tipo = tipo
        self.minimo = minimo
        self.maximo = maximo
def __set_name__(self, owner, name):
    self.nombre = name

def __get__(self, obj, objtype=None):
    if obj is None:
        return self
    return obj.__dict__.get(self.nombre)

def __set__(self, obj, value):
    if not isinstance(value, self.tipo):
        raise TypeError(f"{self.nombre} debe ser {self.tipo.__name__}")
    if self.minimo is not None and value < self.minimo:
        raise ValueError(f"{self.nombre} no puede ser menor que {self.minimo}")
    if self.maximo is not None and value > self.maximo:
        raise ValueError(f"{self.nombre} no puede ser mayor que {self.maximo}")
    obj.__dict__[self.nombre] = value

class Producto: nombre = Validado(str, maximo=100) precio = Validado(float, minimo=0)

def __init__(self, nombre, precio):
    self.nombre = nombre
    self.precio = precio

p = Producto("Portátil", 3500.0)

p.precio = -10 # Levanta ValueError

p.nombre = 123 # Levanta TypeError

Observa el uso de __set_name__, un método opcional del protocolo descriptor introducido en Python 3.6. Se llama cuando se crea la clase e informa al descriptor el nombre del atributo al que está asociado. La PEP 487 documenta esta funcionalidad en detalle.

2. Lazy Loading y Propiedades con Caché

Los descriptores pueden retrasar el cálculo de un atributo hasta que sea realmente necesario, almacenando el resultado en caché:

class LazyProperty:
    def __init__(self, func):
        self.func = func
        self.nombre = func.__name__
def __get__(self, obj, objtype=None):
    if obj is None:
        return self
    valor = self.func(obj)
    obj.__dict__[self.nombre] = valor  # Almacena en __dict__ de la instancia
    return valor

class AnalisisDatos: def init(self, datos): self.datos = datos

@LazyProperty
def media(self):
    print("Calculando media...")
    return sum(self.datos) / len(self.datos)

@LazyProperty
def mediana(self):
    print("Calculando mediana...")
    ordenados = sorted(self.datos)
    n = len(ordenados)
    medio = n // 2
    return ordenados[medio] if n % 2 else (ordenados[medio-1] + ordenados[medio]) / 2

a = AnalisisDatos([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) print(a.media) # Calculando media... 5.5 print(a.media) # 5.5 (¡sin recalcular!) print(a.mediana) # Calculando mediana... 5.5

Este patrón se usa ampliamente en ORMs como SQLAlchemy y Django, donde las consultas a la base de datos se difieren hasta el acceso al atributo. Complementa tus estudios con nuestra guía sobre Programación Orientada a Objetos en Python para entender cómo los descriptores encajan en el ecosistema OOP de Python.

3. Descriptores de Unidad (Medidas)

Los descriptores permiten crear atributos con unidades de medida inteligentes:

class Medida:
    def __init__(self, unidad):
        self.unidad = unidad
def __set_name__(self, owner, name):
    self.nombre = name

def __get__(self, obj, objtype=None):
    if obj is None:
        return self
    return obj.__dict__.get(self.nombre)

def __set__(self, obj, value):
    if value < 0:
        raise ValueError(f"{self.nombre} no puede ser negativo")
    obj.__dict__[self.nombre] = f"{value} {self.unidad}"

class Pedido: peso = Medida("kg") altura = Medida("cm")

pedido = Pedido() pedido.peso = 5.5 pedido.altura = 30 print(pedido.peso) # 5.5 kg print(pedido.altura) # 30 cm

Descriptores en Frameworks Populares

Los frameworks de Python hacen un uso intensivo de descriptores. El ORM de Django, por ejemplo, usa descriptores para implementar sus campos de modelo. Cuando defines nombre = models.CharField(max_length=100), CharField es un descriptor que gestiona la lectura y escritura del atributo, incluyendo validación y conversión a SQL.

El sistema de modelos de Django es uno de los ejemplos más didácticos de descriptores en producción. Cada tipo de campo (CharField, IntegerField, ForeignKey) implementa el protocolo descriptor para traducir atributos Python en columnas de base de datos.

SQLAlchemy también usa descriptores extensivamente. Las columnas mapeadas en una clase declarativa son descriptores que interceptan el acceso para cargar datos de la base de datos bajo demanda (lazy loading) y rastrear cambios. El tutorial ORM de SQLAlchemy muestra cómo funcionan estos descriptores en la práctica.

El propio Python estándar usa descriptores en varios lugares. Los decoradores @staticmethod y @classmethod son descriptores que modifican cómo se llaman los métodos. La documentación oficial tiene una sección dedicada a métodos estáticos y de clase como descriptores.

Para profundizar en cómo los decoradores se relacionan con los descriptores, consulta nuestro tutorial sobre Decoradores en Python — ambos conceptos van de la mano en el diseño de APIs elegantes.

Descriptores vs Property vs __getattr__

Python ofrece múltiples formas de personalizar el acceso a atributos. Aquí te mostramos cuándo usar cada una:

Mecanismo Alcance Uso recomendado
Descriptor Atributo específico Reutilización entre clases, validación compleja
Property Atributo específico Getter/setter simple, sin reutilización
__getattr__ Todos los atributos Fallback para atributos inexistentes
__getattribute__ Todos los atributos Control total (usar con precaución)

Los descriptores son la opción ideal cuando necesitas el mismo comportamiento validado en múltiples clases o múltiples atributos. Si la lógica es específica de un solo atributo, @property es más simple. El artículo Descriptor in Python de GeeksforGeeks compara estos enfoques con ejemplos prácticos.

Errores Comunes y Buenas Prácticas

Los descriptores son poderosos pero requieren cuidado. Estos son los errores más frecuentes:

1. Olvidar Almacenar en el __dict__ de la Instancia

Un error clásico es almacenar valores en el propio descriptor (que es un atributo de clase) en lugar de en el diccionario de la instancia:

class DescriptorErroneo:
    def __init__(self):
        self.valor = None  # Error: ¡esto se comparte entre instancias!
def __get__(self, obj, objtype=None):
    return self.valor

def __set__(self, obj, value):
    self.valor = value  # Todas las instancias comparten el mismo valor

Siempre almacena datos de la instancia en obj.__dict__, no en self. El descriptor en sí mismo es un atributo de clase y se comparte entre todas las instancias.

2. Confundir Data con Non-Data Descriptors

Recuerda: si implementas __set__ o __delete__, tu descriptor se convierte en un data descriptor y siempre tendrá prioridad sobre los atributos de instancia. Esto puede ser sorprendente si solo querías un __get__ personalizado.

3. Rendimiento

Cada acceso a atributo mediado por un descriptor tiene un costo adicional de llamada a función. Para atributos accedidos millones de veces en bucles internos, considera almacenar en caché (como en el ejemplo de LazyProperty) o usar atributos simples.

La documentación de __setattr__ aclara cómo la asignación de atributos interactúa con los descriptores y el ciclo de vida de los objetos.

Conclusión

Los descriptores son uno de los conceptos más elegantes y subestimados de Python. Forman la base de property, staticmethod, classmethod, ORMs, sistemas de validación y mucho más. Dominar los descriptores significa entender verdaderamente cómo funciona el modelo de objetos de Python.

Ahora sabes implementar tus propios descriptores para validación, lazy loading, caché y atributos con unidades. Más importante aún, entiendes el protocolo descriptor y cómo encaja en la jerarquía de resolución de atributos de Python.

Practica creando descriptores reales: comienza con un validador de tipos, evoluciona a lazy properties y luego explora patrones más avanzados. Cuanto más uses descriptores, más natural será identificar oportunidades donde simplifican tu código.

Referencias para continuar tus estudios: