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 condel
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:
- Data descriptors definidos en la clase del objeto
- Atributos de la instancia (el
__dict__del objeto) - 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:
- Python Descriptor HowTo (documentación oficial)
- Modelo de datos: Descriptors (documentación oficial)
- Real Python: Tutorial de Descriptores en Python
- PEP 487 — Simplificación de la creación de clases
- Documentación oficial: property()
- GeeksforGeeks: Descriptor en Python
- ORM de Django: Modelos y campos (uso de descriptores)
- Tutorial ORM de SQLAlchemy (lazy loading con descriptores)