Los descriptors en Python son uno de los mecanismos que hacen posibles propiedades, métodos, validaciones, ORMs y muchas funciones de frameworks. Aunque al principio parecen complejos, la idea central es sencilla: un objeto almacenado en una clase puede controlar qué ocurre cuando otro atributo se lee, se modifica o se elimina. En esta guía aprenderás el protocolo descriptor, crearás ejemplos reutilizables con __get__, __set__, __delete__ y __set_name__, y entenderás cuándo conviene usarlo.
Como se trata de una característica avanzada orientada a objetos, resulta útil dominar clases, instancias y funciones. También puedes repasar decoradores en Python, dataclasses en Python, type hints en Python y herencia múltiple y MRO.
¿Qué es un descriptor en Python?
Un descriptor es un objeto que implementa al menos uno de los métodos especiales __get__, __set__ o __delete__. Cuando ese objeto se asigna como atributo de clase, Python llama automáticamente a esos métodos durante el acceso al atributo. Esto permite centralizar validación, conversión, registro de eventos, carga diferida, control de acceso y valores calculados.
Este comportamiento forma parte del modelo de datos oficial. La sección de descriptors del modelo de datos explica las reglas de búsqueda, mientras que la guía oficial de descriptors muestra cómo funciones, métodos y propiedades utilizan el mismo protocolo.
Primer descriptor con __get__
El descriptor más sencillo puede definir únicamente __get__. El siguiente ejemplo devuelve un saludo cuando se accede desde una instancia y devuelve el propio descriptor cuando se accede desde la clase.
class Saludo:
def __get__(self, instance, owner):
if instance is None:
return self
return f"Hola, {instance.nombre}!"
class Usuario:
mensaje = Saludo()
def __init__(self, nombre):
self.nombre = nombre
usuario = Usuario("Ana")
print(usuario.mensaje)
print(Usuario.mensaje)El argumento instance representa el objeto que solicitó el atributo. Recibe None cuando el acceso se realiza directamente desde la clase. El argumento owner contiene la clase propietaria. Devolver self en el acceso de clase facilita la inspección y la configuración.
Validación con __set__
La validación es uno de los usos más prácticos. El siguiente descriptor acepta únicamente enteros no negativos y almacena el valor en cada instancia.
class EnteroPositivo:
def __set_name__(self, owner, name):
self.nombre_publico = name
self.nombre_privado = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.nombre_privado)
def __set__(self, instance, value):
if not isinstance(value, int):
raise TypeError(
f"{self.nombre_publico} debe ser entero"
)
if value < 0:
raise ValueError(
f"{self.nombre_publico} no puede ser negativo"
)
setattr(instance, self.nombre_privado, value)
class Persona:
edad = EnteroPositivo()
def __init__(self, edad):
self.edad = edadEl método __set_name__ se ejecuta cuando Python crea la clase propietaria. Informa al descriptor el nombre del atributo asignado, lo que permite reutilizar la misma clase descriptor en muchos campos sin configurar cada nombre manualmente.
Data y non-data descriptors
Los descriptors suelen dividirse en dos grupos. Un data descriptor implementa __set__ o __delete__, y también puede implementar __get__. Un non-data descriptor implementa solo __get__. La diferencia importa porque Python les concede distinta prioridad durante la búsqueda de atributos.
- Los data descriptors tienen prioridad sobre los valores del diccionario de la instancia.
- Los non-data descriptors pueden ser ocultados por un atributo de instancia con el mismo nombre.
- Los métodos normales son non-data descriptors.
- Los objetos
propertyson data descriptors.
Esta prioridad explica por qué escribir directamente en instance.__dict__ no siempre cambia el resultado de un atributo. También conecta los descriptors con la herencia y el orden de resolución de métodos.
Cómo property utiliza descriptors
El decorador @property crea un objeto descriptor. Ese objeto guarda funciones getter, setter y deleter, y las llama cuando el atributo se utiliza. En muchas clases, una propiedad es la solución más clara porque el comportamiento pertenece a un campo concreto.
class Producto:
def __init__(self, precio):
self.precio = precio
@property
def precio(self):
return self._precio
@precio.setter
def precio(self, value):
if value < 0:
raise ValueError("El precio no puede ser negativo")
self._precio = float(value)Elige una propiedad cuando la regla se usa en una sola clase. Elige un descriptor personalizado cuando el mismo comportamiento debe reutilizarse en varios campos o clases. Esta comparación evita abstracciones innecesarias.
Descriptor reutilizable para textos
El siguiente descriptor valida textos obligatorios, elimina espacios laterales y aplica una longitud máxima.
class TextoObligatorio:
def __init__(self, maximo=100):
self.maximo = maximo
def __set_name__(self, owner, name):
self.nombre = name
self.nombre_almacenamiento = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.nombre_almacenamiento)
def __set__(self, instance, value):
if not isinstance(value, str):
raise TypeError(f"{self.nombre} debe ser texto")
value = value.strip()
if not value:
raise ValueError(f"{self.nombre} es obligatorio")
if len(value) > self.maximo:
raise ValueError(
f"{self.nombre} supera {self.maximo} caracteres"
)
setattr(instance, self.nombre_almacenamiento, value)
class Articulo:
titulo = TextoObligatorio(80)
autor = TextoObligatorio(50)
def __init__(self, titulo, autor):
self.titulo = titulo
self.autor = autorEste patrón reduce repetición y mantiene las clases de dominio más limpias. Aun así, los mensajes de error deben ser específicos, porque una excepción genérica dentro de un acceso automático resulta difícil de diagnosticar.
Guardar valores por instancia
Un error frecuente consiste en guardar el valor actual dentro del descriptor, por ejemplo con self.value. El descriptor pertenece a la clase y se comparte entre todas las instancias, por lo que distintos objetos terminarían compartiendo datos. Los valores deben almacenarse en la instancia, normalmente bajo un nombre privado.
Otra opción es weakref.WeakKeyDictionary, que asocia instancias y valores sin impedir que las instancias se liberen de memoria. Puede ayudar cuando el objeto propietario no permite atributos adicionales, pero introduce más complejidad.
Evitar recursión infinita
Dentro de __set__, volver a asignar el atributo público activa otra vez el descriptor. Por ejemplo, ejecutar instance.edad = value dentro del descriptor de edad llama a __set__ indefinidamente. Utiliza un nombre de almacenamiento distinto, como _edad, escribe de forma controlada en __dict__ o emplea otra estrategia segura.
La misma precaución se aplica a __get__. Leer el nombre público desde su propio descriptor repite la búsqueda. Una convención clara de nombres privados evita este problema.
Descriptors y herencia
Los descriptors participan en la herencia normal de clases. Una subclase puede heredarlos, sustituirlos o declarar otro atributo con el mismo nombre. Los data descriptors suelen conservar prioridad sobre los valores de instancia, mientras que los non-data descriptors pueden ser ocultados.
Conviene probar explícitamente el comportamiento de subclases, especialmente cuando un framework utiliza descriptors para declarar campos. El MRO, las metaclases y los atributos generados pueden cambiar la clase propietaria recibida por __get__.
Descriptors y dataclasses
Los descriptors pueden combinarse con dataclasses, pero los valores predeterminados y el orden de inicialización requieren atención. Una dataclass puede interpretar el descriptor como valor por defecto, mientras el descriptor espera controlar las asignaciones.
En modelos sencillos, field, una propiedad o la validación en __post_init__ pueden ser más fáciles de mantener. Usa un descriptor cuando el comportamiento de campo se repite; utiliza validación de dataclass cuando una regla depende de varios atributos a la vez.
Pruebas de descriptors
Las pruebas deben cubrir asignaciones válidas, tipos incorrectos, rangos inválidos, acceso desde la clase, eliminación si está soportada e independencia entre instancias.
import pytest
def test_valores_independientes():
primera = Persona(20)
segunda = Persona(40)
assert primera.edad == 20
assert segunda.edad == 40
def test_edad_negativa():
with pytest.raises(ValueError):
Persona(-1)También es recomendable probar serialización, copia e inspección si el descriptor forma parte de una biblioteca. El acceso a atributos parece simple desde fuera, pero puede ejecutar bastante lógica interna.
Cuándo usar descriptors
- Validación reutilizable en varias clases.
- Conversión y normalización automáticas.
- Cálculo diferido o atributos en caché.
- Mapeo de campos de bases de datos en ORMs.
- Auditoría de lecturas y escrituras.
- Diseño de frameworks y bibliotecas.
No conviene usarlos cuando una función, una validación en el constructor o una propiedad resuelven el problema de manera clara. El comportamiento automático es potente, pero puede sorprender a quien no sabe que el acceso al atributo ejecuta código personalizado.
Errores comunes
- Guardar valores en el objeto descriptor compartido.
- Usar internamente el nombre público y provocar recursión.
- No tratar el acceso de clase cuando
instanceesNone. - Crear una abstracción compleja para una regla usada una sola vez.
- No probar herencia, serialización e inspección.
- Ocultar operaciones costosas detrás de un atributo aparentemente simple.
Conclusión
Los descriptors en Python ofrecen control reutilizable sobre el acceso a atributos y ayudan a entender cómo funcionan métodos, propiedades y campos de muchos frameworks. El protocolo utiliza pocos métodos especiales, pero un diseño correcto exige almacenamiento por instancia, errores previsibles y conocimiento de la prioridad de búsqueda.
Empieza con un descriptor pequeño, pruébalo con varias instancias y compáralo con una propiedad o una función de validación. Cuando el mismo comportamiento se repite de verdad en distintas clases, un descriptor bien diseñado puede reducir duplicación y mantener reglas consistentes sin complicar la API pública.





