Introspección en Python: dir, vars, getattr, setattr y hasattr

La introspección es la capacidad de un programa para examinar sus propios objetos mientras se ejecuta. En Python, esto no es un truco avanzado ni una rareza del lenguaje — es una consecuencia directa de que todo objeto lleva consigo sus metadatos. Las funciones dir(), vars(), getattr(), setattr(), hasattr() y delattr() son la interfaz pública de ese mecanismo.

La diferencia entre dir() y vars() vale la pena entenderla desde el principio. vars(obj) devuelve directamente el __dict__ de la instancia: solo los atributos que esa instancia específica posee en memoria. dir(obj), en cambio, recorre toda la cadena MRO (Method Resolution Order) y devuelve una lista consolidada de nombres — incluyendo métodos heredados, propiedades, y atributos de clase. Si necesitas saber qué tiene este objeto concreto, usa vars(). Si necesitas saber qué puedes llamar sobre él, usa dir().

getattr(obj, 'nombre') es equivalente a obj.nombre, con la diferencia crítica de que el nombre puede ser una variable en tiempo de ejecución. Acepta un tercer argumento como valor por defecto, lo que lo convierte en la forma idiomática de acceso defensivo a atributos. hasattr(obj, 'nombre') internamente hace exactamente eso: llama getattr y devuelve False si lanza AttributeError. Esto importa porque hasattr no es una simple comprobación de diccionario — puede ejecutar código si el atributo está definido como property.

Cuándo usarlos: cualquier vez que el nombre del atributo se conoce en runtime en lugar de en tiempo de escritura. Serialización genérica, sistemas de plugins, ORMs livianos, frameworks de configuración — todos dependen de esto. El riesgo real es usarlos donde el acceso estático funcionaría igual de bien, sacrificando legibilidad sin ganar nada.

from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any


@dataclass
class Config:
    host: str = "localhost"
    port: int = 5432
    debug: bool = False
    _internal: str = field(default="private", repr=False)


def serialize(obj: object, exclude_private: bool = True) -> dict[str, Any]:
    """Serializa cualquier objeto usando sus atributos de instancia."""
    instance_attrs = vars(obj)  # solo __dict__, sin métodos ni herencia

    result = {}
    for name, value in instance_attrs.items():
        if exclude_private and name.startswith("_"):
            continue
        result[name] = value
    return result


def apply_overrides(obj: object, overrides: dict[str, Any]) -> list[str]:
    """
    Aplica un dict de sobreescrituras a un objeto.
    Devuelve los nombres de los atributos que realmente existían antes.
    """
    applied = []
    for key, value in overrides.items():
        if hasattr(obj, key):          # evita crear atributos fantasma
            old = getattr(obj, key)
            setattr(obj, key, value)
            print(f"  {key}: {old!r} → {value!r}")
            applied.append(key)
        else:
            print(f"  [ignorado] '{key}' no existe en {type(obj).__name__}")
    return applied


def describe(obj: object) -> None:
    """Muestra la diferencia práctica entre dir() y vars()."""
    all_names = dir(obj)
    own_attrs = vars(obj)

    # dir() incluye métodos del tipo, vars() solo los atributos de instancia
    methods = [n for n in all_names if callable(getattr(obj, n, None))]
    print(f"dir()  → {len(all_names)} nombres totales, {len(methods)} callables")
    print(f"vars() → {len(own_attrs)} atributos propios: {list(own_attrs.keys())}")


# ── demo ──────────────────────────────────────────────────────────────────────

cfg = Config(host="db.prod.internal", port=5432, debug=False)

print("=== serialize ===")
print(serialize(cfg))

print("\n=== apply_overrides ===")
overrides = {"port": 5433, "debug": True, "timeout": 30}  # timeout no existe
apply_overrides(cfg, overrides)

print("\n=== describe ===")
describe(cfg)

# acceso con default: no lanza AttributeError si el atributo fue eliminado
delattr(cfg, "debug")
debug_value = getattr(cfg, "debug", False)
print(f"\ndebug tras delattr: {debug_value!r}")

Qué está pasando en cada decisión

serialize() usa vars(obj) deliberadamente en lugar de dir(). Si usara dir(), tendría que filtrar manualmente todos los métodos heredados de object, los dunder methods, y los atributos de clase. vars() hace exactamente lo que necesitamos: dame el __dict__ de esta instancia y nada más.

El filtro name.startswith("_") en serialize() es una convención — no una regla del lenguaje. En el ejemplo, _internal es un atributo de dataclass marcado con field(repr=False), y lo tratamos como privado por nombre. Esto es serialización genérica: funciona con cualquier objeto que tenga __dict__, no solo con Config.

En apply_overrides(), el hasattr antes de setattr no es paranoia — es diseño. Sin esa guardia, setattr con una clave desconocida simplemente crea un atributo nuevo en la instancia. Eso puede ser exactamente lo que quieres en algunos contextos (objetos de configuración abiertos), o un bug silencioso en otros (cuando el objeto debería tener un esquema fijo). La decisión de incluirlo define el contrato de la función.

describe() ilustra el punto más importante: dir() recorre la jerarquía de clases completa. Un dataclass hereda de object, así que dir(cfg) incluye __class__, __delattr__, __eq__, __hash__, y decenas más que no aparecen en vars(cfg). Para introspección estructural — “¿qué campos tiene este modelo?” — vars() es la herramienta correcta. Para introspección de capacidades — “¿este objeto tiene un método close()?” — usa dir() o simplemente hasattr().

El bloque final demuestra la composición natural de delattr y getattr con default. Después de eliminar debug del __dict__ de la instancia, getattr(cfg, "debug", False) no lanza nada — devuelve el valor por defecto. Fíjate que si Config tuviera debug como atributo de clase, el getattr lo encontraría allí después del delattr de instancia. La introspección respeta el MRO completo.

Errores que debes conocer

Error: usar hasattr() para detectar si un atributo existe cuando ese atributo es una property con lógica interna — hasattr puede suprimir excepciones legítimas que no son AttributeError.

class Broken:
    @property
    def value(self):
        raise ValueError("base de datos no disponible")

obj = Broken()

# ❌ Wrong — en Python 2 hasattr tragaba todas las excepciones;
#    en Python 3 solo AttributeError, pero el ValueError se propaga igual
try:
    if hasattr(obj, "value"):  # lanza ValueError, no AttributeError
        print(obj.value)
except ValueError as e:
    print(f"hasattr no ayudó: {e}")

# ✅ Right — si el atributo puede tener side effects, accede directamente
try:
    val = obj.value
except (AttributeError, ValueError):
    val = None

En Python 3, hasattr solo captura AttributeError, así que un ValueError en una property se propaga de todas formas — la guardia con hasattr no protege nada en ese caso.


Error: asumir que vars() funciona con cualquier objeto — falla con tipos que usan __slots__ porque no tienen __dict__.

class Point:
    __slots__ = ("x", "y")

    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)

# ❌ Wrong
# vars(p)  →  TypeError: vars() argument must have __dict__ attribute

# ✅ Right — cuando no puedes controlar el tipo, usa getattr con los nombres
fields = {slot: getattr(p, slot) for slot in p.__slots__}
print(fields)  # {'x': 1, 'y': 2}

Con __slots__, los atributos no viven en un __dict__ sino en descriptores por slot; vars() simplemente no tiene nada que devolver.

131

Dejar un comentario

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

Scroll al inicio