🔎 Buscar

🏛️ OOP en Python

Clases, self e __init__, atributos y métodos, herencia con super y MRO, property, encapsulación, dunder methods, decoradores, generadores e iteradores, context managers y composición.

Wiki / Apuntes📖 Contenido

OOP en Python

La programación orientada a objetos en Python es el modelo para organizar código en las librerías y frameworks del ecosistema: Django, SQLAlchemy, FastAPI, pandas. Este artículo cubre desde la definición de una clase hasta los patrones avanzados (dunder methods, decoradores, context managers) que hacen que Python se comporte como un lenguaje “acoplable”.

Clases: la base

Una clase es una plantilla. Un objeto (instancia) es el resultado de instanciarla. La palabra clave es class, y __init__ es el constructor que inicializa cada instancia. self es la referencia a la instancia concreta sobre la que se opera.

class Usuario:
    def __init__(self, nombre: str, email: str) -> None:
        self.nombre = nombre        # atributos de instancia
        self.email = email

    def presentarse(self) -> str:
        return f"Soy {self.nombre}"

ana = Usuario("Ana", "ana@mail.com")
print(ana.nombre)                    # Ana
print(ana.presentarse())             # Soy Ana

💡 self no es una palabra reservada, es convención. El primer parámetro de cada método de instancia es siempre la instancia. Llamar ana.presentarse() es equivalente a Usuario.presentarse(ana).

Atributos de clase vs de instancia

class Contador:
    total = 0                       # atributo de CLASE: compartido por todas

    def __init__(self, nombre: str) -> None:
        self.nombre = nombre        # atributo de INSTANCIA: propio de cada una
        Contador.total += 1

a = Contador("a")
b = Contador("b")
print(Contador.total)               # 2  (se consulta desde la clase)
print(b.total)                      # 2  (también visible desde la instancia)

⚠️ Cuidado: si haces b.total = 10 creas un atributo de instancia que oculta al de clase para b — la clase y las demás instancias no cambian. Y nunca uses listas/dicts mutables como atributos de clase: se comparten entre todas las instancias.

Tipos de métodos

class Utilidades:
    marca = "util"                  # atributo de clase

    # Método de INSTANCIA: recibe self, trabaja con el estado
    def metodo_instancia(self) -> str:
        return f"método de instancia en {self.marca}"

    # Método de CLASE: recibe cls (la clase), no el estado de una instancia
    @classmethod
    def metodo_clase(cls) -> str:
        return f"método de clase en {cls.marca}"

    # Método ESTÁTICO: no recibe ni self ni cls; función dentro de la clase
    @staticmethod
    def metodo_estatico(x: int) -> int:
        return x * 2

print(Utilidades.metodo_clase())     # método de clase en util
print(Utilidades.metodo_estatico(4)) # 8
u = Utilidades()
print(u.metodo_instancia())          # método de instancia en util
Decorador Primer argumento Usa estado de instancia Usa datos de clase
(ninguno) self Puede vía self
@classmethod cls No
@staticmethod ninguno No No

💡 @classmethod es el patrón factory más usado: Usuario.crear_admin() que devuelve una instancia con permisos especiales. @staticmethod es para utilidades que no tocan estado.

class Usuario:
    def __init__(self, nombre: str, admin: bool = False) -> None:
        self.nombre = nombre
        self.admin = admin

    @classmethod
    def crear_admin(cls, nombre: str) -> "Usuario":
        return cls(nombre, admin=True)

admin = Usuario.crear_admin("Ana")
print(admin.admin)                   # True

Herencia y super()

La herencia reutiliza y extiende. super() llama al método de la clase padre sin nombrarla:

class Animal:
    def __init__(self, nombre: str) -> None:
        self.nombre = nombre

    def sonido(self) -> str:
        return "..."

    def describir(self) -> str:
        return f"{self.nombre} hace {self.sonido()}"

class Perro(Animal):
    def sonido(self) -> str:          # override
        return "guau"

class Gato(Animal):
    def __init__(self, nombre: str, vidas: int = 9) -> None:
        super().__init__(nombre)      # delega al constructor padre
        self.vidas = vidas

    def sonido(self) -> str:
        return "miau"

class PerroPolicia(Perro):
    def sonido(self) -> str:
        return super().sonido() + " (ladra fuerte)"   # extiende, no reemplaza

rocky = PerroPolicia("Rocky")
print(rocky.sonido())      # guau (ladra fuerte)
print(rocky.describir())   # Rocky hace guau (ladra fuerte)
print(Gato("Misu", 7).vidas)   # 7

MRO: Method Resolution Order

El MRO define el orden en que Python busca un método cuando hay herencia múltiple. Se consulta con Clase.__mro__ y se calcula con el C3 linearization.

class A:
    def quien(self) -> str:
        return "A"

class B(A):
    def quien(self) -> str:
        return "B"

class C(A):
    def quien(self) -> str:
        return "C"

class D(B, C):            # herencia múltiple
    pass

print(D.mro())            # [D, B, C, A, object]
print(D().quien())        # B  (primer padre primero, en orden de declaración)
class LogMixin:
    def log(self, msg: str) -> None:
        print(f"[LOG] {msg}")

class Validable:
    def validar(self) -> bool:
        return True

class Formulario(LogMixin, Validable):
    def enviar(self) -> None:
        self.log("validando...")
        if self.validar():
            self.log("enviado")

Formulario().enviar()     # [LOG] validando... / [LOG] enviado

⚠️ Con herencia múltiple, el orden de los padres importa: Python busca de izquierda a derecha y luego sube por la cadena. Un “diamante” bien construido con super() propaga correctamente; mal construido, causa métodos saltados.

Encapsulación y property

Python no tiene modificadores de acceso (private, protected). La convención:

  • _atributo: “protegido” — interno, no tocar desde fuera (es solo convención).
  • __atributo: name mangling — el nombre se reescribe a _Clase__atributo, evitando colisiones en herencia.
class Cuenta:
    def __init__(self, titular: str, saldo: float) -> None:
        self.titular = titular
        self._saldo = saldo          # convención: interno
        self.__pin = "1234"          # mangling: difícil de acceder fuera

c = Cuenta("Ana", 100.0)
print(c._saldo)                      # accesible (mala práctica, pero posible)
# print(c.__pin)                     # AttributeError: no existe así
print(c._Cuenta__pin)                # '1234'  (el mangling es ofuscación, no seguridad)

property convierte un método en acceso de atributo, con getter, setter y deleter:

class Temperatura:
    def __init__(self, celsius: float) -> None:
        self._celsius = celsius

    @property
    def celsius(self) -> float:
        return self._celsius

    @property
    def fahrenheit(self) -> float:          # solo getter (lectura)
        return self._celsius * 9 / 5 + 32

    @celsius.setter
    def celsius(self, valor: float) -> None:   # validación al asignar
        if valor < -273.15:
            raise ValueError("Temperatura por debajo del cero absoluto")
        self._celsius = valor

    @celsius.deleter
    def celsius(self) -> None:
        self._celsius = 0.0

t = Temperatura(25)
print(t.celsius)                    # 25  (uso como atributo, no método)
print(t.fahrenheit)                 # 77.0
t.celsius = 30                      # pasa por el setter
# t.celsius = -500                 # ValueError
del t.celsius                      # pasa por el deleter
print(t.celsius)                   # 0.0

💡 property te permite empezar con atributos simples y evolucionar a validación sin romper la API pública: t.celsius siempre se escribe igual, cambie por dentro lo que cambie.

Dunder methods

Los métodos mágicos (doble guion bajo) definen cómo se comporta un objeto con los operadores y funciones del lenguaje.

class Punto:
    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

    def __repr__(self) -> str:
        """Representación sin ambigüedad para el desarrollador."""
        return f"Punto({self.x}, {self.y})"

    def __str__(self) -> str:
        """Representación legible para humanos (print)."""
        return f"({self.x}, {self.y})"

    def __eq__(self, otro) -> bool:
        return isinstance(otro, Punto) and (self.x, self.y) == (otro.x, otro.y)

    def __lt__(self, otro) -> bool:
        """Permite ordenar con sorted(): compara por distancia al origen."""
        return self.x ** 2 + self.y ** 2 < otro.x ** 2 + otro.y ** 2

    def __hash__(self) -> int:
        """Objetos iguales → mismo hash (necesario para dicts y sets)."""
        return hash((self.x, self.y))

    def __add__(self, otro: "Punto") -> "Punto":
        return Punto(self.x + otro.x, self.y + otro.y)

p1 = Punto(1, 2)
p2 = Punto(1, 2)
p3 = Punto(3, 4)

print(p1)                    # (1, 2)            → __str__
print(repr(p1))              # Punto(1, 2)       → __repr__
print(p1 == p2)              # True              → __eq__
print(sorted([p3, p1]))      # [Punto(1, 2), Punto(3, 4)]  → __lt__
print(len({p1, p2}))         # 1                 → __hash__ + __eq__
print(p1 + p3)               # Punto(4, 6)       → __add__

⚠️ Regla de oro: si defines __eq__, define también __hash__. Si los objetos son mutables (lista interna), NO los hagas hasheables (devuelve __hash__ = None) o se corromperán dentro de un set/dict.

Iteración, llamada y context manager

class Contador:
    """Iterable con __iter__ y __next__."""
    def __init__(self, tope: int) -> None:
        self.tope = tope

    def __iter__(self):
        self.n = 0
        return self

    def __next__(self) -> int:
        if self.n >= self.tope:
            raise StopIteration
        self.n += 1
        return self.n

for n in Contador(3):
    print(n)                # 1 2 3
class Multiplicador:
    """Objeto llamable: funciona como función."""
    def __init__(self, factor: int) -> None:
        self.factor = factor

    def __call__(self, valor: int) -> int:
        return valor * self.factor

doble = Multiplicador(2)
print(doble(5))             # 10   (se llama como función)
print(doble(10))            # 20
class Conexion:
    """Context manager con __enter__ / __exit__ (patrón with)."""
    def __enter__(self):
        print("abriendo conexión")
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        print("cerrando conexión")

with Conexion() as c:
    print("dentro del with")
# abriendo conexión / dentro del with / cerrando conexión

💡 __len__, __getitem__, __contains__ hacen que tu clase funcione con len(), obj[i] e in respectivamente. Cada dunder es el “enchufe” que conecta tu clase con la sintaxis y las funciones built-in de Python.

Decoradores

Un decorador es una función que recibe una función y devuelve otra. Es azúcar para fn = decorador(fn).

def medir(func):
    """Decorador simple: mide y loguea el tiempo de ejecución."""
    import time

    def envoltura(*args, **kwargs):
        t0 = time.perf_counter()
        resultado = func(*args, **kwargs)
        duracion = time.perf_counter() - t0
        print(f"{func.__name__} tardó {duracion:.4f}s")
        return resultado

    return envoltura

@medir
def calculo_pesado(n: int) -> int:
    return sum(i * i for i in range(n))

print(calculo_pesado(1_000_000))
# calculo_pesado tardó 0.0651s / 333332833333500000

functools.wraps y decoradores con argumentos

Sin functools.wraps, el decorado pierde su nombre y docstring (se vuelve envoltura). wraps lo conserva:

from functools import wraps

def medir(func):
    @wraps(func)                     # copia __name__, __doc__ y más
    def envoltura(*args, **kwargs):
        ...
        return func(*args, **kwargs)
    return envoltura

Decorador con argumentos — el patrón es triple anidamiento (decorador → decorador real → envoltura):

from functools import wraps

def repetir(veces: int):
    """Decorador con argumento: ejecuta la función varias veces."""
    def decorador(func):
        @wraps(func)
        def envoltura(*args, **kwargs):
            resultados = []
            for _ in range(veces):
                resultados.append(func(*args, **kwargs))
            return resultados
        return envoltura
    return decorador

@repetir(3)
def saludar(nombre: str) -> str:
    return f"hola {nombre}"

print(saludar("Ana"))    # ['hola Ana', 'hola Ana', 'hola Ana']
# Decorador con validación de tipos de retorno (ejemplo real)
from functools import wraps

def asegurar_tipo(tipo_esperado):
    def decorador(func):
        @wraps(func)
        def envoltura(*args, **kwargs):
            resultado = func(*args, **kwargs)
            if not isinstance(resultado, tipo_esperado):
                raise TypeError(
                    f"{func.__name__} devolvió {type(resultado)}, "
                    f"se esperaba {tipo_esperado}"
                )
            return resultado
        return envoltura
    return decorador

@asegurar_tipo(str)
def formato(valor: int) -> str:
    return f"nº {valor}"

print(formato(5))        # nº 5

💡 Memoriza el patrón: sin argumentos son 2 niveles (decorador → envoltura); con argumentos son 3 (fábrica → decorador → envoltura). *args, **kwargs en la envoltura es lo que hace que el decorador funcione con cualquier firma.

Generadores e iteradores

Un generador produce valores bajo demanda con yield — en vez de construir toda la lista en memoria, produce uno a la vez:

def pares(tope: int):
    n = 0
    while n < tope:
        yield n
        n += 2

for p in pares(10):
    print(p)              # 0 2 4 6 8

print(sum(pares(10)))     # 20  (consumible como iterable normal)

# next() consume uno a uno
gen = pares(6)
print(next(gen))          # 0
print(next(gen))          # 2
print(next(gen))          # 4
# next(gen)               # StopIteration
# send: comunicación hacia el generador
def acumulador():
    total = 0
    while True:
        valor = yield total       # recibe un valor de send()
        if valor is not None:
            total += valor

acc = acumulador()
next(acc)                 # arranca hasta el primer yield
print(acc.send(10))       # 10
print(acc.send(5))        # 15
print(acc.send(3))        # 18

# yield from: delegar a otro iterable/generador
def planas(matriz):
    for fila in matriz:
        yield from fila

print(list(planas([[1, 2], [3, 4]])))    # [1, 2, 3, 4]

💡 La ventaja: lazy. pares(10**9) ocupa memoria constante, mientras una lista de mil millones de enteros no cabría. Los generadores son la base de itertools, la lectura de archivos línea a línea y las pipelines de procesamiento.

Diferencias clave

Concepto Qué es Métodos clave
Iterable Tiene __iter__ (se puede recorrer) for x in obj
Iterador Tiene __iter__ y __next__ next(it)
Generador Función con yield que devuelve un iterador next(), send(), .close()
class Fibonacci:
    """Iterador completo (clase), equivalente al generador con yield."""
    def __init__(self, tope: int) -> None:
        self.tope = tope
        self.a, self.b = 0, 1

    def __iter__(self):
        return self

    def __next__(self) -> int:
        if self.a > self.tope:
            raise StopIteration
        valor = self.a
        self.a, self.b = self.b, self.a + self.b
        return valor

print(list(Fibonacci(20)))     # [0, 1, 1, 2, 3, 5, 8, 13]

Context managers con @contextmanager

contextlib.contextmanager convierte un generador en un context manager, sin escribir __enter__/__exit__ a mano:

from contextlib import contextmanager

@contextmanager
def temporizador():
    """Mide el tiempo de un bloque with."""
    import time
    t0 = time.perf_counter()
    yield "iniciado"
    print(f"bloque tardó {time.perf_counter() - t0:.3f}s")

with temporizador() as estado:
    print(estado)                  # iniciado
    time.sleep(0.1)
# bloque tardó 0.100s

@contextmanager
def archivo_directo(ruta: str, modo: str = "r"):
    f = open(ruta, modo)
    try:
        yield f                    # lo que el with expone como `as`
    finally:
        f.close()                  # limpieza garantizada

with archivo_directo("notas.txt", "w") as f:
    f.write("guardado")

💡 El código entre yield y el final de la función es el __exit__: se ejecuta siempre, también con excepciones. Si quieres manejar la excepción dentro, envuelve el yield en try/except.

Composición vs herencia

Herencia es “es un” (Perro es Animal). Composición es “tiene un” (Coche tiene un Motor). La composición es más flexible: no acopla a la jerarquía de padres y permite cambiar el colaborador en runtime.

# HERENCIA: frágil si la jerarquía crece
class ReportePDF:
    def exportar(self) -> str:
        return "PDF generado"

class ReportePDFConEnvio(ReportePDF):     # "es un"
    def exportar_y_enviar(self) -> str:
        return self.exportar() + " y enviado"
# COMPOSICIÓN: colaboradores intercambiables, sin herencia
class ExportadorPDF:
    def exportar(self, datos) -> str:
        return f"PDF de {datos}"

class ExportadorCSV:
    def exportar(self, datos) -> str:
        return f"CSV de {datos}"

class ServicioReporte:
    """Un servicio que coordina; no hereda de los exportadores."""
    def __init__(self, exportador):
        self.exportador = exportador     # "tiene un" exportador

    def generar(self, datos) -> str:
        return self.exportador.exportar(datos)

servicio = ServicioReporte(ExportadorPDF())
print(servicio.generar("ventas"))        # PDF de ventas
servicio.exportador = ExportadorCSV()    # cambio en runtime
print(servicio.generar("ventas"))        # CSV de ventas

💡 Regla práctica: usa herencia cuando hay una relación real “es un” y vas a reutilizar la interfaz polimórfica; usa composición cuando solo quieres delegar trabajo a otra clase. “Prefiere composición sobre herencia” (Design Patterns).

Excepciones personalizadas con init

Una excepción propia debe heredar de Exception. Puedes añadir campos y formato en __init__:

class ErrorDeNegocio(Exception):
    """Base para errores del dominio."""

class SaldoInsuficienteError(ErrorDeNegocio):
    def __init__(self, titular: str, saldo: float, intento: float) -> None:
        self.titular = titular
        self.saldo = saldo
        self.intento = intento
        super().__init__(
            f"{titular} intentó retirar {intento} con saldo {saldo}"
        )

class CuentaEnRojoError(ErrorDeNegocio):
    pass

class CuentaBancaria:
    def __init__(self, titular: str, saldo: float) -> None:
        self.titular = titular
        self.saldo = saldo

    def retirar(self, cantidad: float) -> float:
        if cantidad > self.saldo:
            raise SaldoInsuficienteError(self.titular, self.saldo, cantidad)
        self.saldo -= cantidad
        if self.saldo < 0:
            raise CuentaEnRojoError(f"cuenta en negativo: {self.saldo}")
        return self.saldo

cuenta = CuentaBancaria("Ana", 50.0)
try:
    cuenta.retirar(200.0)
except SaldoInsuficienteError as e:
    print(f"Error en cuenta de {e.titular}: {e}")
    print(f"saldo={e.saldo}, intento={e.intento}")
# Error en cuenta de Ana: Ana intentó retirar 200.0 con saldo 50.0
# saldo=50.0, intento=200.0

⚠️ Captura siempre la excepción más específica posible (SaldoInsuficienteError) antes que la genérica (Exception), y usa la jerarquía propia para que el except del negocio no atrape errores de programación (TypeError, ValueError).

Cheatsheet

Quieres… Usas…
Constructor de instancia __init__
Método que usa la clase @classmethod (cls)
Método sin estado @staticmethod
Llamar al padre super()
Validar un atributo @property + @x.setter
Atributo interno _atributo (convención)
Atributo con mangling __atributo
Representación legible __str__
Representación para debug __repr__
Igualdad / orden __eq__, __lt__ (+ __hash__)
Objeto como función __call__
Objeto como iterable __iter__ / __next__
Bloques with __enter__ / __exit__
Función envolvente decoradores + functools.wraps
Valores perezosos yield (generadores)
Context manager sin clase @contextmanager
Relación “es un” herencia
Relación “tiene un” composición

Para profundizar

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.