Atributos de instancia vs atributos de clase en Python

Cuando defines una variable dentro de una clase pero fuera de cualquier método, esa variable no pertenece a ningún objeto concreto — pertenece a la clase misma. Todos los objetos que crees a partir de esa clase comparten exactamente el mismo valor. En cambio, cuando asignas algo a self.algo dentro de __init__ (o cualquier otro método), estás creando un atributo de instancia: una variable propia de ese objeto, guardada en su __dict__ personal, invisible para el resto de instancias.

La distinción es importante porque Python no lanza ningún error si las confundes. El comportamiento incorrecto aparece silenciosamente, y cuando lo notas ya tienes varios objetos con estado corrupto.

El mecanismo de búsqueda de atributos en Python sigue un orden preciso: primero mira en instance.__dict__, y si no encuentra nada, sube a type(instance).__dict__ (la clase). Esto significa que un atributo de clase actúa como un valor por defecto implícito para todas las instancias, hasta el momento en que una instancia define su propia versión. A partir de ese momento, la instancia “sombrea” al atributo de clase sin modificarlo.

El problema real no es ese shadowing — es cuando el atributo de clase es mutable (una lista, un diccionario, un set). Si en lugar de reasignar haces una mutación en el lugar (append, update, += sobre una lista), Python nunca crea una copia en la instancia: estás modificando directamente el objeto de la clase, y ese cambio es visible para todas las instancias. Es el bug más silencioso de este tema.

¿Cuándo tiene sentido un atributo de clase? Cuando el valor genuinamente pertenece al concepto de la clase, no a un objeto específico: constantes compartidas, contadores de instancias creadas, o configuración por defecto que raramente cambia y que no es mutable.

from __future__ import annotations


class BankAccount:
    # Atributo de clase: compartido, inmutable (str), seguro como constante.
    currency: str = "EUR"

    # Atributo de clase: contador global de cuentas creadas.
    _total_accounts: int = 0

    def __init__(self, owner: str, balance: float = 0.0) -> None:
        # Atributos de instancia: propios de cada objeto, viven en self.__dict__
        self.owner = owner
        self.balance = balance

        # Modificamos el atributo de clase a través de la clase, no de self.
        # Hacerlo con self crearía un atributo de instancia que sombrea al de clase.
        BankAccount._total_accounts += 1

    def deposit(self, amount: float) -> None:
        self.balance += amount

    @classmethod
    def total_accounts(cls) -> int:
        # classmethod recibe la clase como primer argumento, no la instancia.
        return cls._total_accounts

    def __repr__(self) -> str:
        return f"BankAccount(owner={self.owner!r}, balance={self.balance}, currency={self.currency!r})"


# --- Demostración del lookup de atributos ---

alice = BankAccount("Alice", 1000.0)
bob = BankAccount("Bob", 500.0)

print(BankAccount.total_accounts())  # 2

# Ambas instancias leen el mismo atributo de clase.
print(alice.currency)  # EUR
print(bob.currency)    # EUR

# Reasignamos en la instancia: crea un atributo propio en alice.__dict__
# El atributo de clase BankAccount.currency NO cambia.
alice.currency = "USD"
print(alice.currency)           # USD  ← ahora lee su propio atributo
print(bob.currency)             # EUR  ← sigue leyendo el de la clase
print(BankAccount.currency)     # EUR  ← la clase no se tocó

# Comprobamos los __dict__ para entenderlo con claridad
print(alice.__dict__)   # {'owner': 'Alice', 'balance': 1000.0, 'currency': 'USD'}
print(bob.__dict__)     # {'owner': 'Bob', 'balance': 500.0}
                        # 'currency' no aparece: lo hereda de la clase


# --- El peligro con mutables ---

class BuggyTeam:
    members: list[str] = []   # ⚠️  lista mutable compartida por TODAS las instancias

    def __init__(self, name: str) -> None:
        self.name = name

    def add_member(self, member: str) -> None:
        self.members.append(member)   # muta la lista de clase, no crea una nueva


team_a = BuggyTeam("Alpha")
team_b = BuggyTeam("Beta")

team_a.add_member("Alice")
team_b.add_member("Bob")

print(team_a.members)  # ['Alice', 'Bob']  ← Bob aparece aunque lo añadimos en team_b
print(team_b.members)  # ['Alice', 'Bob']  ← mismo objeto en memoria


class CorrectTeam:
    def __init__(self, name: str) -> None:
        self.name = name
        self.members: list[str] = []   # lista propia por instancia, creada en __init__

    def add_member(self, member: str) -> None:
        self.members.append(member)


team_c = CorrectTeam("Gamma")
team_d = CorrectTeam("Delta")

team_c.add_member("Carol")
team_d.add_member("Dave")

print(team_c.members)  # ['Carol']
print(team_d.members)  # ['Dave']

Desglosando las decisiones

BankAccount.currency es un str. Los strings en Python son inmutables, así que cuando alice.currency = "USD" aparece en el código, Python no puede mutar el string existente: tiene que crear un atributo nuevo en alice.__dict__. El atributo de clase queda intacto. Esto es precisamente el shadowing: a partir de ese momento, el lookup de alice.currency se detiene en la instancia y nunca llega a la clase.

El contador _total_accounts demuestra el patrón correcto para modificar un atributo de clase: siempre a través del nombre de la clase (BankAccount._total_accounts += 1), nunca con self. Si escribieras self._total_accounts += 1, Python evaluaría primero self._total_accounts (lee de la clase, OK), le sumaría 1, y luego asignaría el resultado a self._total_accounts — creando un atributo de instancia que sombrea al contador global. Cada instancia tendría su propio “contador” con valor 1 y el contador real nunca avanzaría.

La clase BuggyTeam ilustra exactamente por qué los mutables como atributos de clase son una trampa: append no es una asignación, es una operación sobre el objeto al que apunta self.members. Como self.members resuelve a la lista de la clase (no hay versión en la instancia), estás mutando el objeto compartido. La solución de CorrectTeam es trivial pero fundamental: mover la inicialización de la lista a __init__, donde self.members = [] crea un objeto lista nuevo e independiente en el __dict__ de cada instancia.

Errores que debes conocer

Error: Modificar un atributo de clase mutable con self pensando que es “privado” de la instancia.

# ❌ Wrong
class Config:
    tags: list[str] = []

    def add_tag(self, tag: str) -> None:
        self.tags.append(tag)  # muta la lista de clase, no crea una nueva en self

c1 = Config()
c2 = Config()
c1.add_tag("fast")
print(c2.tags)  # ['fast']  ← c2 nunca tocó tags, pero lo ve igual

# ✅ Right
class Config:
    def __init__(self) -> None:
        self.tags: list[str] = []  # cada instancia tiene su propia lista

    def add_tag(self, tag: str) -> None:
        self.tags.append(tag)

Mover la lista a __init__ garantiza que cada objeto recibe su propio objeto lista en el momento de creación.


Error: Incrementar un atributo de clase con self en lugar de con el nombre de la clase.

# ❌ Wrong
class Counter:
    count: int = 0

    def __init__(self) -> None:
        self.count += 1  # lee de la clase, suma 1, guarda en self.__dict__
                         # el contador de clase nunca cambia

c1 = Counter()
c2 = Counter()
print(Counter.count)  # 0  ← el contador real sigue en cero

# ✅ Right
class Counter:
    count: int = 0

    def __init__(self) -> None:
        Counter.count += 1  # modifica directamente el atributo de clase

Al usar Counter.count explícitamente, evitas que Python cree un atributo de instancia que sombree al contador global.

93

Dejar un comentario

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

Scroll al inicio