2. Patrones de diseño#
Un patron de diseño es una plantilla de codificación que se ha demostrado eficaz para resolver un problema habitual. Es útil conocerlos, porque si en nuestro programa nos enfrentamos a un problema que ya ha sido resuelto mediante uno de estos patrones, podremos ponerlo en práctica.
En este apéndice, explicaremos algunos que
2.1. Singleton#
Es un patrón que garantiza que de una clase sólo pueda existir una única instancia.
Puede sernos muy útil,por ejemplo, si creamos una aplicación constituida por diversos paquetes y queremos que la configuración esté disponible en todos ellos sin tener que pasar constantemente los parámetros de configuración.
2.1.1. Caso simple#
En Java se implementa así:
public class Singleton {
private static Singleton instance;
// Posiblemente haya atributos de instancia (y sus correspondintes getters)
private Singleton(Map<String, String> args) {
// Utilizamos los argumentos para
// definir los valores de los atributos
}
public static Singleton initialize(Map <String, String> args) {
if(instance == null) {
instance = new Singleton(args);
return instance;
}
throw new IllegalStateException("La instancia ya fue inicializada");
}
public static Singleton getInstance() {
if(instance == null) throw new IllegalStateException("La instancia no está inicializada: use .initialize");
return instance;
}
}
Es decir, básicamente ocultamos el constructor para que la instancia sólo sea
accesible a través del método estático .getInstance. De este modo, la única
forma de acceder al constructor es a través de .initialize, pero sólo
podremos inicializar una vez.
Truco
Una variante de este patrón es que queramos crear no una instancia,
sino varias, dependiendo del valor de uno de los argumentos. En ese caso, el
atributo instance se puede convertir en un mapa cuyas claves son
esos valores.
2.1.2. Programas multihilo#
En caso de que nuestro programa sea multihilo entonces tendremos que complicar un poco el código:
public class Singleton {
private static volatile Singleton instance;
// Posiblemente haya atributos de instancia (y sus correspondintes getters)
private Singleton(Map<String, String> args) {
// Utilizamos los argumentos para
// definir los valores de los atributos
}
public static Singleton initialize(Map <String, String> args) {
if(instance == null) {
synchronized (Config.class) {
if(instance == null) {
instance = new Singleton(args);
return instance;
}
}
}
throw new IllegalStateException("La instancia ya fue inicializada");
}
public static Singleton getInstance() {
if(instance == null) throw new IllegalStateException("La instancia no está inicializada: use .initialize");
return instance;
}
}
2.2. Factory#
El patrón Factory es un patrón utilizado para creación de objetos que se utiliza para poder escoger en tiempo de ejecución qué objeto crear entre varios que cumplen con una misma interfaz (o pertenecen a subclases de una misma clase).
2.2.1. Implementación#
Para ilustrarlo imaginemos que en nuestra aplicación necesitamos traducir datos a distintos formatos. En una traducción básicamente hay dos operaciones:
Leer, que consiste en convertir la información que está almacenada en un determinado formato en datos internos del programa.
Escribir, que consiste transformar los datos internos al formato especificado.
Por tanto, queramos traducir a lo que queramos traducir, la clase reponsable de la traducción tendrá que implementar la siguiente interfaz:
public interface Traductor {
// Posiblemente sea una lista de un tipo determinado, no el genérico Object.
public List<Object> leer(InputStream st) throws IOException;
public void escribir(OutputStream st, List<Object> data) throws IOException;
}
en la que hemos supuesto que los datos los almacenamos en memoria en forma de
una lista. Hemos considerado una lista del Object genérico, aunque en un
ejemplo real concreto la lista será de alguna clase que hayamos definido. El
caso es que ahora definiremos diferentes clases las cuales implementarán esta
interfaz para distintos formatos (XML, CSV, JSON, etc.). Imaginemos que las clases se denominan TXml, TCvs,
TJson, etc.
Con todo esto, podemos poner en práctica este patrón así:
public class TraductorFactory {
public static Traductor crearTraductor(String formato)
throws IllegalArgumentException, UnsupportedOperationException {
return switch (formato.toLowerCase()) {
// Formatos implementados
case "txt" -> new TTxt();
case "csv" -> new TCsv();
case "json" -> new TJson();
case "yaml" -> new TYaml();
// Formatos conocidos que no se han implementado
case "xml" -> throw new UnsupportedOperationException(String.format("'%s': Formato no soportado.", formato));
// Cualquier otra cosa, no es un formato que reconozcamos
default -> throw new IllegalArgumentException(String.format("'%s': Formato desconocido.", formato));
};
}
}
En definitiva, la clase tiene un método estático que se limita a devolver el objeto de traducción apropiado según sea el formato. En el código principal no habrá más que hacer lo siguiente:
// El formato se proporcionará de alguna manera...
String formato = "json";
Traductor traductor = TraductorFactory.crearTraductor(formato);
// Suponiendo que en "data" estén los datos, esto generará una salida JSON.
traductor.escribir(System.out, data);
Obsérvese que el código de TraductorFactory depende de qué clases
traductoras hayamos creado realmente; y, si se crea una nueva (o se elimina una
ya creada por algún motivo), habrá que editar la clase para que se refleje este
cambio. El siguiente apartado complica un poco la implementación, pero permite
escribir una clase sin esta dependencia, de manera que podemos reaprovecharla,
sea cual sea el caso.
2.2.2. Automatización#
La idea es evitar que tengamos que escribir y reescribir la clase que implementa el patrón Factory constantemente; y además crear un código que nos sirva en cualquier aplicación:
public class Factory<I> {
/**
* Nombre del atributo que contendrá los nombres alternativos para la clase.
*/
private final static String alias = "alias";
/**
* Mapa cuyas claves son los nombres asociados a una clase
* que implementa la interfaz {@link I} y cuyos valores son las propias clases
* que implementan dicha interfaz.
*/
private final Map<String, Class<? extends I>> classes;
/**
* Constructor que escanea el paquete indicado para obtener las clases que implementan la interfaz dada.
* @param interfaceClass La interfaz que implementan todas las clases que se quieren encontrar.
* @param packageName El nombre del paquete donde se encuentran las clases que implementan la interfaz.
*/
public Factory(Class<I> interfaceClass, String packageName) {
checkPackage(packageName);
Reflections reflections = new Reflections(packageName);
classes = reflections.getSubTypesOf(interfaceClass)
.stream()
.flatMap(clazz -> getAliases(clazz).stream().map(alias -> new AbstractMap.SimpleEntry<>(alias, clazz)))
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
(existing, replacement) -> existing,
HashMap::new
));
}
/**
* Constructor que escanea el paquete donde se encuentra una interfaz para obtener las clases que la implementan.
* Se supone que las clases que implementan la interfaz están en el mismo paquete que la propia interfaz.
* @param interfaceClass La interfaz que implementan todas las clases que se quieren encontrar.
*/
public Factory(Class<I> interfaceClass) {
this(interfaceClass, interfaceClass.getPackageName());
}
/**
* Verifica que el paquete existe.
* @param packageName El nombre del paquete a verificar.
*/
private void checkPackage(String packageName) {
if(packageName != null) {
String path = packageName.replace('.', '/');
URL resource = Thread.currentThread().getContextClassLoader().getResource(path);
if(resource == null) {
throw new IllegalArgumentException("El paquete '" + packageName + "' no existe.");
}
}
else {
throw new IllegalArgumentException("El nombre del paquete no puede ser nulo.");
}
}
/**
* Lista los nombres por los que se puede referir una clase (el propio nombre
* de la clase más todos los que contenga el atributo alias)
* @param clazz La clase que se inspecciona
* @return El listado de clases.
*/
private static List<String> getAliases(Class<?> clazz) {
List<String> aliases = new ArrayList<>(List.of(clazz.getSimpleName()));
try {
Field field = clazz.getDeclaredField(alias);
if(!field.canAccess(null)) field.setAccessible(true);
Object value = field.get(null);
if(value instanceof String && !((String) value).isBlank()) {
aliases.add((String) value);
}
else if(value instanceof String[]) {
aliases.addAll(Arrays.asList((String[]) value));
}
}
catch(NoSuchFieldException | IllegalAccessException err) {}
return aliases.stream()
.filter(alias -> alias != null)
.map(alias -> alias.trim().toLowerCase())
.filter(alias -> !alias.isBlank())
.distinct()
.toList();
}
/**
* Obtiene la clase apropiada.
* @param value El nombre de la clase o uno de sus alias
* @return La clase correspondiente
* @throws IllegalArgumentException Cuando no existe ninguna clase con ese nombre o alias.
*/
public Class<? extends I> get(String value) throws IllegalArgumentException {
Class<? extends I> clazz = classes.get(value.toLowerCase());
if(clazz == null) {
String formatos = classes.keySet().stream().collect(Collectors.joining("\n - "));
throw new IllegalArgumentException(String.format("'%s': Formato desconocido. Disponibles:\n - %s", value, formatos));
}
return clazz;
}
/**
* Crea directamente una instancia de la clase correspondiente al nombre o alias dado.
*
* <p>La clase debe tener un constructor sin parámetros y, además, la construcción no debe
* tener dependencias que puedan cruzarse. En ese caso, debe usarse {@code .get(String)},
* que no llega a crear la instancia.
* @param value El nombre de la clase o uno de sus alias
* @return La instancia de la clase correspondiente
* @throws IllegalStateException Cuando no se puede crear la instancia. Habitualmente porque no tiene un constructor sin parámetros.
*/
public I getInstance(String value) throws IllegalStateException {
Class<? extends I> clazz = get(value);
try {
return clazz.getDeclaredConstructor().newInstance();
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(String.format("No se pudo crear una instancia de '%s'.", value), e);
}
}
/**
* Obtiene un mapa que asocia cada clase con su lista de alias a partir {@link #classes}.
* Es útil para generar ayuda.
* @return Otro mapa en que cada clase está relacionada con todos los alias que tiene.
*/
public Map<Class<? extends I>, String[]> getAliasesByClass() {
return classes.entrySet().stream()
.collect(Collectors.groupingBy(
Map.Entry::getValue,
Collectors.mapping(
Map.Entry::getKey,
Collectors.collectingAndThen(
Collectors.toList(),
list -> {
Collections.sort(list);
return list.toArray(String[]::new);
}
)
)
));
}
/**
* Obtiene un mapa que asocia cada alias con su clase correspondiente.
* @return Un mapa que relaciona cada alias con su clase.
*/
public Map<String, Class<? extends I>> getClasses() {
return classes;
}
}
La principal diferencia (además de ser mucho más complicado que el anterior) es
que esta clase sí se instancia con el constructor Factory(Class<I>, String)
para indicar cuál es la interfaz implementada y en qué paquete se encuentran las
clases que la implementan. En el ejemplo de nuestros traductores:
Factory<Traductor> factory = new Factory<>(Traductor.class, "edu.acceso.ejemplo.traductor");
Ahora bien, si se coloca la interfaz en el mismo paquete que las clases, se puede omitir el segundo argumento:
Factory<Traductor> factory = new Factory<>(Traductor.class);
La creación de este objeto Factory provoca que gracias a la reflexión se
analice el paquete y se localicen las clases que implementan la interfaz
(Traductor en este caso). El objetivo es relacionar estas clases con su
nombre y, en su caso, con los nombres alternativos contenidos en el atributo
alias. Por ejemplo:
public class TYaml implements Traductor {
// También podría ser String si sólo hay una alternativa
private static final String[] alias = {"yml", "yaml"};
// Implementación de la traducción para YAML...
}
Esta definición relacionaría las cadenas «tyaml», «yml» y «yaml» con la
clase TYaml, por lo que, cuando quisiéramos crear el objeto adecuado de
traducción, nos bastaría con hacer:
String formato = "yml"; // Por ejemplo.
// Suponemos que Traductor y TYaml están en el mismo paquete.
Factory<Traductor> factory = new Factory<>(Traductor.class);
Class<? extends Traductor> traductorClass = factory.get(formato);
Y ya tendríamos disponible la clase que implementa YAML para instanciarla cuando nos sea necesario. También existe la posibilidad de pedir directamente la instancia:
Traductor traductor = factory.getInstance(formato);
Prudencia
Este método sólo funcionará si el constructor de las clases no
tiene argumentos y, además, la construcción no tiene dependencias cruzadas
en el programa. Por ejemplo, si utilizamos una clase para gestionar la
configuración de usuario es probable que echemos mano de esta
clase Factory. Si el constructor del traductor hace
uso a su vez de la configuración entonces tendremos una dependencia circular:
para construir el objeto de configuración necesitamos un objeto traductor,
pero para construir un objeto traductor necesitamos la configuración.
Por lo general es más seguro obtener la clase del traductor e instanciar
nosotros luego. Justamente en el caso anterior, podríamos obtener la clase
del traductor para definir el atributo del objeto Config y en el getter
de ese atributo, hacer la instanciación.
La clase tiene, además, otros dos métodos adicionales que sirven básicamente para brindar información:
getClasses()que devuelve un mapa en que las claves son las cadenas y los valores las clases correspondientes.
getAliasesByClass()que devuelve un mapa en que cada clave es una clase y los valores un array con todos los nombres con los que está relacionada.