🏛️ 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.
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
💡
selfno es una palabra reservada, es convención. El primer parámetro de cada método de instancia es siempre la instancia. Llamarana.presentarse()es equivalente aUsuario.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 = 10creas un atributo de instancia que oculta al de clase parab— 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 |
Sí | Puede vía self |
@classmethod |
cls |
No | Sí |
@staticmethod |
ninguno | No | No |
💡
@classmethodes el patrón factory más usado:Usuario.crear_admin()que devuelve una instancia con permisos especiales.@staticmethodes 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
💡
propertyte permite empezar con atributos simples y evolucionar a validación sin romper la API pública:t.celsiussiempre 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 conlen(),obj[i]einrespectivamente. 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, **kwargsen 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 deitertools, 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
yieldy el final de la función es el__exit__: se ejecuta siempre, también con excepciones. Si quieres manejar la excepción dentro, envuelve elyieldentry/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 elexceptdel 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
- Python docs — Classes: el capítulo oficial del tutorial sobre clases.
- Python docs — Data model: la lista completa de dunder methods y sus contratos.
- Python docs — functools:
wraps,singledispatchy otros decoradores de la stdlib. - Python docs — contextlib:
contextmanagery utilidades para bloqueswith. - Real Python — OOP in Python: tutorial práctico y extenso.
- Artículo anterior: Fundamentos de Python.