`__getattr__` vs `__getattribute__`: la diferencia que importa

Ambos métodos interceptan el acceso a atributos, pero operan en momentos completamente distintos del proceso de resolución, y confundirlos produce bugs que son casi imposibles de diagnosticar por la forma en que fallan.

__getattribute__ es la puerta de entrada universal: Python lo invoca en absolutamente todo acceso a atributo, obj.x, obj.method, obj.__class__, sin excepción. Es el mecanismo primario de resolución. __getattr__, en cambio, es el fallback: Python solo lo llama cuando la maquinaria normal —__getattribute__ incluida— no encontró el atributo y lanzó AttributeError. Es el mecanismo secundario.

El diseño tiene sentido si lo ves como un pipeline: __getattribute__ siempre se ejecuta primero, busca en __dict__ de la instancia, en __dict__ de la clase, sube por el MRO y, si no encuentra nada, lanza AttributeError. Solo cuando ese error llega sin capturar, Python lo intercepta automáticamente y llama a __getattr__. La separación te permite intervenir en el último momento posible (__getattr__) o en el primero de todos (__getattribute__), con costes muy distintos.

El peligro concreto de sobreescribir __getattribute__ es la recursión infinita. Si dentro de tu implementación escribes self.x, Python vuelve a invocar tu __getattribute__ para resolver ese self.x, que a su vez intenta resolver self.x, y así hasta RecursionError. La salida es siempre super().__getattribute__(name), que salta directamente a la implementación de object y usa la cadena de resolución normal sin volver a pasar por tu override.

__getattr__ es el adecuado para la mayoría de los casos reales: proxies transparentes, clientes de APIs donde los métodos se construyen dinámicamente, ORMs con carga lazy. No tiene el problema de recursión porque solo actúa cuando el atributo genuinamente no existe, y si lo necesitas puedes delegar sin miedo.

from __future__ import annotations
import time
from typing import Any


# ── Caso 1: __getattr__ para un proxy de API remota ──────────────────────────

class APIClient:
    """Genera endpoints dinámicamente: client.users, client.orders, etc."""

    def __init__(self, base_url: str) -> None:
        # Usamos object.__setattr__ para evitar recursión si sobreescribiéramos
        # __setattr__ también, pero aquí es solo buena costumbre defensiva.
        self._base_url = base_url
        self._cache: dict[str, "Endpoint"] = {}

    def __getattr__(self, name: str) -> "Endpoint":
        # Solo llega aquí si `name` no existe en __dict__ ni en la clase.
        # `_base_url` y `_cache` nunca pasan por aquí: ya están en __dict__.
        if name not in self._cache:
            self._cache[name] = Endpoint(f"{self._base_url}/{name}")
        return self._cache[name]


class Endpoint:
    def __init__(self, url: str) -> None:
        self.url = url

    def get(self, resource_id: int) -> str:
        return f"GET {self.url}/{resource_id}"


# ── Caso 2: __getattribute__ para auditoría de accesos ───────────────────────

class Audited:
    """Registra cada acceso a atributo, incluyendo los que existen."""

    def __init__(self, **kwargs: Any) -> None:
        for key, value in kwargs.items():
            # object.__setattr__ porque si sobreescribiéramos __setattr__,
            # self.x = v aquí entraría en el mismo problema de recursión.
            object.__setattr__(self, key, value)
        object.__setattr__(self, "_access_log", [])

    def __getattribute__(self, name: str) -> Any:
        # Capturamos el log ANTES de resolver, usando super() para no recursar.
        log: list = super().__getattribute__("_access_log")

        if not name.startswith("_"):  # no auditamos atributos privados
            log.append((name, time.monotonic()))

        # Delegamos a la implementación estándar: busca en __dict__, MRO, etc.
        return super().__getattribute__(name)

    def access_summary(self) -> dict[str, int]:
        log = object.__getattribute__(self, "_access_log")
        summary: dict[str, int] = {}
        for name, _ in log:
            summary[name] = summary.get(name, 0) + 1
        return summary


# ── Caso 3: __getattr__ para ORM con carga lazy ───────────────────────────────

class LazyRecord:
    """Simula un campo de ORM que hace la query solo cuando se accede."""

    def __init__(self, record_id: int) -> None:
        self._id = record_id
        self._loaded = False
        self._data: dict[str, Any] = {}

    def _load(self) -> None:
        """Simula una consulta a base de datos."""
        print(f"  [SQL] SELECT * FROM records WHERE id = {self._id}")
        self._data = {"name": "Alice", "score": 42, "active": True}
        self._loaded = True

    def __getattr__(self, name: str) -> Any:
        # Solo llegamos aquí si `name` no está en __dict__.
        # `_id`, `_loaded`, `_data` están en __dict__ → nunca pasan por aquí.
        if not self._loaded:
            self._load()
        try:
            return self._data[name]
        except KeyError:
            raise AttributeError(
                f"{type(self).__name__!r} no tiene el campo {name!r}"
            ) from None


# ── Demo ──────────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    print("=== APIClient ===")
    client = APIClient("https://api.example.com/v1")
    print(client.users.get(7))
    print(client.orders.get(42))
    print(client.users.get(9))  # viene del caché

    print("\n=== Audited ===")
    record = Audited(name="Bob", age=30, city="Madrid")
    _ = record.name
    _ = record.age
    _ = record.name
    _ = record.city
    _ = record.name
    print(record.access_summary())

    print("\n=== LazyRecord ===")
    r = LazyRecord(99)
    print("Antes del acceso, no hay query.")
    print(f"name={r.name}, score={r.score}")  # query ocurre aquí, una sola vez
    print(f"active={r.active}")               # ya estaba cargado

Desglose del código

El APIClient es el caso canónico de __getattr__. Fíjate que _base_url y _cache nunca activan el fallback porque se asignaron en __init__ y ya viven en self.__dict__. Solo los nombres desconocidos —client.users, client.orders— pasan por __getattr__. Eso es exactamente para lo que está diseñado: ampliar el espacio de atributos sin tocar los existentes.

Audited muestra por qué __getattribute__ es peligroso y poderoso al mismo tiempo. La primera línea del método recupera _access_log usando super().__getattribute__("_access_log"), no self._access_log. Si usaras self._access_log, invocarías tu propio __getattribute__ para resolver esa referencia, que intentaría leer _access_log, que llamaría a tu __getattribute__ otra vez. super() corta esa recursión delegando directamente a object.__getattribute__. Cada acceso a cualquier atributo no privado queda registrado, incluidos los que existen con total normalidad.

LazyRecord ilustra el patrón ORM. La carga ocurre exactamente una vez: la primera vez que accedes a un campo que no está en __dict__. Después de _load(), los datos se guardan en self._data (un diccionario privado), no directamente como atributos de instancia. Esto es deliberado: si hicieras self.name = self._data["name"] dentro de _load, el atributo pasaría a __dict__ y las siguientes lecturas nunca volverían a pasar por __getattr__. Guardar en _data y resolver siempre desde ahí mantiene el comportamiento uniforme.

Errores que debes conocer

Error: Acceder a self.x dentro de __getattribute__ sin usar super(), causando recursión infinita.

# ❌ Wrong
class Broken:
    def __getattribute__(self, name: str):
        if self._log:  # self._log llama a __getattribute__ → recursión infinita
            print(f"accediendo a {name}")
        return self.__dict__[name]  # __dict__ también pasa por __getattribute__

# ✅ Right
class Fixed:
    def __getattribute__(self, name: str):
        log = super().__getattribute__("_log")  # delega a object
        if log:
            print(f"accediendo a {name}")
        return super().__getattribute__(name)

super().__getattribute__ salta directamente a la implementación de object, que sabe cómo acceder a __dict__ sin volver a pasar por tu override.


Error: Lanzar una excepción que no sea AttributeError desde __getattr__, impidiendo que el mecanismo de fallback funcione correctamente.

# ❌ Wrong
class BadProxy:
    def __getattr__(self, name: str):
        if name not in self._allowed:
            raise ValueError(f"{name} no está permitido")  # rompe hasattr(), getattr() con default, etc.
        return self._fetch(name)

# ✅ Right
class GoodProxy:
    def __getattr__(self, name: str):
        if name not in self._allowed:
            raise AttributeError(f"{name} no está permitido")  # protocolo correcto
        return self._fetch(name)

hasattr(obj, x) se implementa llamando a getattr y capturando AttributeError; si lanzas otra excepción, hasattr la propaga en lugar de devolver False, rompiendo código genérico que usa el protocolo estándar de introspección.


Error: Usar __getattr__ para interceptar atributos que ya existen en __dict__, esperando que se llame siempre.

# ❌ Wrong — __getattr__ NUNCA se invoca para `name` porque ya está en __dict__
class Counter:
    def __init__(self):
        self.name = "original"

    def __getattr__(self, attr: str):
        if attr == "name":          # esta rama nunca se ejecuta
            return "interceptado"
        raise AttributeError(attr)

# ✅ Right — si necesitas interceptar atributos existentes, usa __getattribute__
class Counter:
    def __init__(self):
        self.name = "original"

    def __getattribute__(self, attr: str):
        value = super().__getattribute__(attr)
        if attr == "name":
            return value.upper()    # transforma el valor real
        return value

__getattr__ es un fallback, no un interceptor universal; si el atributo existe, Python nunca llega a invocarlo.

132

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio