Diseño de software II

Ingeniería del Software para Inteligencia Artificial

Introducción

The evolution of a Software Engineer

Código mal diseñado

from sklearn.linear_model import LinearRegression
import numpy as np

def cargar_datos(ruta):
    print(f"Cargando datos desde {ruta}...")
    return [[1, 2], [0.5, -1], [0.2, 4]] # Aquí debería devolver los datos leídos

def preprocesar_datos(datos, tipo):
    # Problema: Lógica de preprocesamiento en una única función
    if tipo == "filtrado":
        print("Filtrando datos...")
        return [x for x in datos if all(i >= 0 for i in x)]
    if tipo == "normalizacion":
        print("Aplicando normalización...")
        return [[(i - min(x)) / (max(x) - min(x)) for i in x] for x in datos]
    else:
        print(f"Preprocesamiento desconocido {tipo}")
        return datos

def predecir(entrada, datos, modelo_tipo):
    # Problema: Función que mezcla la lógica de entrenamiento y predicción
    if modelo_tipo == "media":
        print("Entrenando modelo de clasificación con", datos)
        valor_medio = sum(sum(x) for x in datos) / sum(len(x) for x in datos)
        return valor_medio
    elif modelo_tipo == "regresion_lineal":
        print("Entrenando modelo de regresión con", datos)
        X = np.array([x[:-1] for x in datos])
        y = np.array([x[-1] for x in datos])
        modelo = LinearRegression()
        modelo.fit(X, y)
        prediccion = modelo.predict([entrada])
        return prediccion
    else:
        print("Tipo de modelo desconocido")
        return None

if __name__ == "__main__":
    datos = cargar_datos('/ruta/al/dataset')
    datos = preprocesar_datos(datos, 'filtrado')
    datos = preprocesar_datos(datos, 'normalizacion')
    print(datos)
    prediccion = predecir([1], datos, 'regresion_lineal')
    print(f'La predicción es {prediccion}')

Pasamos las funciones a clases…

from sklearn.linear_model import LinearRegression
import numpy as np

class DataLoader:
    def cargar_datos(self, ruta):
        print(f"Cargando datos desde {ruta}...")
        return [[1, 2], [0.5, -1], [0.2, 4]] # Aquí debería devolver los datos leídos

class Preprocessor:
    def preprocesar_datos(self, datos, tipo):
        # Problema: Lógica de preprocesamiento en una única función.
        if tipo == "filtrado":
            print("Filtrando datos...")
            return [x for x in datos if all(i >= 0 for i in x)]
        if tipo == "normalizacion":
            print("Aplicando normalización...")
            return [[(i - min(x)) / (max(x) - min(x)) for i in x] for x in datos]
        else:
            print(f"Preprocesamiento desconocido {tipo}")
            return datos

class Predictor:
    def predecir(self, entrada, datos, modelo_tipo):
        # Problema: Función que mezcla la lógica de entrenamiento y predicción.
        if modelo_tipo == "media":
            print("Entrenando modelo de clasificación con %s", datos)
            valor_medio = sum(sum(x) for x in datos) / sum(len(x) for x in datos)
            return valor_medio
        elif modelo_tipo == "regresion_lineal":
            print("Entrenando modelo de regresión con %s", datos)
            X = np.array([x[:-1] for x in datos])
            y = np.array([x[-1] for x in datos])
            modelo = LinearRegression()
            modelo.fit(X, y)
            prediccion = modelo.predict([entrada])
            return prediccion
        else:
            print("Tipo de modelo desconocido")
            return None

if __name__ == "__main__":
    data_loader = DataLoader()
    preprocessor = Preprocessor()
    predictor = Predictor()

    datos = data_loader.cargar_datos('/ruta/al/dataset')
    datos = preprocessor.preprocesar_datos(datos, 'filtrado')
    datos = preprocessor.preprocesar_datos(datos, 'normalizacion')
    print("Datos preprocesados: %s", datos)
    prediccion = predictor.predecir([1], datos, 'regresion_lineal')
    print("La predicción es %s", prediccion)

… y retocamos un poco

import logging
from sklearn.linear_model import LinearRegression
import numpy as np

# Configuración básica del logging
logging.basicConfig(level=logging.INFO)

class DataLoader:
    def cargar_datos(self, ruta):
        logging.info(f"Cargando datos desde {ruta}...")
        return [[1, 2], [0.5, -1], [0.2, 4]] # Aquí debería devolver los datos leídos

class Preprocessor:
    def filtrar(self, datos):
        logging.info("Filtrando datos...")
        return [x for x in datos if all(i >= 0 for i in x)]
    
    def normalizar(self, datos):
        logging.info("Aplicando normalización...")
        return [[(i - min(x)) / (max(x) - min(x)) for i in x] for x in datos]

class Predictor:
    def __media(self, datos):
        logging.info("Entrenando modelo de clasificación con %s", datos)
        valor_medio = sum(sum(x) for x in datos) / sum(len(x) for x in datos)
        return valor_medio
    
    def __regresion_lineal(self, datos):
        logging.info("Entrenando modelo de regresión con %s", datos)
        X = np.array([x[:-1] for x in datos])
        y = np.array([x[-1] for x in datos])
        modelo = LinearRegression()
        modelo.fit(X, y)
        return modelo

    def predecir(self, entrada, datos, modelo_tipo):
        if modelo_tipo == "media":
            return self.__media(datos)
        elif modelo_tipo == "regresion_lineal":
            modelo = self.__regresion_lineal(datos)
            prediccion = modelo.predict([entrada])
            return prediccion

if __name__ == "__main__":
    data_loader = DataLoader()
    preprocessor = Preprocessor()
    predictor = Predictor()

    datos = data_loader.cargar_datos('/ruta/al/dataset')
    datos = preprocessor.filtrar(datos)
    datos = preprocessor.normalizar(datos)
    logging.info("Datos preprocesados: %s", datos)
    prediccion = predictor.predecir([1], datos, 'regresion_lineal')
    logging.info("La predicción es %s", prediccion)

Sigue estando mal

💩

Problemas de diseño

  • No hay flexibilidad para añadir o combinar transformaciones
  • Todos los modelos están implementados en la misma clase
    • La clase se volverá muy compleja si añadimos nuevos modelos
    • Dificulta separar el entrenamiento y la predicción, cada modelo almacena información distinta
  • El modelo se selecciona usando strings

Necesitamos más herramientas 🛠️

Herencia y Polimorfismo

Herencia

La herencia es un mecanismo fundamental en POO que permite que una clase (derivada/subclase) adquiera atributos y métodos de otra clase (base/superclase).

Ventajas:

  • Reutilización de código: Permite aprovechar código ya existente.
  • Extensibilidad: Facilita la ampliación o modificación de comportamientos.
  • Polimorfismo: Las clases derivadas pueden redefinir (sobreescribir) métodos para comportarse de forma específica.

Herencia en Python

class Animal:
    def respirar(self):
        print("Estoy respirando")

    def hablar(self):
        print("El animal hace un sonido")

class Perro(Animal):
    def hablar(self):
        print("Guau")

perro = Perro()
perro.respirar()
perro.hablar()
Estoy respirando
Guau

Ejemplo práctico: excepciones propias

class DivisionByZeroError(Exception):
    """Excepción lanzada cuando se intenta hacer una división entre cero"""
    pass

def divide(a, b):
    if b == 0:
        raise DivisionByZeroError()
    else:
        return a/b

try:
    divide(10, 0)
except DivisionByZeroError:
    print("El dividendo no puede ser 0")
El dividendo no puede ser 0

Ejemplo práctico: excepciones propias

Es frecuente heredar de clases proporcionadas por Python o librerías de terceros para extender su funcionamiento con nuestro propio código.

Herencia con más de una subclase

class Perro(Animal): # Hereda de Animal
    def hablar(self):
        print("Guau")

class Gato(Animal):  # Hereda de Animal
    def hablar(self):
        print("Miau")

perro = Perro()
gato = Gato()

perro.hablar()
gato.hablar()
Guau
Miau

Atributos y métodos privados

Los atributos y métodos privados no son accesibles a las clases derivadas.

class Animal:
    def __init__(self, nombre): # ❌ No se ejecuta y no declara __nombre
        self.__nombre = nombre
    
    def saluda(self):
        print(f"Hola, me llamo {self.__nombre}")
    
class Perro(Animal):
    def __init__(self, nombre, raza):
        self.__nombre = nombre # ❌ No accede al atributo heredado
        self.__raza = raza

perro = Perro("Firulais", "Labrador")
perro.saluda() # ❌ Error, no existe __nombre
# AttributeError: 'Perro' object has no attribute '_Animal__nombre'

Método super()

Permite acceder a los métodos de la clase base sobreescritos por la clase derivada.

class Animal:
    def __init__(self, nombre):
        self.__nombre = nombre

    def saluda(self):
        print(f"Hola, me llamo {self.__nombre}")

class Perro(Animal):
    def __init__(self, nombre, raza):
        super().__init__(nombre) # ✅ Inicializa los atributos heredados
        self.__raza = raza

perro = Perro("Firulais", "Labrador")
perro.saluda() # ✅ Ahora funciona

Atributos y métodos privados

Los campos privados siguen sin ser accesibles a las clases derivadas.

class Animal:
    def __init__(self, nombre):
        self.__nombre = nombre

class Perro(Animal):
    def __init__(self, nombre, raza):
        super().__init__(nombre)
        self.__raza = raza

    def saluda(self):
        print(f"Hola, me llamo {self.__nombre}")

perro = Perro("Firulais", "Labrador")
perro.saluda() # ❌ Error, no existe __nombre
# AttributeError: 'Perro' object has no attribute '_Perro__nombre'

Atributos y métodos protegidos

Los atributos y métodos protegidos sí son accesibles.

class Animal:
    def __init__(self, nombre):
        self._nombre = nombre

class Perro(Animal):
    def __init__(self, nombre, raza):
        super().__init__(nombre)
        self._raza = raza

    def saluda(self):
        print(f"Hola, me llamo {self._nombre}")

perro = Perro("Firulais", "Labrador")
perro.saluda() # ✅ Ahora funciona
Hola, me llamo Firulais

Clases abstractas

  • Tienen métodos no implementados @abstractmethod
  • Heredan de ABC (Abstract Base Class)
  • No se pueden instanciar
from abc import ABC, abstractmethod

class Animal(ABC):
    def respirar(self):
        print("Estoy respirando")

    @abstractmethod
    def hablar(self):
        pass

animal = Animal() # ❌ No se puede instanciar

Clases abstractas

Las clases derivadas deben sobreescribir los métodos abstractos para poder instanciarse.

from abc import ABC, abstractmethod

class Animal(ABC):
    def respirar(self):
        print("Estoy respirando")

    @abstractmethod
    def hablar(self):
        pass

class Perro(Animal):
    def hablar(self):
        print("Guau")

animal = Perro() # ✅ Perro SÍ se puede instanciar

Interfaces

  • Son contratos que describen cuál es el comportamiento esperado de una clase.
  • Definen los métodos que se deberán implementar, sin proporcionar su implementación.
  • Las clases pueden implementar un interfaz para adquirir ese comportamiento.

Interfaces

Interfaces

En Python se implementan como clases puramente abstractas.

# animal.py
from abc import ABC, abstractmethod

class Animal(ABC):
    @abstractmethod
    def respirar(self):
        pass

    @abstractmethod
    def hablar(self):
        pass
# perro.py
from animal import Animal

class Perro(Animal):
    def respirar(self):
        print("Estoy respirando")

    def hablar(self):
        print("Guau")

Herencia múltiple

En Python se permite heredar de varias clases, aunque es más frecuente hacerlo con interfaces y no heredar de varias superclases.

Polimorfismo

El polimorfismo es la capacidad de los objetos de comportarse de diferentes formas.

perro : Perro = Perro()
animal : Animal = Perro()

perro.ladra()    # ✅ Definido en Perro
animal.ladra()   # ❌ No debería hacerse
animal.respira() # ✅ Definido en Animal

Un objeto de tipo Perro también es un Animal.

Pero cuando se comporta como Animal, no deben usarse los métodos específicos de las clases derivadas.

Polimorfismo

La verdadera ventaja es poder usar distintas clases como si fueran del mismo tipo: la clase base o interfaz que tienen en común.

animales : list[Animal] = []
animales.append(Perro())
animales.append(Gato())

for animal in animales:
    animal.hablar()

Polimorfismo

Cada instancia se comporta de forma diferente dependiendo de su tipo específico (clase derivada).

animales : list[Animal] = []
animales.append(Perro())
animales.append(Gato())

for animal in animales:
    animal.hablar()
Guau
Miau

Polimorfismo

Y esto nos permite componer comportamientos que no dependen de ninguna subclase concreta.

Dependencias más débiles == Menor acoplamiento

👍🏼

Polimorfismo

Y esto nos permite componer comportamientos que no dependen de ninguna subclase concreta.

class Adiestrador:
    def adiestrar(animal: Animal): None
        print("Saluda!")
        animal.hablar()

Polimorfismo

La clase Adiestrador no tiene por qué saber nada de las subclases.

  • Encapsulación de comportamientos específicos
  • Menor acoplamiento en Adiestrador

Polimorfismo

El comportamiento es el mismo con interfaces.

class Trainer:
    def train(model: Model, dataloader: Dataloader, epochs: int): None
        for epoch in range(epochs)
            print(f"Epoch #{epoch}")
            for batch in dataloader:
                model.forward(batch)

Duck Typing

Duck Typing

Duck Typing

En Python, lo que importa es que un objeto tenga los métodos y atributos necesarios, sin importar su herencia explícita.

class Perro: # No hereda de Animal
    def hablar(self):
        print("Guau")

class Gato: # No hereda de Animal
    def hablar(self):
        print("Miau")

class Adiestrador:
    def adiestrar(animal): # Puede recibir cualquier objeto
        print("Saluda!")
        animal.hablar() # 🤯 Funciona!

Duck Typing

class Perro: # No hereda de Animal
    def hablar(self):
        print("Guau")

class Gato: # No hereda de Animal
    def hablar(self):
        print("Miau")

class Adiestrador:
    def adiestrar(animal): # Puede recibir cualquier objeto
        print("Saluda!")
        animal.hablar() # 🤯 Funciona!

Funciona siempre que el objeto recibido tenga el método hablar()

Duck Typing

Conceptualmente sigue siendo polimorfismo, aunque no se implemente la herencia.

Duck Typing

RECUERDA

Adiestrador no sabe qué subclases existen.

Esta representación no refleja bien el uso de las clases.

Vamos a mejorar este diseño

import logging
from sklearn.linear_model import LinearRegression
import numpy as np

# Configuración básica del logging
logging.basicConfig(level=logging.INFO)

class DataLoader:
    def cargar_datos(self, ruta):
        logging.info(f"Cargando datos desde {ruta}...")
        return [[1, 2], [0.5, -1], [0.2, 4]] # Aquí debería devolver los datos leídos

class Preprocessor:
    def filtrar(self, datos):
        logging.info("Filtrando datos...")
        return [x for x in datos if all(i >= 0 for i in x)]
    
    def normalizar(self, datos):
        logging.info("Aplicando normalización...")
        return [[(i - min(x)) / (max(x) - min(x)) for i in x] for x in datos]

class Predictor:
    def __media(self, datos):
        logging.info("Entrenando modelo de clasificación con %s", datos)
        valor_medio = sum(sum(x) for x in datos) / sum(len(x) for x in datos)
        return valor_medio
    
    def __regresion_lineal(self, datos):
        logging.info("Entrenando modelo de regresión con %s", datos)
        X = np.array([x[:-1] for x in datos])
        y = np.array([x[-1] for x in datos])
        modelo = LinearRegression()
        modelo.fit(X, y)
        return modelo

    def predecir(self, entrada, datos, modelo_tipo):
        if modelo_tipo == "media":
            return self.__media(datos)
        elif modelo_tipo == "regresion_lineal":
            modelo = self.__regresion_lineal(datos)
            prediccion = modelo.predict([entrada])
            return prediccion

if __name__ == "__main__":
    data_loader = DataLoader()
    preprocessor = Preprocessor()
    predictor = Predictor()

    datos = data_loader.cargar_datos('/ruta/al/dataset')
    datos = preprocessor.filtrar(datos)
    datos = preprocessor.normalizar(datos)
    logging.info("Datos preprocesados: %s", datos)
    prediccion = predictor.predecir([1], datos, 'regresion_lineal')
    logging.info("La predicción es %s", prediccion)

Principios SOLID

  • Single Responsibility Principle
  • Open/Closed Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency Inversion Principle

Principio de Responsabilidad Única

Single Responsibility Principle

Principio de Responsabilidad Única

Una clase sólo debe tener una razón para cambiar

Sólo tiene una responsabilidad == Alta cohesión ✅

Principio de Responsabilidad Única

Esta clase implementa dos preprocesados diferentes.

class Preprocessor:
    def filtrar(self, data):
        logging.info("Filtrando datos...")
        return [x for x in data if all(i >= 0 for i in x)]
    
    def normalizar(self, datos):
        logging.info("Aplicando normalización...")
        return [[(i - min(x)) / (max(x) - min(x)) for i in x] for x in datos]

Principio de Responsabilidad Única

La separamos en dos clases.

Si hay que cambiar un preproceso, lo hacemos de forma aislada.

class FilterPreprocessor:
    def filter(self, data):
        logging.info("Filtrando datos...")
        return [x for x in data if all(i >= 0 for i in x)]

class NormalizationPreprocessor:
    def normalize(self, data):
        logging.info("Aplicando normalización...")
        return [[(i - min(x)) / (max(x) - min(x)) for i in x] for x in data]

Principio de Responsabilidad Única

Hacemos lo mismo con los modelos.

¿Seguro que está bien?

Principio de Responsabilidad Única

La herencia/interfaces nos permitirán usar el polimorfismo.

Principio Abierto/Cerrado

Open/Closed Principle

Principio Abierto/Cerrado

Las entidades de software deben estar abiertas a la extensión, pero cerradas a la modificación

Debería poder añadir nuevos preprocesos o modelos sin modificar los que ya existen.

Principio Abierto/Cerrado

El método fit(data, dropout) es incompatible.

Principio Abierto/Cerrado

No deberíamos propagar los cambios ❌

Principio Abierto/Cerrado

Principio de Sustitución de Liskov

Liskov Substitution Principle

Principio de Sustitución de Liskov

Las subclases deben poder sustituir a sus superclases sin alterar el comportamiento correcto del sistema

No debería ser necesario saber el tipo exacto de una subclase.

Principio de Sustitución de Liskov

Este código sólo funciona para un modelo concreto ❌

class TrainingAlgorithm:
    def train_model(model, data_path):
        data = DataLoader().load_data(data_path)
        data = FilterPreprocessor().apply(data)
        data = NormalizationPreprocessor().apply(data)
        model.dropout = 0.5
        model.fit(data)

Principio de Sustitución de Liskov

Mejor ahora, pero se puede mejorar más .

class TrainingAlgorithm:
    def train_model(model, data_path):
        data = DataLoader().load_data(data_path)
        data = FilterPreprocessor().apply(data)
        data = NormalizationPreprocessor().apply(data)
        if isinstance(model, NeuralModel):
            model.dropout = 0.5
        model.fit(data)

Principio de Segregación de Interfaces

Interface Segregation Principle

Principio de Segregación de Interfaces

Una clase no debe ser obligada a depender de métodos que no utiliza

Es preferible aumentar la granularidad de los interfaces.

Principio de Segregación de Interfaces

Añadimos una nueva clase y un método para exportar modelos:

Pero sólo tiene sentido hacerlo con redes neuronales…

Principio de Segregación de Interfaces

class AverageModel:
    get_layers = None # No se puede usar

class LinearRegressionModel:
    get_layers = None # No se puede usar

class NeuralModel:
    def get_layers(self):
        return [] # Devuelve las capas del modelo

class ONNXExporter:
    def export(model, filename):
        if model.get_layers is not None:
            print(model.get_layers())

Principio de Segregación de Interfaces

En estos casos hay que dividir las interfaces.

Principio de Segregación de Interfaces

class ONNXExporter:
    def export(model, filename):
        if isinstance(model, Exportable)
            print(model.get_layers())

Principio de Inversión de Dependencias

Dependency Inversion Principle

Principio de Inversión de Dependencias

Los módulos de alto nivel deben depender de abstracciones y no de implementaciones concretas de módulos de bajo nivel

No deberían instanciarse directamente las subclases en el código que sólo depende de la superclase/interfaz.

Principio de Inversión de Dependencias

class TrainingAlgorithm:
    def train_model(model, data_path):
        data = DataLoader().load_data(data_path)
        data = FilterPreprocessor().apply(data)
        data = NormalizationPreprocessor().apply(data)
        if isinstance(model, NeuralModel):
            model.dropout = 0.5
        model.fit(data)

Principio de Inversión de Dependencias

Principio de Inversión de Dependencias

TrainingAlgorithm no depende de subclases.

class TrainingAlgorithm:
    def train_model(model, filters, data):
        data = DataLoader().load_data(data_path)
        for filter in filters:
            data = filter.apply(data)
        model.fit(data)

filters = [FilterPreprocessor(), NormalizationPreprocessor()]

if model_type == "neural":
    model = NeuralModel(dropout = 0.5)
elif model_type == "linear":
    model = LinearModel()
else
    model = AverageModel()

trainer = TrainingAlgorithm()
trainer.train_model(model, filters, "/path/to/data")

Inyección de dependencias

Hemos pasado las dependencias desde el exterior en lugar de crearlas internamente.

Esta técnica se llama Inyección de dependencias y favorece el uso de pruebas automatizadas

class TrainingAlgorithm:
    def train_model(model, filters, data):
        ...

trainer = TrainingAlgorithm()
trainer.train_model(model, filters, "/path/to/data")

Patrón Factory

Otra técnica para independizar el código de las subclases consiste en usar Factorías.

class ModelFactory:
    @classmethod
    def create_model(cls, model_type):
        if model_type == "neural":
            model = NeuralModel(dropout = 0.5)
        elif model_type == "linear":
            model = LinearModel()
        elif model_type == "average"
            model = AverageModel()
        else
            raise UnknownModelError(f"Incorrect model {model_type}")

model = ModelFactory.create_model(model_type)

Patrones de diseño

Patrones de diseño

Los patrones de diseño son soluciones reutilizables a problemas comunes en el desarrollo de software, que facilitan la creación de sistemas flexibles, escalables y mantenibles.

El patrón Factory que acabamos de ver es un ejemplo de este tipo de patrones.

Patrones de diseño

Otros ejemplos de patrones frecuentes:

  • Adapter
  • Observer
  • Template method

Patrón Adapter

Patrón Adapter

El patrón Adapter es un patrón estructural que permite que dos clases con interfaces incompatibles trabajen juntas, adaptando la interfaz de una a la que espera el cliente sin modificar el código existente.

Patrón Adapter

# Modelo legado con una interfaz diferente
class LegacyModel:
    def forward(self, input_data):
        # Lógica antigua para producir una salida
        return f"Salida del modelo legado para {input_data}"

# Adapter que adapta la interfaz LegacyModel al método predict esperado
class ModelAdapter:
    def __init__(self, legacy_model):
        self.legacy_model = legacy_model

    def predict(self, input_data):
        # Adapta la llamada a forward para cumplir la interfaz estándar
        return self.legacy_model.forward(input_data)

class TrainingAlgorithm:
    def train():
        legacy_model = LegacyModel()
        adapter = ModelAdapter(legacy_model)
        resultado = adapter.predict("datos de entrada")
        print(resultado)  # Salida: "Salida del modelo legado para datos de entrada"

Patrón Adapter

Patrón Observer

Patrón Observer

El patrón Observer permite a varios objetos suscribirse a otro (sujeto), de forma que cuando el sujeto cambia de estado, todos los observadores asociados son notificados y actualizados automáticamente.

Patrón Observer

from lightning.pytorch.callbacks import Callback


class MyPrintingCallback(Callback):
    def on_train_start(self, trainer, pl_module):
        print("Training is starting")

    def on_train_end(self, trainer, pl_module):
        print("Training is ending")


trainer = Trainer(callbacks=[MyPrintingCallback()])
trainer.fit(model, dataloader)

Patrón Observer

Patrón Observer

Este patrón es un ejemplo de Inversión de Control

Es un principio de diseño que invierte la responsabilidad de gestionar el flujo de ejecución y la creación de objetos, delegándola a un framework o contenedor en lugar de realizarla directamente en el código de la aplicación.

Patrón Template Method

Patrón Template Method

El patrón Template Method define el esqueleto de un algoritmo en una clase base, permitiendo que las subclases redefinan ciertos pasos sin cambiar la estructura general del algoritmo.

Patrón Template Method

from abc import ABC, abstractmethod

class DataProcessor(ABC):
    def process(self):
        """Método plantilla que define el esqueleto del algoritmo."""
        data = self.load_data()
        processed_data = self.process_data(data)
        self.save_data(processed_data)

    @abstractmethod
    def load_data(self):
        """Carga los datos desde alguna fuente."""
        pass

    @abstractmethod
    def process_data(self, data):
        """Procesa los datos cargados."""
        pass

    @abstractmethod
    def save_data(self, processed_data):
        """Guarda o utiliza los datos procesados."""
        pass

class CSVDataProcessor(DataProcessor):
    def load_data(self):
        print("Cargando datos desde CSV...")
        return "datos_csv"

    def process_data(self, data):
        print("Procesando datos CSV...")
        return data.upper()

    def save_data(self, processed_data):
        print("Guardando datos procesados en CSV:", processed_data)

class JSONDataProcessor(DataProcessor):
    def load_data(self):
        print("Cargando datos desde JSON...")
        return "datos_json"

    def process_data(self, data):
        print("Procesando datos JSON...")
        return data[::-1]

    def save_data(self, processed_data):
        print("Guardando datos procesados en JSON:", processed_data)

# Ejemplo de uso:
if __name__ == "__main__":
    print("Usando CSVDataProcessor:")
    csv_processor = CSVDataProcessor()
    csv_processor.process()

    print("\nUsando JSONDataProcessor:")
    json_processor = JSONDataProcessor()
    json_processor.process()

Patrón Template Method