Entendiendo los Descriptores en Python: Guía Completa con Ejemplos

¿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:

  1. Descriptor con datos (buscando en la cadena MRO, incluida la clase base)
  2. Atributo de instancia (en __dict__)
  3. Descriptor sin datos (por MRO)
  4. 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

Etiquetas: descriptores Python __get__ __set__ __delete__

Publicado el 6-22 03:27