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:

Interface Traductor#
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:

Clase Factory#
public class Factory<I> {
    private static final Logger logger = LoggerFactory.getLogger(Factory.class);
    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;

    /**
     * Escanea un paquete para obtener clases.
     * @param <I> El tipo de la interfaz.
     * @param packageName El nombre del paquete.
     * @param interfaceClass La interfaz que implementan todas las clases que se quieren encontrar.
     */
    public Factory(String packageName, Class<I> interfaceClass) {
        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
            ));
    }

    /**
     * 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 el traductor.
     * @param formato El formato en el que se quiere traducir.
     * @return El objeto traductor.
     */
    @SuppressWarnings("null")
    public I getObject(String formato) {
        Class<? extends I> clazz = classes.get(formato.toLowerCase());

        if(clazz == null) {
            String formatos = classes.keySet().stream().collect(Collectors.joining("\n - "));
            logger.error("'{}': Formato desconocido. Disponibles:\n - {}", formato, formatos);
            System.exit(2);
        }
        try {
            return clazz.getDeclaredConstructor().newInstance();
        } catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException
                | NoSuchMethodException | SecurityException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Obtiene un mapa que asocia cada clases con su lista de alias a partir del mapa que relaciona cada alias con su clase corrspondiente
     * @param <T> El tipo de la interfaz
     * @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);
                        }
                    )
                )
            ));
    }

    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(String packageName, Class<I> interfaceClass) para indicar en qué paquete se encuentran las clases que implementan la interfaz y cuál es esta. En el ejemplo de nuestros traductores:

Factory<Traductor> trFactory = new Factory<>("edu.acceso.ejemplo.traductor", 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 aliases. Por ejemplo:

public class TYaml implements Traductor {
   // También podría ser String si sólo hay una alternativa
   private static final String[] = {"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.

Factory<Traductor> trFactory = new Factory<>("edu.acceso.ejemplo.traductor", Traductor.class);
Traductor traductor = trFactory.getObject(formato);

La clase tiene, además, 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.

Advertencia

El ćodigo tiene una limitación: el constructor de las clases no puede tener argumentos. En caso de que debiera tenerlos, no obstante, es fácil soslayar la limitación: bastaría con incluir en la interfaz un método inicializador. Por ejemplo:

public interface Foobar {

   public void initialize( /* Los argumentos que sea */ );
   // Resto de métodos que definen la interfaz.
}