Patrones Singleton y Builder, te cuento porque deberías aprenderlos

Publicado el 21-07-2023 |

Autor: Pablo Caamaño

Uno de los conocimientos que considero fundamentales y que son una herramienta vital para el día a día de un desarrollador de software, son los patrones de diseño. Sin ánimos de entrar en una definición aburrida que fácilmente encontrarás en Google, los definiría como formas estandarizadas de diseñar un programa y que sirven para resolver ciertos tipos necesidades. Con lo de estandarizadas me refiero a que son soluciones generales adoptadas como convenciones, es decir aceptadas, probadas y aplicadas por la comunidad.

El principal valor de conocer diferentes patrones de diseño, es ahorrar tiempo y esfuerzo en diseñar algo que resuelva una x necesidad. A veces sucede que, aplicando una metodología "anti-patrón", se termina perdiendo tiempo y esfuerzo para llegar a una solución que no es lo suficientemente robusta o que posiblemente se asemeje a algo que ya se diseño con anterioridad. En pocas palabras, conocer y aplica patrones ayuda a no re-inventar la rueda.

Por todo esto, y como el objetivo de este blog es compartir información, principalmente para todo para los que están entrando en el mundo del desarrollo de software, voy a intentar mostrarte de forma sencilla como utilizar estos patrones que seguramente vas a terminar aplicando a diario. Así como hacerte entender el valor que aportan y donde entran en juego sin que (posiblemente) lo sepas.

Antes de entrar en detalles, quiero dejar en claro que los ejemplos estarán aplicados en Java, ya que es el lenguaje que estoy más acostumbrado a utilizar. Sin embargo, cabe destacar que esto se puede aplicar en cualquier lenguaje orientado a objetos y seguramente en la mayoría de frameworks más populares.

Lo primero, diferenciar los tipos de patrones

Primero que nada, hay que entender que existe una organización para diferencias los patrones de diseño según el objetivo de cada grupo. Por un lado, se encuentran los patrones estructurales, los cuales se dedican a definir lineamientos para estructurar de forma eficaz un desarrollo, sus clases y objetos, priorizando evitar acoplamientos. Luego se encuentran los patrones de comportamiento, los cuales se aplican con el fin de manejar los procesos y comunicación entre los componentes de un proyecto de forma optima.

Los casos que vamos a ver a continuación pertenecen a los llamados patrones creacionales. Los cuales se encargan de definir la formar de crear objetos, instanciarlos y “setearlos”, así como facilitar la forma de hacerlo. Como esta es una operación recurrente en POO, es que estos tipos de patrones tienden a ser de los más utilizados.

Patrón Singleton

El objetivo del singleton es controlar las instancias instancias que se pueden realizar de una clase. Para lograr esto, se “cierra” el constructor de la clase con el modificador private y se dedica un método especifico para la instanciación de la clase. En este método propio de la clase se verificará si ya existe una instancia. Si no existe una instancia, este método podrá invocar el constructor privado, ya que pertenece a la clase. En caso de existir una instancia, se retornará esta misma, así evitando la generación de una segunda.

Si te preguntas cual es la ventaja y posible aplicación de esta metodología, un buen ejemplo sería una clase que tenga acceso a consumir un recurso o a abrir una conexión de la cual se desee tener el control. Piensa en una base de datos, no sería eficiente que se abran quince conexiones en un mismo proceso. En cambio, sería más eficiente establecer la conexión por única vez y reutilizar la misma en las quince partes del programa.

Para ver esto en código, voy a generar una clase Connector.java ficticia, en la cual se implementa singleton y un método login que genera un rellena un usuario. Este conector de mentira, será instanciado en UserServiceImpl.java para mostrar como funciona este patrón:

java
package ar.com.pablocaamano.api.util;

import ar.com.pablocaamano.api.model.User;
import lombok.Getter;

@Getter
public class Connector {
    private static Connector instance = null;
    private User user;

    private Connector() {}

    public static Connector getInstance() {
        if(instance == null) {
            instance = new Connector();
        }
        return instance;
    }

    public void login(String mail, String password) {
        this.user = new User(mail,password);
    }
}

Aquí se ve la implementación de singleton en la clase Connector, que lo único que hace es generar un objecto User con los datos que recibe el método login().

java
@Slf4j
@Service
public class UserServiceImpl implements UserService {
    @Override
    public User getUser() throws JsonProcessingException {
        //Connector c1 = new Connector(); // --> No compila
        c1.login("paulcaa@mail.com","123456");
        User u1= c1.getUser();
        log.info("Usuario1: {}", new ObjectMapper().writeValueAsString(u1));

        Connector c2 = Connector.getInstance(); // --> instanciacion de forma correcta
        User u2 = c2.getUser();
        log.info("Usuario2: {}", new ObjectMapper().writeValueAsString(u1));

        return u1;
    }
}

La clase UserServiceImpl intenta generar dos instancia del Connector, sin embargo la instancia c1 generará un error al querer usar el constructor. Mientras que la la instancia c2 es la forma correcta de utilizar.

El principal problema que tiene singleton, es que hay que tener cuidado con el estado del objeto. Es decir, que entre una operación y otra se va a utilizar la misma instancia. Lo cual puede llevar a que las variables o atributos del objeto ya estén inicializadas con datos previos, y que si no se limpian pueden dar lugar a errores.

Como en este fragmento, que se puede ver como aparentemente se obtienen dos instancias del Connector. Sin embargo, se trata de la misma instancia, esto se denota al imprimir los objetos u1 y u2. Confirmamos que es la misma instancia, ya que ambos tienen seteados los mismos datos debido a que no se “limpió” la información establecida en c1.

java
@Override
public User getUser() throws JsonProcessingException {
    
    Connector c1 = Connector.getInstance();
    c1.login("paulcaa@mail.com","123456");
    User u1= c1.getUser();
    log.info("Usuario1: {}", new ObjectMapper().writeValueAsString(u1));

    Connector c2 = Connector.getInstance();
    User u2 = c2.getUser();
    log.info("Usuario2: {}", new ObjectMapper().writeValueAsString(u1));
    
    return u1;
}
Aquí se observa al hacer debug que ambas instancias contienen los mismos datos, porque efectivamente son las mismas instancias Aquí se observa al hacer debug que ambas instancias contienen los mismos datos, porque efectivamente son las mismas instancias

Aquí se implementa el método clearData(), que se llama cuando se detecta que se va a retornar una instancia pre-existente. De esta forma se evitaría que lleguen datos de la anterior utilización.

java
@Getter
public class Connector {
    private static Connector instance = null;
    private User user;

    private Connector() {}

    public static Connector getInstance() {
        if(instance == null) {
            instance = new Connector();
        } else {
            clearData();
        }
        return instance;
    }

    public void login(String mail, String password) {
        this.user = new User(mail,password);
    }

    private void clearData() {
        user = null;
    }
}

Posiblemente usabas Singleton y no lo sabías

Si alguna vez utilizaste frameworks de Java como Spring o SpringBoot, habrás notado que no es necesario instanciar las clases. Siempre que hablemos de clases de Spring, es decir "beans", se va a realizar la inyección de dependencia. Es decir, el framework posee un control de instancias que no es más que un singleton. Al iniciar el programa de Spring, se genera la instancia de cada bean y las inyecta en los lugares donde se solicitó usarlas. Esto comúnmente se suele indicar con la anotación @Autowired, aunque en versiones más modernas no es necesario esta anotación.

Un detalle a tener en cuenta, es que Spring entiende que una clase la tiene que convertir en un bean porque se encuentra anotada como @Component o extensiones de la misma, como@Service, @Controller, @Repository, etc. Las clases que no cuenten con una anotación de Spring, serán tratadas como clases Java Standard (POJOs). Y para poder generar una instancia se va a tener que recurrir a instanciar de forma clásica o implementar un propio singleton.

Patrón Builder

En el caso del builder, el objetivo es generar objetos y facilitar la carga de datos en sus atributo. Si pensamos las formas tradicionales de generar una instancia y poblar los datos de la misma, la primera es mediante la sentencia new, para luego ir invocando los métodos setter de cada atributo. Otra forma, si se quiere reducir el código, es usando la sobrecarga del constructor para enviar los parámetros en la misma instanciación.

Sin embargo, ambas formas suelen ser incomodas. La primera requiere tipear mucho código, sumado a que si se requieren crear varios objetos de la misma forma se va a obtener mucho código duplicado. La segunda puede ser conflictiva si se cuenta con muchos atributos. Es decir, se vuelve complicado cuando se empiezan a requerir múltiples sobrecargas porque en algunos casos se requiere pasar por parámetros ciertos datos y en otros no. Para dar un ejemplo puntual, teniendo una clase producto como la siguiente:

java
public class ProductServiceImpl implements ProductService {
    @Override
    public List listProducts() {
        List products = new LinkedList<>();

        // Forma tradicional invocando todos los setters
        Product p1 = new Product();
        p1.setId(UUID.randomUUID());
        p1.setName("TV Samsung 55");
        p1.setDescription("Smart TV OLED 55 Pulgadas");
        p1.setPrice(600.00);
        p1.setExpiration(LocalDate.now().plusYears(3));
        products.add(p1);

        // Usando sobrecarga de constructor
        products.add(new Product(UUID.randomUUID(),"TV LG 55"));
        products.add(new Product(UUID.randomUUID(),"Xbox Series X",350.00));
        products.add(new Product(UUID.randomUUID(),"Notebook Asus i7",LocalDate.now().plusYears(5)));

        return products;
    }
}

Aquí se puede ver como la creación de objetos y carga de datos, tanto con setters como por constructor se vuelve tediosa, complicada y desprolija. Para poder hacer esto, también hay que ensuciar la clase del modelo, quedando de esta forma:

java
@Getter
@Setter
public class Product {
    private UUID id;
    private String name;
    private String description;
    private double price;
    private LocalDate expiration;

    public Product() {}

    public Product(UUID id, String name, String description, double price, LocalDate expiration) {
        this.id = id;
        this.name = name;
        this.description = description;
        this.price = price;
        this.expiration = expiration;
    }

    public Product(UUID id, String name) {
        this.id = id;
        this.name = name;
        this.description = name;
    }

    public Product(UUID id, String name, LocalDate expiration) {
        this.id = id;
        this.name = name;
        this.description = name;
        this.expiration = expiration;
    }

    public Product(UUID id, String name, double price) {
        this.id = id;
        this.name = name;
        this.description = name;
        this.price = price;
    }
}

Generar objetos es una tarea básica y esencial, por lo cual estos escenarios se van a repetir recurrentemente. Aquí es donde implementar un builder facilita las cosas, ya que va a permitir generar la instancia e ir invocando una a una la carga de datos, según se lo requiera. Dicho de otra forma, en la misma instanciación se podrán customizar los datos para inicializar el objeto y todo en una línea de código. Con el ejemplo de a continuación se podrá apreciar lo customizable y sencilla que se vuelve la generación de una instancia y la definición de los datos:

java
@Service
public class ProductServiceImpl implements ProductService {
    @Override
    public List listProducts() {
        return List.of(
                ProductBuilder.init().addName("TV Samsung 55").addDescription("Smart TV OLED 55 Pulgadas")
                        .addPrice(600.00).defineYearsExpiration(3).build(),
                ProductBuilder.init().addName("TV LG 55").build(),
                ProductBuilder.init().addName("Xbox Series X").addPrice(350.00).build(),
                ProductBuilder.init().addName("Notebook Asus i7").defineYearsExpiration(5).build()
        );
    }
}

En este ejemplo se puede ver el mismo ProductServiceImpl, con las mismas instanciaciones del caso anterior, pero con la diferencia de ser mucho más simple y prolijo gracias a la utilización de un builder.

Una clase builder, es una clase(que se suele hacer tipo singleton) que provee métodos para cargar cada uno de los datos de la clase. En cada uno de estos métodos se pueden agregar la validaciones y conversiones necesarias. Se proporciona un método de cierre del proceso, el cual retorna la instancia. De esta forma se pueden elegir los datos a cargar según se necesite, sin andar sobre escribiendo. Un ejemplo de builder para el modelo Product ya visto, sería el siguiente:

java
public class ProductBuilder {
    private static ProductBuilder builder = null;
    private static Product product;

    private ProductBuilder() {}

    public static ProductBuilder init() {
        product = new Product();
        product.setId(UUID.randomUUID());
        product.setPrice(0);
        return new ProductBuilder();
    }

    public ProductBuilder addId(UUID id) {
        product.setId(id);
        return this;
    }

    public ProductBuilder addName(String name) {
        product.setName(name);
        return this;
    }

    public ProductBuilder addDescription(String description) {
        product.setDescription(description);
        return this;
    }

    public ProductBuilder addPrice(double price) {
        if (price<=0) {
            throw new RuntimeException("EL precio no puede ser cero 0 negativo");
        }
        product.setPrice(price);
        return this;
    }

    public ProductBuilder defineYearsExpiration(int years) {
        product.setExpiration(LocalDate.now().plusYears(years));
        return this;
    }

    public Product build() {
        return product;
    }
}

El builder requiere un método de iniciación (init) y otro de cierre (build), en el medio se encuentran definidos de forma separada cada m{etodo para asignación de datos. De esta forma, la carga de datos se puede ejecutar en el orden que se requiera y saltearse los que no sean necesarios.

Como implementar Builders de forma sencilla

El patrón builder, tal como se mostró en los ejemplos anteriores, se puede implementar en cualquier lenguaje orientado a objetos. Simplemente hay que adaptar la sintaxis al lenguaje correspondiente, pero manteniendo la lógica.

Sin embargo, si llegara a resultar muy tedioso esto de generar el builder manualmente porque solo se necesita uno sencillo sin validaciones ni nada especifico, hay una solución. Si se utiliza Java, existe una librería muy conocida llamada Lombok, la cual mediante annotations permite generar métodos estandar como setters, getters, constructores y toString. Además de estos métodos clásicos, permite generar un builder sencillo y funcional, mediante la annotation @Builder. La cual ahorra mucho tiempo si se quiere algo sencillo y rápido, veámoslo en código:

java
@Getter
@Setter
@Builder
public class User {
    private String username;
    private String mail;
    private String password;

    public User(String m, String pwd) {
        username = Arrays.asList(m.split("@")).get(0);
        mail = m;
        password = pwd;
    }
}

Manos a la obra

En resumen, como pudimos ver a lo largo de los ejemplos, los patrones de diseño nos aportan reglas a seguir pero soluciones muy útiles. Si no conocías esto patrones, o si los conocías pero no los asociabas, una buena recomendación sería proyectos y ponerlo en práctica. Estos casos son muy básicos, pero tremendamente poderosos para muchos casos habituales.

Así que con esto concluye este mini post. Espero que esta información te sirva para mejorar los proyectos y habilidades. Intentaré traer más patrones que considere útil de compartir. Si hay algún concepto que no quedó bien explicado, algo a mejorar en el post u consultas, te invito a dejar un comentario o a escribirme por correo electrónico, así como desde Telegram.
Sin más que agregar, nos leemos la próxima, saludos!

Contribuir con el Blog

Dejame tú comentario:

Comentarios anteriores

No se registraron comentarios