¿Qué es un descriptor?
Un descriptor es una clase que implementa al menos uno de los métodos especiales __get__, __set__ o __delete__. Cuando un atributo de clase es un descriptor, acceder, modificar o eliminar ese atributo a través de la propia clase o de una instancia desencadena estos métodos.
class Descriptor:
def __get__(self, instancia, propietario):
return {'instancia': instancia, 'propietario': propietario, 'valor': 12345}
desc_global = Descriptor() # descriptor a nivel de módulo
class A:
prop = desc_global
class B:
def __init__(self):
self.prop = desc_global # no es atributo de clase, no funciona como descriptor
class C:
desc_clase = Descriptor() # descriptor definido dentro de la clase
def __init__(self):
# Las tres líneas siguientes asignan el resultado de __get__ al __dict__ de la instancia, pero con diferencias en 'instancia'
self.prop = self.desc_clase # instancia: objeto C
# self.prop = C.desc_clase # instancia: None
# self.prop = type(self).desc_clase # instancia: None
a = A()
b = B()
c = C()
print('a.prop:', a.prop)
print('b.prop:', b.prop)
print('c.prop:', c.prop)
print('-' * 30, 'Separador', '-' * 30)
print(A.prop)
print(C.desc_clase)
print('-' * 30, 'Separador', '-' * 30)
print(a.__dict__) # vacío
print(c.__dict__) # contiene el resultado de __get__ porque en __init__ se accedió mediante self.desc_clase
Salida:
a.prop: {'instancia': <__main__.A object at 0x...>, 'propietario': <class '__main__.A'>, 'valor': 12345}
b.prop: <__main__.Descriptor object at 0x...>
c.prop: {'instancia': <__main__.C object at 0x...>, 'propietario': <class '__main__.C'>, 'valor': 12345}
------------------------- Separador -------------------------
{'instancia': None, 'propietario': <class '__main__.A'>, 'valor': 12345}
{'instancia': None, 'propietario': <class '__main__.C'>, 'valor': 12345}
------------------------- Separador -------------------------
{}
{'prop': {'instancia': <__main__.C object at 0x...>, 'propietario': <class '__main__.C'>, 'valor': 12345}}
Descriptor sin datos (non-data descriptor)
Solo implementa __get__. Cuando se accede a un atributo con el mismo nombre desde una instancia, la instancia tiene prioridad si tiene ese atributo en su propio __dict__.
class DescriptorSinDatos:
def __get__(self, instancia, propietario):
return {'instancia': instancia, 'propietario': propietario, 'valor': 12345}
class D:
attr = DescriptorSinDatos()
def __init__(self, valor):
self.attr = valor # esto guarda en la instancia, no llama al descriptor
d = D('88888')
print('d.attr:', d.attr) # 88888, porque la instancia tiene su propio atributo
print('-' * 30)
print(D.attr) # Diccionario con instancia=None
print('-' * 30)
print(d.__dict__) # {'attr': '88888'}
Descriptor con datos (data descriptor)
Implementa __set__ o __delete__. Al acceder a un atributo desde una instancia, el descriptro tiene prioridad sobre el __dict__ de la instancia.
class DescriptorConDatos:
def __get__(self, instancia, propietario):
return {'instancia': instancia, 'propietario': propietario, 'valor': 12345}
def __set__(self, instancia, valor):
instancia.otro_atributo = valor
class E:
attr = DescriptorConDatos()
def __init__(self, valor):
self.attr = valor # Esto llama a __set__, no guarda 'attr' en la instancia
e = E('88888')
print('e.attr:', e.attr) # Llama a __get__
print('-' * 30)
print(E.attr) # instancia=None
print('-' * 30)
print(e.__dict__) # {'otro_atributo': '88888'}
Orden de búsqueda de atributos
Cuando se accede a un atributo mediante una instancia, el orden es:
- Descriptor con datos (buscando en la cadena MRO, incluida la clase base)
- Atributo de instancia (en
__dict__) - Descriptor sin datos (por MRO)
- Atributo de clase normal (por MRO)
class DescriptorPrioridad:
def __get__(self, instancia, propietario):
return 'Soy el descriptor'
def __set__(self, instancia, valor):
instancia.aux = valor
class F:
attr = DescriptorPrioridad()
def __init__(self, valor):
self.attr = valor # Llama a __set__
f = F('valor_inicial')
f.__dict__['attr'] = 'Atributo de instancia' # Forzamos un atributo en la instancia
print('f.attr:', f.attr) # Sigue llamando al descriptor (data descriptor gana)
print('f.__dict__:', f.__dict__)
Implementación equivalente de __getattribute__
El método __getattribute__ (implementado en C) se puede simular en Python para entender el mecanismo interno. A continuación se muestra una versión simplificada que NO incluye el manejo de __getattr__.
def buscar_en_mro(cls, nombre, predeterminado=None):
for base in cls.__mro__:
if nombre in vars(base):
return vars(base)[nombre]
return predeterminado
def getattribute_emulado(obj, nombre):
pred = object()
tipo_obj = type(obj)
attr_clase = buscar_en_mro(tipo_obj, nombre, pred)
getter = getattr(type(attr_clase), '__get__', pred)
if getter is not pred:
if hasattr(type(attr_clase), '__set__') or hasattr(type(attr_clase), '__delete__'):
return getter(attr_clase, obj, tipo_obj) # descriptor con datos
if hasattr(obj, '__dict__') and nombre in vars(obj):
return vars(obj)[nombre] # atributo de instancia
if getter is not pred:
return getter(attr_clase, obj, tipo_obj) # descriptor sin datos
if attr_clase is not pred:
return attr_clase # atributo de clase normal
raise AttributeError(nombre)
# Ejemplo de uso (omitido por brevedad)
ORM simulado con descriptores
Los descriptores se utilizan en ORM como SQLAlchemy. A continuación, un ejemplo que simula una base de datos SQLite.
import sqlite3
conexion = sqlite3.connect(':memory:')
cursor = conexion.cursor()
cursor.execute('CREATE TABLE scores (nombre text, nota real)')
cursor.execute("INSERT INTO scores VALUES ('Ana', 85.0)")
cursor.execute("INSERT INTO scores VALUES ('Luis', 92.0)")
conexion.commit()
class Columna:
def __set_name__(self, propietario, nombre):
self.nombre_col = nombre
self.tabla = propietario.tabla
def __get__(self, instancia, propietario):
sql = f"SELECT {self.nombre_col} FROM {self.tabla} WHERE {propietario.clave}=?"
cursor.execute(sql, (instancia.clave,))
return cursor.fetchone()[0]
def __set__(self, instancia, valor):
sql = f"UPDATE {self.tabla} SET {self.nombre_col}=? WHERE {type(instancia).clave}=?"
cursor.execute(sql, (valor, instancia.clave))
conexion.commit()
class Puntaje:
tabla = 'scores'
clave = 'nombre'
puntuacion = Columna()
def __init__(self, nombre):
self.clave = nombre
print(Puntaje('Ana').puntuacion) # 85.0
Puntaje('Ana').puntuacion = 95.0
print(Puntaje('Ana').puntuacion) # 95.0
Simulación de @property con descriptores
La propiedad incorporada se puede implementar manualmente:
class Propiedad:
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
self.__doc__ = doc
def __get__(self, instancia, propietario):
if instancia is None:
return self
return self.fget(instancia)
def __set__(self, instancia, valor):
if self.fset is None:
raise AttributeError("No tiene setter")
self.fset(instancia, valor)
def setter(self, fset):
self.fset = fset
return self
class Persona:
def __init__(self, nombre):
self._nombre = nombre
@Propiedad
def nombre(self):
return self._nombre.upper()
@nombre.setter
def nombre(self, val):
self._nombre = val
p = Persona('Carlos')
print(p.nombre) # CARLOS
p.nombre = 'Ana'
print(p.nombre) # ANA
Enlace de métodos de instancia con descriptores
Python convierte automáticamente una función definida en una clase en un método enlazado. Podemos smiularlo:
class MetodoEnlazado:
def __init__(self, func, instancia):
self.__func__ = func
self.__self__ = instancia
def __call__(self, *args, **kwargs):
return self.__func__(self.__self__, *args, **kwargs)
class FuncionDescriptor:
def __init__(self, func):
self.func = func
def __get__(self, instancia, propietario):
if instancia is None:
return self.func
return MetodoEnlazado(self.func, instancia)
class MiClase:
@FuncionDescriptor
def saludar(self, nombre):
return f"Hola {nombre}, soy {self}"
obj = MiClase()
print(obj.saludar('Mundo')) # Enlazado
print(MiClase.saludar) # Función sin enlazar
Simulación de @classmethod
class ClassMethodDescriptor:
def __init__(self, fn):
self.fn = fn
def __get__(self, instancia, propietario):
return lambda *args, **kwargs: self.fn(propietario, *args, **kwargs)
class MiClase:
@ClassMethodDescriptor
def metodo_clase(cls, x):
return f"{cls.__name__}: {x}"
print(MiClase.metodo_clase(10)) # MiClase: 10
print(MiClase().metodo_clase(20)) # MiClase: 20
Simulación de @staticmethod
class StaticMethodDescriptor:
def __init__(self, fn):
self.fn = fn
def __get__(self, instancia, propietario):
return self.fn
class MiClase:
@StaticMethodDescriptor
def metodo_estatico(x):
return x * 2
print(MiClase.metodo_estatico(5)) # 10
print(MiClase().metodo_estatico(7)) # 14