1 de enero de 2022

Patrón de Diseño Template Method



Template Method es un patrón de diseño de comportamiento del ámbito de clases. Con este patrón se define el esqueleto de un algoritmo en una clase abstracta, y se deja que sean las subclases quienes proporcionen los detalles concretos de algunos o todos los pasos de ese algoritmo. Lo interesante de este patrón es que también da la opción a las subclases de redefinir los pasos de acuerdo con sus necesidades, sin cambiar la estructura del algoritmo.

En este tutorial te explico los detalles de Template Method, y te muestro una estrategia de implementación.

Este patrón ayuda a definir el esqueleto o estructura de un algoritmo, el cual se asume siempre tendrá los mismos pasos en el mismo orden, pero la forma de realizar estos pasos será diferente dependiendo de la situación particular que se quiera resolver.

Digamos que queremos construir una casa. Los pasos para construir una casa son siempre los mismos sin importar el material o la ubicación en la que esta se encuentra. Así que siempre haremos lo siguiente:
  1. Construir los cimientos.
  2. Colocar pilares.
  3. Construir el sótano.
  4. Construir el primer piso.
  5. Construir el segundo piso.
  6. Colocar el techo.
  7. Colocar los acabados.
  8. Pintar.

Si tenemos una casa de madera en el bosque o una casa de ladrillos en la ciudad siempre realizaremos los mismos pasos. Lo mismo ocurre si tendremos una casa permanente o una casa temporal (digamos que pasaremos una noche en el bosque y queremos construir un refugio para esa noche, o que está nevando y queremos construir un iglú).

Del párrafo anterior podemos decir lo siguiente: Template Method da la estructura del algoritmo a nivel de la clase base, (de construcción de casas en este ejemplo) pero deja que sean las subclases las que proporcionen los de cada uno de esos pasos.

  • P: ¿Qué ocurre si en mi ciudad hay grandes lluvias que generan inundaciones y tener un sótano es una mala idea?
  • R: Para esa implementación particular podemos omitir (colocar una implementación vacía) en el método de la construcción del sótano.

  • P: ¿Qué ocurre si mi casa solo tendrá un piso?
  • R: Similar a la respuesta anterior, podemos proporcionar una implementación vacía para la construcción del segundo piso o tener una implementación que reciba el número de pisos que tendrá la casa.

  • P: En los puntos anteriores, el paso para construir el primer piso y pintar se colocan al mismo nivel, pero construir un piso lleva mucho más esfuerzos y pasos intermedios que pintar.
  • R: Sí, pero eso no modifica el algoritmo. Pintar puede ser solo un método que cambie el color de la casa (o de los distintos elementos de la casa, dependiendo de la implementación), y construir un piso puede tener muchos pasos intermedios o un algoritmo interno por si mismo (colocar las paredes, cableados eléctricos, tuberías, etc) o ser tan sencillo como colocar un conjunto de ramas (si es que nuestra casa será una casa temporal para pasar una noche en el boque). Nuevamente, esto depende de la implementación que hagamos para la casa particular que estemos construyendo en ese momento.


Objetivo o intención del patrón

Con la explicación anterior, podemos ver que el objetivo de Template Method es:
  • Definir el esqueleto de un algoritmo en una operación (o método) y delegar algunos de los pasos a las subclases.
  • Permitir a las subclases redefinir ciertos pasos del algoritmo sin darles la opción de cambiar la estructura del algoritmo.


Implementación

La implementación de este patrón, como ya puedes estarte imaginando, es muy sencilla:
  1. Definir una clase abstracta con el esqueleto del patrón. Esta clase será la que contengan los pasos que conforman el algoritmo.
  2. Definir una serie de métodos abstractos que son los pasos del algoritmo. A estos métodos se les conoce formalmente como operaciones primitivas. No necesariamente deben ser métodos abstractos, si de antemano sabemos que sin importar la implementación que usemos, algún o algunos pasos siempre tendrán la misma implementación, podemos proporcionarlos como métodos definidos con lógica implementada. Sin embargo, es importante que estos métodos puedan ser sobre escritos por las clases concretas. Algo que no se recomienda es que todos los métodos sean concretos, ya que se pierde la intención del patrón de que el algoritmo (o los pasos concretos del mismo) sean implementados por las subclases.
  3. Definir un método final (para evitar ser modificado por las subclases) con los pasos que conforman el algoritmo. Este método es donde se invocan las operaciones primitivas. Normalmente este se implementa como una serie de llamadas a métodos, algunas condiciones y ciclos.
  4. Crear una o varias implementaciones concretas de la clase abstracta y proporcionar una implementación para los pasos abstractos del algoritmo.
  5. En el cliente, obtener alguna de estas implementaciones y ejecutar el método que ponga en marcha el algoritmo (el template method).

Tal vez el paso menos claro sea el paso 3. Este método, que es el corazón del patrón, es quien tiene la plantilla de la secuencia de los pasos del algoritmo. En la descripción menciono que podemos tener condiciones y ciclos, pero es importante que no escribamos en realidad detalles, sino que lo dejemos lo más abstracto posible. Aclaremos un poco esto usando el ejemplo de la construcción de casas:
public final Casa construyeCasa(Direccion direccion, Color color){
	Casa casa = new Casa(direccion);
	
	construyeCimientos(casa);
	colocaPilares(casa);
	if(isSotanoNecesario(casa){
		construyeSotano(casa);
	}
    
	construyePrimerPiso(casa);

 	if(isSegundoPisoNecesario(casa)){
		construyeSegundoPiso(casa);
	}
	
	colocaTecho(casa);
	colocaAcabados(casa);
	pinta(casa, color);
	
	return casa;
}

El algoritmo anterior tiene muchas oportunidades de mejora pero por ahora sirve bien para ilustrar la idea.

En el ejemplo anterior, construyeCasa es nuestro Template Method, construyeCimientos, colocaPilares, isSotanoNecesario, pinta, etc, son los pasos del algoritmo (las operaciones primitivas). Cada uno de estos es un método abstracto que debe ser implementado por las clases concretas.

Si, por ejemplo, pinta, tendrá siempre (o casi siempre) la misma implementación, podemos proporcionarlo como un método concreto similar a:
protected void pinta(Casa casa, Color color) {
	casa.setColor(color);
}

El hecho de que pinta sea protected, brinda la oportunidad de que cualquiera de sus subclases pueda reimplementarlo en cualquier momento.

Aquí es importante resaltar una cosa. Ya que el algoritmo, el template method, se encuentra en la clase abstracta y los detalles, las implementaciones de los métodos primitivos, en las clases concretas, es el código de la clase abstracta el que invocará a los métodos de la subclase. A esto se le conoce como El principio de Hollywood: “No nos llames, nosotros te llamamos”, al indicar que no son las subclases las que invocan a los métodos de la clase base, sino al revés. Este principio se usa mucho en inversión de control para reducir el acoplamiento entre clases.


Diagrama

Este es el diagrama del patrón.


 

Como puedes ver es muy directo. Tenemos una clase abstracta que contiene al templateMethod, y las operaciones primitivas definidas como otra serie de métodos abstractos.

También, tenemos a la clase (o clases) concretas que extienden den la clase abstracta e implementan los métodos correspondientes a las operaciones primitivas. A diferencia de otros patrones de diseño, en este caso la clase abstracta no puede ser sustituida por una interface, ya que es necesario que proporcione la implementación del templateMethod, de otra forma no habría manera de definir esta plantilla del algoritmo.


Ejemplo

Para los ejemplos uso Java 17 y Gradle. Hago uso de lambdas en cierta parte del código, por lo que debe funcionar en cualquier versión superior a la 8. También, uso Lombok para evitar escribir código repetitivo de forma innecesaria.

Para validar el correcto funcionamiento de la aplicación usaré JUnit y AssertJ. Agrega las siguientes dependencias en el archivo build.gradle (si usas Maven, coloca las dependencias correspondientes en el archivo pom.xml).
dependencies {
    compileOnly 'org.projectlombok:lombok:1.18.22'
    annotationProcessor 'org.projectlombok:lombok:1.18.22'
 
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
    testImplementation 'org.assertj:assertj-core:3.21.0'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'
}

Ahora sí, al código.

El problema que resolveremos es el siguiente: Debemos crear una aplicación que pueda recibir una serie de números enteros a través de un canal de datos, los transforme de alguna forma y regresará un único valor. Se puede generar una notificación en caso de que una o una serie de condiciones se cumplan. Para obtener los valores de entrada y regresar el valor de salida debemos usar un canal de comunicación; dependiendo de la implementación de canal puede ser necesario tener que abrirlo (establecer una conexión) y cerrarlo (desconectarse).

En el párrafo anterior he descrito un problema de manera general que puede resolverse de distintas formas, especialmente la parte que dice “los transforme de alguna forma” y “en caso de que una o una serie de condiciones se cumplan”. Lo único que he hecho es describir los pasos con los que debemos contar. Además, he descrito que se tendrá un canal de datos el cual potencialmente debe abrirse y cerrarse y que en algún punto puede generase una notificación.

Como puedes ver, si tomamos los elementos del párrafo anterior podemos generar un template method, que en este caso llamaré convierte, el cual se vea de la siguiente forma:
public abstract class ConversorDatos {

	public final int convierte(int[] numeros) {

		CanalDatos canalDatos = conecta(numeros);

		Dato datoEntrada = selecciona(canalDatos);

		Dato datoProcesado = procesa(datoEntrada);

		if(isNotificacionNecesaria(datoProcesado)){
			notifica(datoProcesado);
		}

		desconecta(canalDatos);

		return datoProcesado.getDatoProcesado();
	}   
}   

Quiero que veas que en código anterior hay dos cosas importantes. La primera es que he usado una clase abstracta, ConversorDatos, de esta forma me aseguro de que nadie podrá usar directamente esta clase y que se deberá proporcionar al menos una implementación de la misma. La segunda es que el método convierte es final. De esta forma me aseguro de que ninguna de las subclases pueda redefinir este método cambiando el algoritmo.

Para hacer énfasis: el template method proporciona los pasos o la estructura del algoritmo, pero sin indicar los detalles de la implementación. Puedes ver que tenemos un CanalDatos y un Dato. Estas son algunas clases que generamos siguiendo la especificación descrita.

Además, tenemos una serie de operaciones o métodos: conecta, selecciona, procesa, isNotificacionNecesaria, notifica y desconecta; estos son las operaciones primitivas que se describen en el paso 2 de la implementación del patrón. Como queremos que sea alguna subclase la que proporcione los detalles o implementación de los mismos, los definiré como una serie de métodos abstractos:
protected abstract CanalDatos conecta(int[] numeros);

protected abstract Dato selecciona(CanalDatos canalDatos);

protected abstract Dato procesa(Dato datoEntrada);

protected abstract boolean isNotificacionNecesaria(Dato dato);

protected abstract void notifica(Dato dato);

protected abstract void desconecta(CanalDatos canalDatos);

Aquí uso protected como modificador de acceso ya que estas operaciones primitivas no son métodos que tenga la intención de que puedan ser invocados desde cualquier lado, solo desde el código de la clase base.

📌 Nota: Existe una convención para los nombres de las operaciones primitivas. Esta convención dice que los nombres de las operaciones primitivas deben comenzar con “do” en inglés. Si seguimos esta convención en español los nombres de los métodos tendrían que comenzar con “haz”. Yo no lo he hecho porque me parece que gramaticalmente no es correcto. Pero si los nombres de estos métodos fueran en inglés, tendrían que llamarse: doConnect, doSelect, doProcess, doNotify y doDisconnect.
¿Todas las operaciones primitivas deben ser abstractas? No. Podemos proporcionar una implementación genérica para los pasos para los que tenga sentido. Digamos que por default queremos que el método isNotificacionNecesaria regrese false, podemos proporcionar una implementación como la siguiente:
protected boolean isNotificacionNecesaria(Dato dato){
    return false;
}

Así estamos indicando que no es necesario generar una notificación; pero además dejamos la posibilidad de que cualquiera de las subclases sobreescriba ese paso si lo necesita.

La clase CoversorDatos completa queda de la siguiente forma:
public abstract class ConversorDatos {
    public final int convierte(int[] numeros) {

        CanalDatos canalDatos = conecta(numeros);

        Dato datoEntrada = selecciona(canalDatos);

        Dato datoProcesado = procesa(datoEntrada);

        if(isNotificacionNecesaria(datoProcesado)){
            notifica(datoProcesado);
        }

        desconecta(canalDatos);

        return datoProcesado.getDatoProcesado();
    }

    protected abstract CanalDatos conecta(int[] numeros);

    protected abstract Dato selecciona(CanalDatos canalDatos);

    protected abstract Dato procesa(Dato datoEntrada);

    protected boolean isNotificacionNecesaria(Dato dato){
        return false;
    }

    protected abstract void notifica(Dato dato);

    protected abstract void desconecta(CanalDatos canalDatos);
}

Ahora debemos crear las implementaciones de las clases auxiliares CanalDatos y Dato. 🏆 Buena práctica. Por motivos de espacio y para no alargar el tutorial, crearé las tres clases anteriores como clases concretas; sin embargo, lo mejor sería crearlas como clases abstractas o interfaces y de esa forma podremos también reducir el acoplamiento entre las clases.
CanalDatos nos servirá para leer la información. Como en este ejemplo estamos trabajando con un arreglo de enteros tendremos la siguiente implementación:
@Data
@AllArgsConstructor
public class CanalDatos {
    private int[] datos;
}

En el caso de Dato, la usaremos para mantener la lista de enteros originales que estamos recibiendo, y para almacenar el resultado de la conversión de datos:
@Data
@RequiredArgsConstructor
public class Dato {
    private final int[] numerosSeleccionados;
    private int datoProcesado;
}

Lo siguiente es crear una subclase de ConversorDatos que proporcione implementaciones para cada uno de los métodos abstractos anteriores. En este caso crearé una clase que regrese la suma de los números pares que hayan sido proporcionados, si esta suma es mayor a 100 se creará una notificación. Vamos paso por paso.

A esta clase la llamaré ConversorDatosPares y extenderá de CoversorDatos:
public class ConversorDatosPares extends ConversorDatos {

}

Para el primer paso, conecta, lo que haré será crear un nuevo CanalDatos con el arreglo de enteros que recibe como parámetros:
@Override
protected CanalDatos conecta(int[] numeros) {
    return new CanalDatos(numeros);
}

En selecciona, filtraré los números que sean múltiplos de 2 (los números pares) y crearé un nuevo arreglo que contenga solo estos datos. Luego crearé una instancia de Dato pasando este nuevo arreglo a su constructor y lo regresaré:

📌 Nota: Todo el método siguiente podría quedar en una sola línea de código, pero lo separo en varias para hacer más explícito lo que está ocurriendo.
@Override
protected Dato selecciona(CanalDatos canalDatos) {

    int[] numerosPares = Arrays.stream(canalDatos.getDatos()).filter(numero -> numero % 2 == 0).toArray();

    Dato dato = new Dato(numerosPares);

    return dato;
}

Para el procesamiento de los datos realizaré la suma de todos los números que se encuentran en el arreglo, esa suma la usaré para crear un nuevo objeto de tipo Dato el cual tendrá dos atributos; el primero es la lista de los datos que conforman la suma (los números pares obtenidos en el paso anterior) y la suma de estos enteros. Regresaré esa instancia como valor de retorno del método:
@Override
protected Dato procesa(Dato datoEntrada) {
    int resultadoSuma = Arrays.stream(datoEntrada.getNumerosSeleccionados()).sum();

    Dato datoSalida = new Dato(datoEntrada.getNumerosSeleccionados());
    datoSalida.setDatoProcesado(resultadoSuma);

    return datoSalida;
}

En isNotificacionNecesaria validaré si la suma total es mayor a 100, en cuyo caso regresaré true, de lo contrario regresaré false. Esto ayudará a saber si es necesario enviar una notificación.
@Override
protected boolean isNotificacionNecesaria(Dato dato) {
    return dato.getDatoProcesado() > 100;
}

Si es necesario enviar una notificación, notifica debería hacerlo. Para este ejemplo notifica no hará nada, por lo que el cuerpo del método estará vacío.
@Override
protected void notifica(Dato dato) {
    
}

Finalmente, en la implementación de desconecta cerraremos el canal de datos. En este caso será tan simple como establecer el arreglo a null:
@Override
protected void desconecta(CanalDatos canalDatos) {
    canalDatos.setDatos(null);
}

La implementación completa de CoversorDatosPares queda de la siguiente forma:
public class ConversorDatosPares extends ConversorDatos {
    @Override
    protected CanalDatos conecta(int[] numeros) {
        return new CanalDatos(numeros);
    }

    @Override
    protected Dato selecciona(CanalDatos canalDatos) {

        int[] numerosPares = Arrays.stream(canalDatos.getDatos()).filter(numero -> numero % 2 == 0).toArray();

        Dato dato = new Dato(numerosPares);

        return dato;
    }

    @Override
    protected Dato procesa(Dato datoEntrada) {
        int resultadoSuma = Arrays.stream(datoEntrada.getNumerosSeleccionados()).sum();

        Dato datoSalida = new Dato(datoEntrada.getNumerosSeleccionados());
        datoSalida.setDatoProcesado(resultadoSuma);

        return datoSalida;
    }

    @Override
    protected boolean isNotificacionNecesaria(Dato dato) {
        return dato.getDatoProcesado() > 100;
    }

    @Override
    protected void notifica(Dato dato) {
        
    }

    @Override
    protected void desconecta(CanalDatos canalDatos) {
        canalDatos.setDatos(null);
    }
}

Y listo, la implementación que hemos hecho es así de sencilla.

El diagrama de nuestra implementación de Template Method es el siguiente:

 




Lo siguiente es validar que todo funciona de forma correcta, para eso crearemos una serie de pruebas unitarias. En la primera verificamos que la suma de los números del 1 al 10 nos regrese un valor de 30. Esto es, tendremos los siguientes datos de entrada:
1, 2, 3, 4, 5, 6, 7, 8, 9, 10


Filtraremos los números pares, por lo que el arreglo final será:
2, 4, 6, 8, 10

Y si hacemos la suma de los números anteriores obtendremos:
30;

Por lo que la primera prueba unitaria queda de la siguiente forma:
@Test
public void testConvierteDatos_cuandoValorMenorA100() {
    ConversorDatos conversorDatos = new ConversorDatosPares();

    assertThat(conversorDatos.convierte(new int[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10})).isEqualTo(30);
}

Si ejecutas la prueba anterior debes obtener como resultado que la prueba pasa correctamente.

En la segunda prueba obtendremos la suma de los números pares del 2 al 20; esto es, tenemos los siguientes datos de entrada:
2, 4, 6, 8, 10, 12, 14, 16, 18, 20

En donde todos los números son pares. Si realizamos la suma obtendremos como resultado el valor de 110:
@Test
public void testConvierteDatos_cuandoValorMayorA100() {
    ConversorDatos conversorDatos = new ConversorDatosPares();

    assertThat(conversorDatos.convierte(new int[]{2, 4, 6, 8, 10, 12, 14, 16, 18, 20})).isEqualTo(110);
}

Si ejecutas la prueba, esta también debe ejecutarse de forma correcta, por lo que podemos comprobar que nuestra implementación funciona correctamente.
🏆 Buena práctica: En este caso solo he creado dos pruebas unitarias que verifican el template method, convierte, esto para ahorrar un poco de espacio y porque el tutorial no está centrado en pruebas sino en el patrón de diseño. Lo mejor sería crear, además de estas dos pruebas unitarias, otras que validen cada uno de los pasos del algoritmo, de esta forma si hacemos alguna refactorización o cambio en cualquiera de los pasos del algoritmo podremos validar que cada uno sigue funcionando de forma adecuada o de lo contrario podremos saber exactamente en donde está el problema.
El patrón tiene un punto de extensión muy interesante que permite agregar algunos métodos adicionales que permiten informar de ciertos eventos de interés dentro del algoritmo y que su implementación es opcional para las subclases. A estos métodos especiales se les conoce como hooks, y normalmente se colocan antes y después de los pasos cruciales del algoritmo. En la clase base se proporciona una implementación vacía de estos hooks, con lo que se logra que el invocarlos no tenga ningún efecto, pero se permite que las subclases los implementen.

Existe una convención de nombres que indica que los nombres de los hooks a los que se informan antes de que ocurra el evento de interés deben tener el prefijo pre y a los que se les informa después del evento deben tener el prefijo post. Por ejemplo, si el evento de interés es la ejecución del método convierte, debemos tener dos hooks:
	preConvierte()
y
	postConverte()

y estos se invocarían al inicio y al final de la ejecución del método convierte:
public final int convierte(int[] numeros) {

    preConvierte();
    
    CanalDatos canalDatos = conecta(numeros);

    Dato datoEntrada = selecciona(canalDatos);

    Dato datoProcesado = procesa(datoEntrada);

    if(isNotificacionNecesaria(datoProcesado)){
        Notificacion notificacion = notifica(datoProcesado);
    }

    desconecta(canalDatos);

    postConvierte();
    
    return datoProcesado.getDatoProcesado();
}

Como mencioné antes: en la clase base proporcionamos una implementación vacía y, opcionalmente, en la clase concreta podemos sobreescribir estos métodos con una implementación adecuada.

No es necesario que los métodos reciban parámetros, pero podemos agregarlos si es que lo consideremos importante. Digamos que en preConvierte queremos pasar como parámetro el arreglo de enteros que recibimos, y en postConvierte el resultado de la operación de conversión. Estos dos métodos quedarían de la siguiente forma:
protected void preConvierte(int[] numeros) {

}

protected void postConvierte(int datoProcesado) {

}

y los invocaríamos de la siguiente forma:
public final int convierte(int[] numeros) {

    preConvierte(numeros);

    CanalDatos canalDatos = conecta(numeros);

    Dato datoEntrada = selecciona(canalDatos);

    Dato datoProcesado = procesa(datoEntrada);

    if(isNotificacionNecesaria(datoProcesado)){
        Notificacion notificacion = notifica(datoProcesado);
    }

    desconecta(canalDatos);

    postConvierte(datoProcesado.getDatoProcesado());

    return datoProcesado.getDatoProcesado();
}

De forma parecida, si quisiéramos informar antes de la ejecución de alguna de las operaciones primitivas, podemos proporcionar un par de hooks de la siguiente forma:
preProcesa(datoEntrada);

Dato datoProcesado = procesa(datoEntrada);

postProcesa(datoProcesado);

¿Debemos proporcionar siempre dos hooks, un pre y un post? Aunque esto es lo normal no es estrictamente necesario. Como siempre digo, solo hazlo si tiene sentido para la implementación particular que estás realizando.


Ventajas y Desventajas

  • Abstrae las partes invariantes de un algoritmo y delega a las subclases el que proporcionen los detalles concretos del mismo.
  • Ayuda a reducir la duplicidad de código.
  • Tiene puntos de extensión y notificación, conocidos como hooks, que pueden ser implementados en caso de ser necesario.
  • Solo se pude tener un algoritmo por template method, si se tienen dos algoritmos más o menos parecidos y se quieren poder utilizar a partir de l a misma clase base, es necesario comenzar a agregar más y más condiciones.
  • Una vez que la estructura del algoritmo está establecido y tenemos algunas subclases funcionando es muy complicado modificar el algoritmo, ya que cualquier cambio nos obligaría a modificar todas las subclases.


Otros Nombres

  • Template Pattern
  • Template


Conclusión

Este es un patrón de diseño muy simple en concepto e implementación. Se usa mucho especialmente en frameworks y librerías o para controlar el ciclo de vida de elementos de nuestra aplicación.

El corazón del algoritmo es definir el template method y asegurarse de que:
  1. el esqueleto del algoritmo no pueda ser modificado por las subclases, y
  2. proporcionar puntos de extensión a las subclases.

Como nota adicional, en el repositorio dejo, además del código, el archivo original en draw.io con los esquemas de los patrones.

Espero que este tutorial les sea de utilidad. Si tienen alguna duda, sugerencia, comentario o aclaración, pueden dejarla en la sección de comentarios o enviar un correo a programadorjavablog@gmail.com (pueden agregarme al chat de gmail). También puedes seguir JavaTutoriales en las siguientes redes sociales: Saludos y gracias.

Descarga los archivos de este tutorial desde mi repositorio en GitHub:
Entradas relacionadas: