Patrones de diseño en TypeScript
Los patrones de diseño son un conjunto de técnicas genéricas de resolución de problemas que suelen surgir con frecuencia a la hora de diseñar software. Cada una de esas técnicas necesitará de una adaptación al caso de uso específico al que nos estemos enfrentando.
Existen numerosos patrones de diseño, los cuales se pueden clasificar, principalmente, atendiendo a las tres siguientes categorías:
- Patrones creacionales: proporcionan mecanismos para la creación de objetos. Ejemplos de este tipo de patrones podrían ser Singleton, Prototype, Builder, Factory method o Abstract factory, entre otros.
- Patrones estructurales: describen cómo se combinan objetos y clases en estructuras más grandes. Entre estos patrones se pueden encontrar Adapter, Bridge, Composite, Decorator, Facade, Lightweight o Proxy, entre otros.
- Patrones de comportamiento: tienen en cuenta la comunicación y asignación de responsabilidades entre objetos. Algunos de estos patrones serían Chain of responsibility, Iterator, Observer, Strategy, Template method, Memento o State, entre otros.
Patrones creacionales
En esta sección iremos enumerando alguno de los patrones creacionales más utilizados.
Singleton
Este patrón creacional permite instanciar una clase una única vez, generalmente, para controlar el acceso a algún tipo de recurso compartido como, por ejemplo, un archivo, una base de datos o un generador de números aleatorios, entre otros. Además, el acceso proporcionado a dicha instancia única es global.
La primera vez que se accede al recurso se crea el objeto correspondiente y, en sucesivos accesos, en lugar de instanciar nuevos objetos, se proporciona el que se creó inicialmente.
Supongamos que quisiéramos crear una clase que modela una instancia del problema de la mochila. Una instancia de dicho problema viene caracterizada por un conjunto de elementos, cada uno de ellos con un peso y un beneficio asociados, además de una capacidad máxima que es capaz de soportar la mochila. Este problema podría modelarse mediante la siguiente clase:
type KnapsackItem = {
weight: number;
profit: number;
}
class KnapsackInstance {
constructor(private readonly items: KnapsackItem[],
private readonly capacity: number) {
}
getCapacity() {
return this.capacity;
}
getItems() {
return this.items;
}
getItem(index: number) {
if (index >= this.getNumberOfItems()) {
return undefined;
}
return this.items[index];
}
getNumberOfItems() {
return this.items.length;
}
}
const knapsackInstance = new KnapsackInstance([
{weight: 5, profit: 10},
{weight: 3, profit: 15},
{weight: 10, profit: 3},
], 20);
const secondKnapsackInstance = new KnapsackInstance([
{weight: 5, profit: 10},
{weight: 3, profit: 15},
{weight: 10, profit: 3},
], 20);
if (knapsackInstance === secondKnapsackInstance) {
console.log('We are the same object');
} else {
console.log('We are different objects');
}
Una instancia del problema de la mochila, esto es, un objeto de la clase KnapsackInstance podría verse
como un recurso compartido. Por ejemplo, diferentes algoritmos podrían tratar de resolver dicha instancia
del problema de la mochila y no sería necesario tener diferentes objetos de la clase KnapsackInstance
para una misma instancia del problema, dado que la información siempre sería la misma en todos los objetos
y tener varios de ellos sería redundante e ineficiente.
Aplicando el patrón Singleton, nuestra clase se vería modificada de la siguiente manera:
type KnapsackItem = {
weight: number;
profit: number;
}
class KnapsackInstance {
private items: KnapsackItem[];
private capacity: number;
private static knapsackInstance: KnapsackInstance;
private constructor() {
this.items = [];
this.capacity = 0;
}
public static getKnapsackInstance(): KnapsackInstance {
if (!KnapsackInstance.knapsackInstance) {
KnapsackInstance.knapsackInstance = new KnapsackInstance();
}
return KnapsackInstance.knapsackInstance;
}
getCapacity() {
return KnapsackInstance.knapsackInstance.capacity;
}
setCapacity(capacity: number) {
KnapsackInstance.knapsackInstance.capacity = capacity;
}
getItems() {
return KnapsackInstance.knapsackInstance.items;
}
setItems(items: KnapsackItem[]) {
KnapsackInstance.knapsackInstance.items = items;
}
getItem(index: number) {
if (index >= KnapsackInstance.knapsackInstance.getNumberOfItems()) {
return undefined;
}
return KnapsackInstance.knapsackInstance.items[index];
}
addItem(item: KnapsackItem) {
KnapsackInstance.knapsackInstance.items.push(item);
}
getNumberOfItems() {
return KnapsackInstance.knapsackInstance.items.length;
}
}
const knapsackInstance = KnapsackInstance.getKnapsackInstance();
knapsackInstance.addItem({weight: 10, profit: 30});
const secondKnapsackInstance = KnapsackInstance.getKnapsackInstance();
secondKnapsackInstance.addItem({weight: 20, profit: 20});
if (knapsackInstance === secondKnapsackInstance) {
console.log('We are the same object');
} else {
console.log('We are different objects');
}
KnapsackInstance.getKnapsackInstance().getItems().forEach((item) =>
console.log(item));
Tal y como puede observarse, hemos implementado el patrón Singleton de la siguiente manera:
- Se ha añadido un atributo privado estático
knapsackInstancecuyo tipo es la propia claseKnapsackInstancepara almacenar la única instancia que va a tener dicha clase. - Se ha declarado el constructor de la clase como privado, por lo que no puede invocarse desde fuera de la propia clase.
- Se ha declarado un método público estático
getKnapsackInstance. Durante la primera invocación a dicho método se crea la única instancia de la claseKnapsackInstancea través de su constructor. En sucesivas invocaciones al método, se devuelve la instancia de la clase creada durante la primera invocación. - Desde fuera de la clase
KnapsackInstancese debe acceder a la única instancia de dicha clase a través del método estáticogetKnapsackInstance.
El uso del patrón Singleton también tiene algunas desventajas. La principal de ella es que rompe con el principio Single responsibility de SOLID, dado que las clases Singleton suelen resolver varias tareas simultáneamente como, por ejemplo, asegurarse de que la clase tenga una única instancia y, a la vez, proporcionar un punto de acceso común a dicha instancia única.
Factory method
Este patrón establece que, en lugar de crear diferentes productos u objetos directamente a través de sus constructores, lo que debe hacerse es utilizar un método de creación especial denominado método fábrica. Los productos se siguen creando a través de sus respectivos constructores pero invocados desde el método fábrica.
Lo anterior permite que se pueda sobreescribir el método fábrica de una superclase creadora en diferentes subclases creadoras y, por lo tanto, disponer de diferentes productos que se pueden crear a partir del método fábrica. Estas subclases creadoras solo podrán devolver diferentes productos siempre y cuando dichos productos tengan una clase base o interfaz común de producto. Además, el método fábrica siempre debe devolver productos cuyo tipo sea dicha clase base o interfaz de producto.
El código cliente, es decir, el que utiliza el método fábrica, no diferencia los productos creados por el método fábrica, dado que los trata como de la clase base o interfaz común de producto, por lo que tampoco conoce detalles acerca de su implementación concreta.
Veamos lo anterior con un ejemplo:
type ColorType = 'red' | 'yellow' | 'blue' | 'green';
/**
* This class declares an abstract factory method (or a default factory
* method) that returns a TwoDimensionalFigure object. Subclasses may
* provide a particular implementation of this method.
*/
abstract class TwoDimensionalFigureCreator {
public abstract factoryMethod(): TwoDimensionalFigure;
/**
* Logic that relies on TwoDimensionalFigure objects returned by
* the factory method. Subclasses can change this logic indirectly
* by overriding the factory method and returning a different
* TwoDimensionalFigure object.
*/
public logic(): string {
const twoDimensionalFigure = this.factoryMethod();
return `I am a ${twoDimensionalFigure.getName()}, ` +
`I am ${twoDimensionalFigure.getColor()} and ` +
`my area is equal to ${twoDimensionalFigure.getArea()}`;
}
}
/**
* Concrete implementation of the class TwoDimensionalFigureCreator that
* returns Rectangle objects through the factory method. It can be observed
* how the concrete implementation of the factory method still returns
* a TwoDimensionalFigure object, which is an abstract type
*/
class RectangleCreator extends TwoDimensionalFigureCreator {
constructor(private readonly name: string, private color: ColorType,
private base: number, private height: number) {
super();
}
public factoryMethod(): TwoDimensionalFigure {
return new Rectangle(this.name, this.color, this.base, this.height);
}
}
/**
* Concrete implementation of the class TwoDimensionalFigureCreator that
* returns Circle objects through the factory method. It can be observed
* how the concrete implementation of the factory method still returns
* a TwoDimensionalFigure object, which is an abstract type
*/
class CircleCreator extends TwoDimensionalFigureCreator {
constructor(private readonly name: string, private color: ColorType,
private radius: number) {
super();
}
public factoryMethod(): TwoDimensionalFigure {
return new Circle(this.name, this.color, this.radius);
}
}
/**
* Interface that declares all the common functionality that concrete
* TwoDimensionalFigure objects have to implement
*/
interface TwoDimensionalFigure {
getName(): string;
getColor(): ColorType;
getArea(): number;
}
/**
* Class that provides a concrete implementation of a TwoDimensionalFigure object
*/
class Rectangle implements TwoDimensionalFigure {
private readonly sides = 4;
constructor(private readonly name: string, private color: ColorType,
private base: number = 1, private height: number = 1) {
}
getSides() {
return this.sides;
}
getName() {
return this.name;
}
getColor() {
return this.color;
}
getBase() {
return this.base;
}
getHeight() {
return this.height;
}
setColor(color: ColorType) {
this.color = color;
}
setBase(base: number) {
this.base = base;
}
setHeight(height: number) {
this.height = height;
}
getArea() {
return this.base * this.height;
}
}
/**
* Class that provides a concrete implementation of a TwoDimensionalFigure object
*/
class Circle implements TwoDimensionalFigure {
constructor(private readonly name: string, private color: ColorType,
private radius: number = 1) {
}
getName() {
return this.name;
}
getColor() {
return this.color;
}
getRadius() {
return this.radius;
}
setColor(color: ColorType) {
this.color = color;
}
setRadius(radius: number) {
this.radius = radius;
}
getArea() {
return Math.PI * Math.pow(this.radius, 2);
}
}
/**
* Client code that works with an instance of a concrete creator through its
* common superclass TwoDimensionalFigureCreator.
*/
function clientCode(twoDimensionalFigureCreator: TwoDimensionalFigureCreator) {
console.log(twoDimensionalFigureCreator.logic());
}
clientCode(new RectangleCreator('RedRectangle', 'red', 10, 5));
clientCode(new CircleCreator('BlueCircle', 'blue', 7));
Se ha implementado el patrón de diseño Factory method de la siguiente manera:
- Existen diferentes productos u objetos que se desean crear mediante una clase
creadora. Todos esos productos deben implementar la misma interfaz, la cual declara
métodos que tienen sentido en todos los productos. En nuestro caso, dicha interfaz
viene dada por
TwoDimensionalFigurey declara métodos comunes a todas las figuras de dos dimensiones como, por ejemplo, el métodogetArea. - Los productos u objetos concretos son implementaciones de la interfaz de producto
TwoDimensionalFigure, en este caso,RectangleyCircle. - La clase creadora, que en nuestro caso es
TwoDimensionalFigureCreator, declara el métodofactoryMethodque devuelve nuevos productos. Es importante que el tipo de retorno de este método coincida con la interfaz de productoTwoDimensionalFigure. El métodofactoryMethodpuede ser abstracto para obligar a que todas las subclases creadoras lo implementen, aunque también podría devolver un producto por defecto, en caso de que no se sobreescribafactoryMethoden alguna subclase creadora. Por último, es importante mencionar que la principal responsabilidad de la clase creadora no suele ser crear diferentes productos, sino que implementa algún tipo de lógica central relacionada con los productos que crea. En nuestro caso, dicha lógica viene dada por el métodologicde la clase creadoraTwoDimensionalFigureCreator. - Las subclases creadoras concretas, en nuestro caso,
RectangleCreatoryCircleCreator, sobreescriben, si fuera necesario, el métodofactoryMethodpara que devuelva un producto diferente en cada caso. Es importante que el tipo devuelto por el métodofactoryMethodde una subclase creadora siga siendo el de la interfaz de producto, es decir,TwoDimensionalFigure, considerando nuestro ejemplo. - Por último, el código cliente, modelado en nuestro ejemplo a través de la función
clientCodeno diferencia entre diferentes objetos creadores. Solo sabe de la existencia de un métodologicque es común a todos los objetos creadoresTwoDimensionalFigureCreator. El comportamiento de dicho métodologices modificado, indirectamente, a través de la particularización de los métodos de los que hace uso como, por ejemplo,getArea, cuya implementación varía con cada producto, en nuestro caso,RectangleyCircle.
El resultado de la ejecución de nuestro ejemplo es el siguiente:
I am a RedRectangle, I am red and my area is equal to 50
I am a BlueCircle, I am blue and my area is equal to 153.93804002589985
Una de las principales ventajas del uso del patrón de diseño Factory method es que se respetan los principios SOLID Single responsibility y Open-closed. Además, se evitan acoplamientos entre creadores y productos.
Patrones estructurales
En esta sección iremos enumerando alguno de los patrones estructurales más utilizados.
Adapter
Este patrón estructural permite que objetos con interfaces incompatibles puedan colaborar.
Un adaptador permite transformar la interfaz de un objeto para que otro objeto sea capaz de entenderla. De este modo, por ejemplo, un adaptador se puede utilizar para llevar a cabo transformaciones de datos con diferentes formatos.
Supongamos una situación en la que un sistema A gestiona información que se encuentra en formato CSV, mientras que el sistema B trabaja con datos en formato JSON. Nuestro código cliente es capaz de comprender la interfaz del sistema A, pero no la del sistema B:
// Client code is able to work properly with the interface of SystemA
class SystemA {
constructor(private csvData: string = '') {
}
getData(): string {
return this.csvData;
}
}
type JSONData = {
name: string;
surname: string;
username: string;
}
// Client code wants to use SystemB, but its interface is not compatible with
// the current client code
class SystemB {
constructor(private jsonData: JSONData = {
name: '', surname: '', username: ''}) {
}
getSpecificData(): JSONData {
return this.jsonData;
}
}
// Initialization of systems A and B
const systemA = new SystemA('Eduardo,Segredo,esegredo');
const systemB = new SystemB({
name: 'Eduardo',
surname: 'Segredo',
username: 'esegredo',
});
// Client code
function clientCode(data: string) {
console.log(data);
}
clientCode(systemA.getData());
console.log(systemB.getSpecificData());
// This provokes an error. The client code does not understand the interface
// provided by SystemB
// clientCode(systemB.getSpecificData());
El patrón Adapter podría utilizarse para resolver el problema anterior. En concreto, podríamos hacer lo siguiente:
// Client code is able to work properly with the interface of SystemA
class SystemA {
constructor(private csvData: string = '') {
}
getData(): string {
return this.csvData;
}
}
type JSONData = {
name: string;
surname: string;
username: string;
}
// Client code wants to use SystemB, but its interface is not compatible with
// the current client code
class SystemB {
constructor(private jsonData: JSONData = {
name: '', surname: '', username: ''}) {
}
getSpecificData(): JSONData {
return this.jsonData;
}
}
// Adapter class that makes SystemA to understand the interface of SystemB
class Adapter extends SystemA {
constructor(private service: SystemB) {
super();
}
getData(): string {
return `${this.service.getSpecificData().name},` +
`${this.service.getSpecificData().surname},` +
`${this.service.getSpecificData().username}`;
}
}
// Initialization of systems A and B
const systemA = new SystemA('Eduardo,Segredo,esegredo');
const systemB = new SystemB({
name: 'Eduardo',
surname: 'Segredo',
username: 'esegredo',
});
// Client code
function clientCode(data: string) {
console.log(data);
}
clientCode(systemA.getData());
console.log(systemB.getSpecificData());
// Now, the client code understands the interface provided by SystemB
// through the adapter
const adapter = new Adapter(systemB);
clientCode(adapter.getData());
En concreto, hemos hecho lo siguiente para implementar el patrón Adapter:
- Partimos de la premisa de que queremos utilizar en nuestro código cliente la funcionalidad
de una clase o servicio cuyo código no se puede modificar o que sería muy complicado hacerlo.
En este caso, dicha funcionalidad viene dada por la clase
SystemB, que almacena información en formato JSON. - La interfaz con el código cliente viene dada por la clase
SystemA. Dicha clase debe contener todo lo necesario para comunicar nuestro código cliente con un servicio concreto. Por ejemplo, nuestro código cliente necesita de un métodogetDataque obtiene información en formato CSV, independientemente del servicio que proporcione dicha información. - La clase adaptadora
Adapterextiende aSystemA, por lo que cumple con la interfaz requerida por el código cliente. En nuestro caso,Adapterhereda un métodogetData. - Además, la clase
Adaptercontiene un atributoserviceque almacena una referencia a un objeto de la claseSystemB. - En este punto, se deberán sobreescribir, en la clase adaptadora, todos los métodos definidos
en la interfaz con el código cliente. En nuestro caso, dicha interfaz viene dada por
SystemAy solo debemos sobreescribir el métodogetDataen la claseAdapter. Se puede observar comogetDatahace uso de la interfaz específica deSystemBpara llevar a cabo la conversión de formato desdeSystemBaSystemA.
Entre las principales ventajas de este patrón de diseño cabe mencionar que permite cumplir con los principios SOLID Single responsibility y Open-closed.
Facade
Supongamos un conjunto complejo de clases o una librería que queremos utilizar. Este patrón provee una interfaz simplificada que facilita la utilización de entidades como las anteriores.
Generalmente, el patrón es aplicable cuando deseamos que nuestro código fuente pueda trabajar con objetos pertenecientes a una librería compleja o, incluso, a diferentes librerías. Generalmente, en estos casos es necesario inicializar todos los objetos y orquestar la ejecución de los métodos pertenecientes a esos objetos siguiendo un orden determinado.
/**
* One of the libraries used by our client code.
*/
class ProductLibrary {
public simpleProduct(a: number = 0, b: number = 0) {
return a * b;
}
public complexProduct(...numbers: number[]) {
return numbers.reduce((number, result) => result * number);
}
}
/**
* Another library used by the client code.
*/
class AdditionLibrary {
public simpleAddition(a: number = 0, b: number = 0) {
return a + b;
}
public complexAddition(...numbers: number[]) {
return numbers.reduce((number, result) => result + number);
}
}
/**
* The client code works with the above libraries directly.
*/
function clientCode(base: number, power: number, deltas: number[]) {
const productLibrary = new ProductLibrary();
const additionLibrary = new AdditionLibrary();
const myNumbers: number[] = [];
for (let index = 0; index < power; index++) {
myNumbers.push(base);
}
return productLibrary.complexProduct(...myNumbers) +
additionLibrary.complexAddition(...deltas);
}
console.log(clientCode(3, 5, [1, 2, 3]));
En el ejemplo anterior debemos pensar que, las librerías que utilizamos directamente en el código
cliente, en nuestro caso representadas por las clases ProductLibrary y AdditionLibrary, son
significativamente complejas. También debemos pensar que en las situaciones donde este patrón
es aplicable no solo hacemos uso de dos librerías, sino de muchas más. El uso de una nueva librería
en nuestro código cliente o el hecho de que cambie la interfaz proporcionada por alguna de las librerías
que utilizamos hace que tengamos que modificar el código cliente.
La solución que propone el patrón Facade es proporcionar una interfaz simplificada a una librería más compleja o a un conjunto de diferentes librerías. Debe limitarse a proporcionar aquella funcionalidad realmente importante para nuestro código cliente. A continuación, se ilustra una posible implementación del patrón Facade teniendo en cuenta nuestro ejemplo anterior:
/**
* One of the libraries used by the Facade. The library can
* directly work with the client code or the Facade.
*/
class ProductLibrary {
public simpleProduct(a: number = 0, b: number = 0) {
return a * b;
}
public complexProduct(...numbers: number[]) {
return numbers.reduce((number, result) => result * number);
}
}
/**
* Another library used by the Facade.
*/
class AdditionLibrary {
public simpleAddition(a: number = 0, b: number = 0) {
return a + b;
}
public complexAddition(...numbers: number[]) {
return numbers.reduce((number, result) => result + number);
}
}
/**
* The Facade class provides a simple interface to a specific part of the
* functionality provided by the set of libraries that it uses.
*/
class Facade {
constructor(protected productLibrary: ProductLibrary,
protected additionLibrary: AdditionLibrary) {
}
/**
* The methods provided by the Facade should only give access to that
* functionality provided by the libraries that is important for the
* client code. At the same time, they should orchestrate the
* invocation of those methods in a particular order.
*/
public powAndAdd(base: number, power: number, deltas: number[]) {
const myNumbers: number[] = [];
for (let index = 0; index < power; index++) {
myNumbers.push(base);
}
return this.productLibrary.complexProduct(...myNumbers) +
this.additionLibrary.complexAddition(...deltas);
}
}
/**
* The client code works with complex libraries through the simple interface
* provided by the Facade, rather than invoking the methods of the libraries
* directly.
*/
function clientCode(facade: Facade) {
console.log(facade.powAndAdd(3, 5, [1, 2, 3]));
}
clientCode(new Facade(new ProductLibrary(), new AdditionLibrary()));
Ambos ejemplos vistos con anterioridad muestran el valor 249 por la consola,
tras su ejecución.
En concreto, hemos seguido los siguientes pasos para implementar el patrón Facade en nuestro ejemplo:
- El patrón siempre es aplicable en aquellas situaciones donde sea posible proporcionar
una interfaz más simple que aquellas proporcionadas por las librerías que queremos utilizar, en
nuestro caso, las librerías representadas por las clases
ProductLibraryyAdditionLibrary. Generalmente, al igual que ocurre con el patrón Adapter, el código fuente de dichas librerías no se encuentra disponible o no puede ser modificado. Además, en este caso, podemos trabajar con esas librerías directamente desde el código cliente. Si no pudiéramos, estaríamos en una situación donde, seguramente, tendríamos que implementar el patrón Adapter. - Esta interfaz que proporciona una funcionalidad más simplificada y limitada de las librerías debe
implementarse como una clase fachada que, en nuestro ejemplo, hemos denominado
Facade. Al mismo tiempo, los métodos de la claseFacadedeberán ser capaces de orquestar el uso de los métodos de las librerías. Tal y como puede observarse, el métodopowAndAddde la claseFacadehace uso de los métodoscomplexProductycomplexAdditionde las libreríasProductLibraryyAdditionLibrary, respectivamente. - A pesar de poder utilizar las librerías directamente desde el código cliente, este último debería comunicarse con las librerías exclusivamente a través de la fachada. Lo anterior permitiría que, por ejemplo, si se modificase la interfaz de alguna de las librerías, solamente se tendría que modificar la fachada para adaptarnos a la nueva interfaz, en lugar de tener que modificar el código cliente.
- Si la clase
Facadeactual se volviera poco manejable, se podría crear una nueva clase fachada que incorporase parte de la funcionalidad extraida de la claseFacadeactual.
Por último, cabe mencionar que la principal ventaja de este patrón estructural es que se podemos desacoplar nuestro código cliente de la complejidad de los múltiples sistemas o librerías que utiliza.
Patrones de comportamiento
En esta sección iremos enumerando alguno de los patrones de comportamiento más utilizados.
Observer
Este patrón de diseño permite a un objeto definir un método de suscripción para que otros objetos, denominados observadores o suscriptores, reciban una notificación con cada evento que le suceda al objeto observado.
El patrón Observer es bastante habitual en aquellos casos en los que tenemos que manejar componentes en interfaces gráficas de usuario. Permite definir clases manejadoras (observers o listeners) que reaccionan ante un evento concreto como, por ejemplo, hacer clic con el ratón sobre un botón. A continuación, se muestra un ejemplo de implementación en TypeScript:
/**
* Interface for observable classes
*/
interface Observable {
subscribe(observer: Observer): void;
unsubscribe(observer: Observer): void;
notify(): void;
}
/**
* Interface for observer classes
*/
interface Observer {
update(observable: Observable): void;
}
enum ButtonClickEventType {'NO_EVENT', 'LEFTCLICK', 'RIGHTCLICK', 'CENTERCLICK'};
/**
* Class Button that implements the Observable interface, i.e.,
* Button objects can be observed
*/
class Button implements Observable {
private observers: Observer[] = [];
private eventType: ButtonClickEventType = ButtonClickEventType.NO_EVENT;
constructor(private id: number, private name: string) {
}
getId() {
return this.id;
}
getName() {
return this.name;
}
getEventType() {
return this.eventType;
}
subscribe(observer: Observer) {
if (this.observers.includes(observer)) {
throw new Error('The observer had already been subscribed');
} else {
this.observers.push(observer);
}
}
unsubscribe(observer: Observer) {
const index = this.observers.indexOf(observer);
if (index === -1) {
throw new Error('The observer has not been subscribed');
} else {
this.observers.splice(index, 1);
}
}
notify() {
this.observers.forEach((observer) => observer.update(this));
}
onLeftClick() {
this.eventType = ButtonClickEventType.LEFTCLICK;
this.notify();
}
onRightClick() {
this.eventType = ButtonClickEventType.RIGHTCLICK;
this.notify();
}
onCenterClick() {
this.eventType = ButtonClickEventType.CENTERCLICK;
this.notify();
}
}
/**
* Class ButtonObserver that implements the interface Observer, i.e.,
* it is able to observe other objects
*/
class ButtonObserver implements Observer {
constructor(private id: number, private name: string) {
}
getId() {
return this.id;
}
getName() {
return this.name;
}
update(observable: Observable) {
if (observable instanceof Button) {
switch(observable.getEventType()) {
case ButtonClickEventType.LEFTCLICK:
console.log(`I am a ButtonObserver called ${this.name} ` +
`and I have observed that Button ${observable.getName()} ` +
`was left-clicked with the mouse`);
break;
case ButtonClickEventType.RIGHTCLICK:
console.log(`I am a ButtonObserver called ${this.name} ` +
`and I have observed that Button ${observable.getName()} ` +
`was right-clicked with the mouse`);
break;
case ButtonClickEventType.CENTERCLICK:
console.log(`I am a ButtonObserver called ${this.name} ` +
`and I have observed that Button ${observable.getName()} ` +
`was center-clicked with the mouse`);
break;
}
}
}
}
// Client code
const myButton = new Button(0, 'myButton');
const firstButtonObserver = new ButtonObserver(0, 'firstButtonObserver');
const secondButtonObserver = new ButtonObserver(1, 'secondButtonObserver');
console.log('firstButtonObserver subscription');
myButton.subscribe(firstButtonObserver);
console.log('secondButtonObserver subscription');
myButton.subscribe(secondButtonObserver);
try {
myButton.subscribe(secondButtonObserver);
} catch (error) {
console.log('secondButtonObserver was already subscribed');
}
console.log('myButton left click');
myButton.onLeftClick();
console.log('firstButtonObserver unsubscription');
myButton.unsubscribe(firstButtonObserver);
console.log('myButton right click');
myButton.onRightClick();
console.log('myButton center click');
myButton.onCenterClick();
Teniendo en cuenta el ejemplo anterior, se han seguido los siguientes pasos para implementar el patrón Observer:
- Hemos definido la interfaz
Observable, la cual deberán implementar todas las clases que quieran ser observadas, o lo que es lo mismo, todas aquellas clases que quieran notificar de algún evento. Dicha interfaz define los métodos de suscripciónsubscribeyunsubscribede objetosObserver, es decir, objetos suscriptores. Además, también hemos definido un métodonotify, el cual permite notificar de un evento concreto a todos los suscriptores. - Hemos definido la interfaz
Observer, la cual deberán implementar todas las clases que quieran observar, o lo que es lo mismo, las clases suscriptoras. Esta interfaz cuenta con un métodoupdateque recibe un objetoObservable, de modo que los objetos suscriptores puedan extraer la información del evento notificado desde el propio objeto que notifica. - Se ha definido una clase concreta
Buttonque implementa la interfazObservable. Quizás, si se tuvieran diferentes tipos de objetos observables o notificadores, lo ideal sería crear una clase abstracta que implemente dicha interfazObservablepara, luego, extender dicha clase abstracta y así poder particularizar la implementación de los métodos suscriptores. La claseButtontambién implementa algunos métodos propios como, por ejemplo,onLeftClick, los cuales permiten modificar el estado de un objetoButtony notificar de ello a todos los suscriptores. - Se ha definido una clase concreta
ButtonObserverque implementa la interfazObserver. Al igual que en el paso anterior, si se tuvieran diferentes tipos de objetos suscriptores, lo ideal sería crear una clase abstracta que implementeObserverpara, luego, extender dicha clase abstracta y así poder particularizar la implementación del método de actualizaciónupdate. En nuestro ejemplo, el métodoupdatecomprueba, en primer lugar, el tipo del objeto notificador que nos ha notificado. Si es un objetoButton, entonces comprobamos su estado y, en función del mismo, se gestiona la notificación de un modo u otro. - El código cliente se encarga de crear todos los objetos suscriptores necesarios, además de suscribirlos a los objetos notificadores correspondientes.
La consola muestra lo siguiente al ejecutar el ejemplo anterior:
firstButtonObserver subscription
secondButtonObserver subscription
secondButtonObserver was already subscribed
myButton left click
I am a ButtonObserver called firstButtonObserver and I have observed that Button myButton was left-clicked with the mouse
I am a ButtonObserver called secondButtonObserver and I have observed that Button myButton was left-clicked with the mouse
firstButtonObserver unsubscription
myButton right click
I am a ButtonObserver called secondButtonObserver and I have observed that Button myButton was right-clicked with the mouse
myButton center click
I am a ButtonObserver called secondButtonObserver and I have observed that Button myButton was center-clicked with the mouse
Entre las principales ventajas del patrón de diseño Observer, cabe mencionar que se respeta el principio SOLID Open-closed. Se pueden introducir nuevas clases suscriptoras sin necesidad de modificar el código fuente de la clase notificadora, y viceversa.
Strategy
El patrón de comportamiento Strategy permite definir una familia de algoritmos, cada uno de ellos en una clase independiente, de manera que los diferentes objetos de esas clases sean intercambiables.
Generalmente, el patrón es aplicable cuando se dispone de una clase, denominada contexto, que lleva a cabo una funcionalidad específica de maneras diferentes como, por ejemplo, aplicar diferentes métodos de ordenación a una colección o resolver un problema de optimización mediante diferentes tipos de técnicas algorítmicas:
enum ALGORITHM_TYPE {FIRST, SECOND, THIRD};
/**
* This is an example of context class where different algorithms are available
*/
class Solver {
constructor(private data: number[], private algorithmType: ALGORITHM_TYPE) {
}
/**
* Any type of algorithm that operates over data
*/
firstAlgorithm() {
console.log(`First algorithm applied to: ${this.data}`);
}
/**
* Any type of algorithm that operates over data
*/
secondAlgorithm() {
console.log(`Second algorithm applied to: ${this.data}`);
}
/**
* Any type of algorithm that operates over data
*/
thirdAlgorithm() {
console.log(`Third algorithm applied to: ${this.data}`);
}
logic() {
switch (this.algorithmType) {
case ALGORITHM_TYPE.FIRST:
this.firstAlgorithm();
break;
case ALGORITHM_TYPE.SECOND:
this.secondAlgorithm();
break;
case ALGORITHM_TYPE.THIRD:
this.thirdAlgorithm();
break;
default:
throw new Error('No valid algorithm');
}
}
}
let mySolver = new Solver([1, 2, 3], ALGORITHM_TYPE.FIRST);
mySolver.logic();
mySolver = new Solver([1, 2, 3], ALGORITHM_TYPE.SECOND);
mySolver.logic();
mySolver = new Solver([1, 2, 3], ALGORITHM_TYPE.THIRD);
mySolver.logic();
En el ejemplo anterior la clase Solver representa el contexto. Se puede observar como los diferentes
algoritmos se encuentran implementados, directamente, en dicha clase. Además, si quisiéramos incluir
un cuarto algoritmo, deberemos modificar la propia clase contexto, incluyendo la implementación del
propio algoritmo, además de contemplarlo en la sentencia switch-case del método logic. El resultado
de la ejecución del ejemplo anterior es el siguiente:
First algorithm applied to 1,2,3
Second algorithm applied to 1,2,3
Third algorithm applied to 1,2,3
Strategy propone extraer cada uno de esos algoritmos del contexto para incluirlos en clases independientes denominadas estrategias. En este punto cabe mencionar que todas las estrategias deberán implementar una interfaz común. Luego, la clase contexto deberá almacenar una referencia a una estrategia, la cual recibirá desde el código cliente. También dispondrá de un setter que permitirá cambiar la estrategia aplicada en tiempo de ejecución. Teniendo en cuenta todo lo anterior, el contexto se vuelve independiente de las estrategias, de modo que se pueden añadir nuevas estrategias o modificar las ya existentes sin necesidad de modificar el contexto. Nuestro ejemplo anterior con el patrón Strategy implementado quedaría tal y como sigue:
/**
* This is an example of context class
*/
class Solver {
/**
* The context has a reference to a Strategy object. It should only works
* with strategies through the Strategy interface
*/
constructor(private data: number[], private strategy: Strategy) {
}
/**
* A setter is required in order to change the strategy in execution time
* @param strategy - Current strategy applied
*/
setStrategy(strategy: Strategy) {
this.strategy = strategy;
}
/**
* The context delegates some work to the Strategy object
*/
logic() {
this.strategy.execute(this.data);
}
}
/**
* Common interface to all Strategy objects. The context uses this
* interface to work with strategies
*/
interface Strategy {
execute(data: number[]): void;
}
/**
* Some concrete strategy
*/
class FirstAlgorithm implements Strategy {
execute(data: number[]) {
console.log(`First algorithm applied to ${data}`);
}
}
/**
* Some concrete strategy
*/
class SecondAlgorithm implements Strategy {
execute(data: number[]) {
console.log(`Second algorithm applied to ${data}`);
}
}
/**
* Some concrete strategy
*/
class ThirdAlgorithm implements Strategy {
execute(data: number[]) {
console.log(`Third algorithm applied to ${data}`);
}
}
// Client code
const mySolver = new Solver([1, 2, 3], new FirstAlgorithm());
mySolver.logic();
mySolver.setStrategy(new SecondAlgorithm());
mySolver.logic();
mySolver.setStrategy(new ThirdAlgorithm());
mySolver.logic();
En concreto, hemos implementado el patrón Strategy de la siguiente manera:
- En la clase contexto
Solver, hemos sido capaces de identificar diferentes algoritmos. - Hemos declarado la interfaz
Strategy, que contiene operaciones comunes a todas las estrategias como, por ejemplo, el métodoexecuteque permite iniciar la ejecución de una estrategia. - Hemos definido una clase independiente para cada algoritmo identificado. Todas implementan
la interfaz
Strategy. - Mediante composición, hemos añadido un atributo
strategya la clase contextoSolvercuyo tipo es el de la interfaz común a todas las estrategias, es decir,Strategy. Además, también hemos añadido un settersetStrategyaSolverque nos permite, en tiempo de ejecución, modificar la estrategia aplicada. La clase contexto solo interactúa con las estrategias a través de la interfazStrategy. Por último, la clase contexto podría definir una interfaz que permita a la estrategia acceder a sus datos. - El código cliente es responsable de asociar la clase contexto con alguna estrategia, así como de modificar la estrategia aplicada en tiempo de ejecución si así se requiere.
Entre las principales ventajas de este patrón, cabe mencionar las siguientes:
- Se pueden intercambiar los diferentes algoritmos o estrategias disponibles en tiempo de ejecución.
- Se pueden aislar los detalles de implementación de las estrategias del contexto que los utiliza.
- Se puede sustituir la herencia por la composición.
- Se respeta el principio Open-closed de SOLID.
Template Method
El patrón Template Method permite definir un esqueleto algorítmico en una superclase, de modo que aquellas subclases que extiendan el comportamiento de dicho esqueleto puedan sobreescribir algunos pasos del algoritmo sin modificar su estructura base.
Un ejemplo donde este patrón es aplicable podría ser la familia de los algoritmos evolutivos. Se trata de una familia de metaheurísticas compuesta, principalmente, por algoritmos genéticos, algoritmos de programación genética, estrategias evolutivas y evolución diferencial, entre otras variantes. Los anteriores algoritmos se basan en una estructura común con un conjunto de pasos u operaciones que varían dependiendo de la técnica en particular que se quiera implementar.
Por ejemplo, podríamos declarar un esqueleto algorítmico para algoritmos evolutivos tal y como se ilustra a continuación:
/**
* Own type to describe an individual, i.e., a solution
* of our problem.
*/
type Individual = {
decisionVariables: number[];
evaluate: () => void;
}
/**
* Abstract class of an Evolutionary Algorithm, which must be
* extended by subclasses that implement a particular approach.
*/
abstract class EvolutionaryAlgorithm {
protected population: Individual[];
constructor(
protected mutationRate: number,
protected crossoverRate: number,
protected maxNumberGenerations: number) {
this.population = [];
}
/**
* Template method that defines the skeleton of an Evolutionary Algorithm.
*/
public run() {
// Population initialisation
this.initPopulation();
// Hook
this.afterInitialisation();
// Initial population evaluation
this.evaluatePopulation();
// Hook
this.afterEvaluation();
// Run the generations of the algorithm
let currentNumberGenerations = 0;
while (currentNumberGenerations < this.maxNumberGenerations) {
// Generates the children
const childPopulation = this.generateAndEvaluateChildPopulation();
// Hook
this.afterChildrenGeneration();
// Selects the fittest individuals from among parents and children
this.population = this.selectFromParentsAndChildren(childPopulation);
// Hook
this.afterSurvivorSelection();
// New generation performed
currentNumberGenerations++;
}
}
/**
* Operations that already have implementations in the skeleton.
*/
protected evaluatePopulation() {
console.log('Template: evaluating population');
this.population.forEach((individual) => {
individual.evaluate();
});
}
protected generateAndEvaluateChildPopulation() {
console.log('Template: generating children');
const childPopulation: Individual[] = [];
this.population.forEach((individual) => {
const otherIndividual =
this.population[Math.floor(Math.random() * this.population.length)];
const [newIndividual, otherNewIndividual] =
this.crossover(individual, otherIndividual, this.crossoverRate);
this.mutation(newIndividual, this.mutationRate);
this.mutation(otherNewIndividual, this.mutationRate);
newIndividual.evaluate();
otherNewIndividual.evaluate();
childPopulation.push(newIndividual, otherNewIndividual);
});
return childPopulation;
}
/**
* Operations that must be implemented by subclasses
*/
protected abstract initPopulation(): void;
protected abstract crossover(firstIndividual: Individual,
secondIndividual: Individual, crossoverRate: number):
[Individual, Individual];
protected abstract mutation(individual: Individual,
mutationRate: number): void;
protected abstract selectFromParentsAndChildren(
childPopulation: Individual[]): Individual[];
/**
* Empty operations that could be implemented by subclasses (not
* mandatory)
*/
protected afterInitialisation() {}
protected afterEvaluation() {}
protected afterChildrenGeneration() {}
protected afterSurvivorSelection() {}
}
Tal y como puede observarse, existen cuatro tipos de métodos bien diferenciados:
- El método plantilla, en nuestro caso, el método
run, que establece los pasos u operaciones comunes del esqueleto algorítmico. - Métodos que llevan a cabo pasos u operaciones por defecto pero que,
aún así, podrían sobreescribirse en las subclases como, por ejemplo, el método
evaluatePopulation. - Métodos que representan pasos u operaciones abstractas que deben definirse
obligatoriamente en las subclases como, por ejemplo, el método
initPopulation. - Métodos hook que representan pasos u operaciones opcionales que se
suelen ejecutar antes y/o después de llevar a cabo pasos críticos del método
de plantilla y que pueden ser sobreescritos por las subclases como, por ejemplo,
el método
afterSurvivorSelection.
A continuación, se muestra la implementación de la clase GeneticAlgorithm que
extiende el comportamiento de EvolutionaryAlgorithm:
/**
* Concrete classes have to implement all abstract operations
* and could override some operations with a default behaviour
*/
class GeneticAlgorithm extends EvolutionaryAlgorithm {
constructor(protected mutationRate: number, protected crossoverRate: number,
protected maxNumberGenerations: number) {
super(mutationRate, crossoverRate, maxNumberGenerations);
}
/**
* Particular implementation of the population initialisation
*/
protected initPopulation() {
console.log(`GA: initialising population`);
const firstInd = {
decisionVariables: [1, 2],
evaluate: () => {},
};
const secondInd = {
decisionVariables: [3, 4],
evaluate: () => {},
};
this.population.push(firstInd, secondInd);
}
/**
* Particular implementation of the crossover operator
*/
protected crossover(firstIndividual: Individual,
secondIndividual: Individual, crossoverRate: number):
[Individual, Individual] {
console.log(`GA: applying crossover with crossover rate ${crossoverRate}`);
return [firstIndividual, secondIndividual];
}
/**
* Particular implementation of the mutation operator
*/
protected mutation(_: Individual, mutationRate: number) {
console.log(`GA: applying mutation with mutation rate ${mutationRate}`);
}
/**
* Particular implementation of the survivor operator
*/
protected selectFromParentsAndChildren(_: Individual[]) {
console.log(`GA: Selecting survivors for next generation`);
return this.population;
}
/**
* Particular implementation of a non-mandatory operation
*/
protected afterSurvivorSelection() {
console.log(`GA: I have just selected survivors for the next generation`);
}
}
Se puede observar como se han tenido que implementar todos los pasos u operaciones declarados
como abstractos en EvolutionaryAlgorithm. Además, también se ha implementado el paso
opcional afterSurvivorSelection. Por último, se ha mantenido el comportamiento por defecto
del resto de métodos.
Por último, un ejempo de código cliente podría ser el siguiente, en el que se muestra como
trabaja con la clase abstracta EvolutionaryAlgorithm a través de su método de plantilla
run:
/**
* Client code
*/
function clientCode(evolutionaryAlgorithm: EvolutionaryAlgorithm) {
evolutionaryAlgorithm.run();
}
clientCode(new GeneticAlgorithm(0.1, 1.0, 10));
Los pasos que hemos seguido para implementar el patrón Template method son los siguientes:
- Hemos analizado la estructura de un algoritmo, en este caso, de un algoritmo evolutivo, y hemos sido capaces de establecer una estructura o conjunto de pasos comunes que comparten todos los algoritmos pertenecientes a dicha familia.
- Hemos escrito la clase base abstracta
EvolutionaryAlgorithmque contiene el método de plantillarun. Además, hemos definido un conjunto de métodos abstractos que representan diferentes pasos u operaciones a llevar a cabo dentro del método de plantilla. - Hemos dotado a algunos pasos u operaciones de un comportamiento por defecto que las subclases no tienen que sobreescribir, aunque todos ellos podrían ser abstractos.
- Hemos añadido métodos hook que se ejecutan entre los pasos críticos del método de plantilla. Estos métodos se declaran con un cuerpo vacío y pueden ser sobreescritos opcionalmente en las subclases.
- Hemos definido la clase
GeneticAlgorithmcomo subclase deEvolutionaryAlgorithm, implementando todos los métodos obligatorios y algunos opcionales.
Entre las principales ventajas de este patrón de comportamiento cabe mencionar que se respeta el principio SOLID Open-closed, dado que podemos añadir nuevas variantes de algoritmos evolutivos a través de la herencia e implementación de los métodos obligatorios y opcionales.
Entre sus desventajas, hay que destacar que se podría violar el principio SOLID Liskov substitution por eliminar una implementación por defecto de un paso concreto a través de una de las subclases. Además, la complejidad en el mantenimiento de un método de plantilla suele aumentar considerablemente con el número de pasos que se permiten sobreescribir en las subclases.