Diseño: SOLID básico y patrones
¿Qué es?
El diseño orientado a objetos es el conjunto de criterios para decidir cómo repartir responsabilidades entre clases de modo que el código sea fácil de cambiar. SOLID es un acrónimo de cinco principios de diseño. Un patrón de diseño es una solución probada a un problema recurrente; veremos dos: Factory (fábrica) y Strategy (estrategia).
¿Cómo funciona?
SOLID son cinco letras: Single Responsibility (una clase, una razón para cambiar), Open/Closed (abierta a extensión, cerrada a modificación), Liskov (una subclase debe poder sustituir a su base sin romper nada), Interface Segregation (interfaces pequeñas y específicas) y Dependency Inversion (depende de abstracciones, no de detalles concretos). Factory centraliza la creación de objetos; Strategy permite intercambiar un algoritmo en tiempo de ejecución.
¿Para qué sirve?
Sirve para que el sistema crezca sin convertirse en un nudo. En el sistema de notas, queremos poder añadir nuevos formatos de reporte o nuevas reglas de aprobación sin reescribir las clases existentes. SOLID y estos patrones lo hacen posible.
Qué pieza del sistema construimos
Cerramos el sistema con dos mejoras de diseño: separamos la generación de reportes en clases intercambiables (patrón Strategy), y creamos una fábrica que construye el tipo de estudiante correcto a partir de datos (patrón Factory), de cara a la persistencia. Aplicamos de paso Single Responsibility y Open/Closed.
Single Responsibility: separar el reporte de los datos
En el módulo anterior, Curso imprimía. Pero imprimir es otra responsabilidad. Si mañana queremos exportar a CSV, no deberíamos tocar Curso. Sacamos el reporte fuera.
class Curso:
def __init__(self, nombre):
self.nombre = nombre
self.estudiantes = []
def inscribir(self, estudiante):
self.estudiantes.append(estudiante)
Curso ahora solo gestiona inscripciones. Una sola razón para cambiar.
Strategy: reportes intercambiables (Open/Closed)
Definimos una abstracción para "una forma de reportar" y varias implementaciones. Usamos ABC (clase base abstracta) para fijar la interfaz.
from abc import ABC, abstractmethod
class Reporte(ABC):
@abstractmethod
def generar(self, curso) -> str:
...
class ReporteTexto(Reporte):
def generar(self, curso) -> str:
lineas = [f"Curso: {curso.nombre}"]
for e in curso.estudiantes:
lineas.append(f" {e.nombre}: {e.promedio:.2f}")
return "\n".join(lineas)
class ReporteCSV(Reporte):
def generar(self, curso) -> str:
filas = ["nombre,promedio"]
for e in curso.estudiantes:
filas.append(f"{e.nombre},{e.promedio:.2f}")
return "\n".join(filas)
La estrategia se elige al usar, no al definir el curso:
def imprimir_reporte(curso, estrategia: Reporte):
print(estrategia.generar(curso))
imprimir_reporte(curso, ReporteTexto())
imprimir_reporte(curso, ReporteCSV())
Esto cumple Open/Closed: para añadir un ReporteJSON creas una clase nueva; no modificas ninguna existente. El sistema queda abierto a extensión, cerrado a modificación.
Strategy y duck typing se llevan bien en Python. La clase ABC documenta y exige la interfaz, pero imprimir_reporte funcionaría con cualquier objeto que tenga generar(curso). La abstracción explícita es para claridad y para que el error salga temprano.
Factory: crear el objeto correcto
Para la persistencia, leeremos datos (por ejemplo, de un archivo) donde cada estudiante trae un tipo. Una fábrica decide qué clase instanciar, centralizando esa decisión en un solo lugar.
class EstudianteFactory:
@staticmethod
def crear(datos: dict):
tipo = datos.get("tipo", "regular")
nombre = datos["nombre"]
if tipo == "becado":
est = EstudianteBecado(nombre)
elif tipo == "regular":
est = Estudiante(nombre)
else:
raise ValueError(f"Tipo desconocido: {tipo}")
for n in datos.get("notas", []):
est.agregar_nota(n)
return est
crudo = [
{"tipo": "regular", "nombre": "Ana", "notas": [4.0, 5.0]},
{"tipo": "becado", "nombre": "Luis", "notas": [3.2]},
]
estudiantes = [EstudianteFactory.crear(d) for d in crudo]
Si mañana aparece un tipo nuevo, lo agregas solo dentro de la fábrica. El resto del sistema —que recibe estudiantes ya construidos— no se entera. Eso es Dependency Inversion en pequeño: el código de alto nivel depende de la abstracción "estudiante", no de qué subclase concreta es.
Error común: convertir SOLID en dogma y sobre-diseñar. No crees una fábrica ni una jerarquía de strategies para un programa de diez líneas que nunca va a cambiar. Aplica el principio cuando el cambio sea probable; antes, la simplicidad gana.
Tech English: Single Responsibility = responsabilidad única, Open/Closed = abierto/cerrado, strategy / factory = estrategia / fábrica, abstract base class = clase base abstracta.
Ejercicios
-
Tercera estrategia. Diseña
ReporteJSON(Reporte)que devuelva el reporte como cadena JSON (usaimport json). Demuestra queimprimir_reportelo usa sin ningún cambio, y nombra qué principio SOLID acabas de respetar. -
Refactor por responsabilidad. Te dan una clase
Cursoque inscribe estudiantes, calcula promedios, imprime en pantalla y guarda en disco. Diseña cómo la separarías en clases distintas según Single Responsibility: di qué clase queda con qué responsabilidad y por qué.