Método __getattribute__ y descriptores de datos en Python

Método especial __getattribute__()

El método __getattribute__() es una característica especial de las clases en Python que permite controlar el acceso a los atributos de una manera muy flexible.

Características principales:

  • Se invoca siempre que se accede a un atributo, a diferencia de __getattr__() que solo se ejecuta cuando el atributo no se encuentra en la instancia ni en la clase.
  • Si una clase define tanto __getattribute__() como __getattr__(), el método __getattr__() solo se ejecutará si __getattribute__() lanza una excepción AttributeError.
  • Para evitar recursión infinita al acceder a atributos dentro de __getattribute__(), se debe llamar al método de la clase padre usando super(obj, self).__getattribute__(attr).
  • Este método solo funciona en clases de nuevo estilo (que heredan de object).

Ejemplo de __getattr__ tradicional

class Persona:
    def __init__(self, nombre):
        self.nombre = nombre
    
    def __getattr__(self, elemento):
        print('Método __getattr__ ejecutado')
        # return self.__dict__[elemento]

p1 = Persona('Carlos')
print(p1.nombre)
p1.apellido  # Acceso a atributo inexistente, ejecuta __getattr__

Ejemplo de __getattribute__

class Persona:
    def __init__(self, nombre):
        self.nombre = nombre
    
    def __getattribute__(self, elemento):
        print('Este método siempre se ejecuta')
        # Se ejecuta tanto para atributos existentes como inexistentes

p1 = Persona('Carlos')
p1.nombre
p1.apellido

Ambos métodos juntos

class Persona:
    def __init__(self, nombre):
        self.nombre = nombre
    
    def __getattr__(self, elemento):
        print('Ejecutando __getattr__')
    
    def __getattribute__(self, elemento):
        print('Ejecutando __getattribute__')
        raise AttributeError('Simulando error')

p1 = Persona('Carlos')
p1.nombre
p1.apellido
# Cuando __getattribute__ y __getattr__ coexisten, solo se ejecuta __getattribute__
# a menos que este lance AttributeError

Descriptores en Python

Los descriptores son objetos que permiten personalizar el comportamiento de acceso a atributos. Cuando se accede a un atributo cuyo valor es un descriptor, Python puede reemplazar el comportamiento predeterminado y ejecutar los métodos del descriptor.

Protocolo de descriptores

Un descriptor es cualquier objeto que implementa al menos uno de estos tres métodos:

# Se ejecuta al obtener el valor del atributo
descr.__get__(self, obj, tipo=None) --> valor

# Se ejecuta al establecer el valor del atributo
descr.__set__(self, obj, valor) --> None

# Se ejecuta al eliminar el atributo
descr.__delete__(self, obj) --> None

También se puede expresar como:

__get__(self, instancia, propietario) --> valor
__set__(self, instancia, valor)
__delete__(self, instancia)

Tipos de descriptores

Descriptores de datos: Implementan tanto __get__ como __set__ (y opcionalmente __delete__). Tienen mayor prioridad que los atributos de instancia.

Descriptores no de datos: Solo implementan __get__. Los métodos y funciones regulares son descriptores no de datos.

Reglas de prioridad (de mayor a menor):

  1. Atributos de clase
  2. Descriptores de datos
  3. Atributos de instancia
  4. Descriptores no de datos
  5. Método __getattr__() como último recurso

Ejemplo básico de descriptor

class MiDescriptor:
    def __get__(self, instancia, propietario):
        print("Método __get__ ejecutado")
        return hex(id(instancia))
    
    def __set__(self, instancia, valor):
        print("Método __set__ ejecutado:", valor)
    
    def __delete__(self, instancia):
        print("Método __delete__ ejecutado")

class Datos:
    valor = MiDescriptor()

d = Datos()
d.valor        # Llama a __get__
d.valor = 50   # Llama a __set__
del d.valor    # Llama a __delete__
Datos.valor    # __get__ con instancia = None

Desarrollando un descriptor para validación de tipos

class Validador:
    def __init__(self, nombre, tipo_esperado):
        self.nombre = nombre
        self.tipo_esperado = tipo_esperado
    
    def __get__(self, instancia, propietario):
        if instancia is None:
            return self
        return instancia.__dict__[self.nombre]
    
    def __set__(self, instancia, valor):
        if not isinstance(valor, self.tipo_esperado):
            raise TypeError(f'Se esperaba {self.tipo_esperado.__name__}')
        instancia.__dict__[self.nombre] = valor
    
    def __delete__(self, instancia):
        del instancia.__dict__[self.nombre]

class Empleado:
    nombre = Validador('nombre', str)
    edad = Validador('edad', int)
    salario = Validador('salario', float)
    
    def __init__(self, nombre, edad, salario):
        self.nombre = nombre
        self.edad = edad
        self.salario = salario

# Uso correcto
emp1 = Empleado('Ana', 30, 2500.50)
print(emp1.nombre)

# Uso incorrecto - lanza excepción
emp2 = Empleado(123, 25, 3000)  # TypeError

Usando decoradores para descriptores automáticamente

def validar_tipos(**kwargs):
    def decorador(clase):
        for nombre, tipo in kwargs.items():
            setattr(clase, nombre, Validador(nombre, tipo))
        return clase
    return decorador

@validar_tipos(nombre=str, edad=int, salario=float)
class Persona:
    def __init__(self, nombre, edad, salario):
        self.nombre = nombre
        self.edad = edad
        self.salario = salario

p1 = Persona('Luis', 28, 1500.75)

El decorador @property

El decorador @property es un descriptor de datos que permite definir métodos que se comportan como atributos.

Uso básico

class Habitacion:
    def __init__(self, nombre, ancho, largo):
        self.nombre = nombre
        self.ancho = ancho
        self.largo = largo
    
    @property
    def area(self):
        return self.ancho * self.largo

r1 = Habitacion('Sala', 5, 4)
print(r1.area)  # 20

Property con setter y deleter

class Producto:
    def __init__(self):
        self._precio = 100
        self._descuento = 0.2
    
    @property
    def precio_final(self):
        return self._precio * (1 - self._descuento)
    
    @precio_final.setter
    def precio_final(self, valor):
        self._precio = valor
    
    @precio_final.deleter
    def precio_final(self):
        del self._precio

p = Producto()
print(p.precio_final)  # 80.0
p.precio_final = 200
print(p.precio_final)  # 160.0

Implementando nuestro propio @property

class PropiedadDiferida:
    def __init__(self, func):
        self.func = func
    
    def __get__(self, instancia, propietario):
        if instancia is None:
            return self
        valor = self.func(instancia)
        setattr(instancia, self.func.__name__, valor)  # Caché
        return valor

class Rectangulo:
    def __init__(self, ancho, largo):
        self.ancho = ancho
        self.largo = largo
    
    @PropiedadDiferida
    def area(self):
        print('Calculando área...')
        return self.ancho * self.largo

r = Rectangulo(3, 4)
print(r.area)  # Calcula y guarda en caché
print(r.area)  # Usa el valor cacheado

Métodos estáticos y de clase

Los métodos estáticos y de clase también utilizan el protocolo de descriptores internamente.

Implementando @staticmethod

class MetodoEstatico:
    def __init__(self, func):
        self.func = func
    
    def __get__(self, instancia, propietario):
        return self.func

class Calculadora:
    @MetodoEstatico
    def sumar(x, y):
        return x + y

print(Calculadora.sumar(2, 3))  # 5
print(Calculadora().sumar(4, 5))  # 9

Implementando @classmethod

class MetodoClase:
    def __init__(self, func):
        self.func = func
    
    def __get__(self, instancia, propietario):
        if propietario is None:
            propietario = type(instancia)
        
        def wrapper(*args, **kwargs):
            return self.func(propietario, *args, **kwargs)
        return wrapper

class Usuario:
    nombre = 'invitado'
    
    @MetodoClase
    def saludar(cls):
        print(f'Hola, {cls.nombre}')

Usuario.saludar()  # Hola, invitado
Usuario().saludar()  # Hola, invitadoclass Data(object):
    def test(self):
        print("test")

d = Data()

print d.test      # Solo bound method pasa self implícitamente
print Data.test.__get__(d, Data)   # Mismo resultado
print Data.test     # Unbound method necesita self explícito
print Data.test.__get__(None, Data)  # instance = None

Resumen de descriptores

Los descriptores son la base de muchas características de Python:

  • @property - Define propiedades con getter, setter y deleter
  • @staticmethod - Métodos que no requieren instancia
  • @classmethod - Métodos que reciben la clase como primer argumento
  • Funciones regulares - También son descriptores no de datos
  • __slots__ - Utiliza descriptores internamente

El protocolo de descriptores permite implementar validaciones, cachés, propiedades calculadas y muchas otras características avanzadas en Python. Es una herramienta fundamental para el desarrollo de frameworks y bibliotecas que requieren control granular sobre el comportamiento de los atributos.

Etiquetas: Python descriptors __getattribute__ __getattr__ property-decorator

Publicado el 7-1 21:14