- Patrones de diseño y programación a usar
- Conclusión
- Referencias
Los patrones de diseño son soluciones a problemas generales que los desarrolladores de software enfrentan durante el desarrollo de software. Por lo general, algunos de ellos se toman como mejores prácticas en situaciones específicas porque han demostrado que brindan una solución eficiente. Sin embargo, los lenguajes de programación evolucionan con el tiempo, agregan nuevas características y se crean nuevos “frameworks” que brindan nuevas formas de desarrollar, por lo tanto, los patrones de diseño también evolucionan.
Tomemos como ejemplo el Singleton, un patrón de diseño que permite tener solo una instancia de una clase en toda la aplicación. Es posible que algunas personas lo consideren un antipatrón y, en la práctica, al usar un framework como Jakarta EE, Quarkus, Spring o Micronaut, la creación manual de un Singleton ya no es común.
Es por eso que me gustaría revisar algunos de los patrones de diseño en el Java moderno, este artículo está basado en la charla “Design Patterns Revisited in Modern Java de Venkat Subramaniam“.
Patrones de diseño y programación a usar
Optional
Manejar el null
es un smell (algo que no parece correcto), es por eso que uno de los consejos en el libro Effictive Java dice, para las colecciones “No devuelva un nulo, en su lugar devuelva una colección vacía“. Deberíamos extender eso y aplicar lo mismo para cualquier valor. Por lo tanto, cuando un método puede o no devolver un valor, debemos usar el Optional.
Hace que nuestro código sea obvio, la persona que llama a ese método no debe adivinar si el método devolverá nulo
o no, evitando líneas innecesarias para verificaciones de null
.
public Optional<String> someMethod(){ if(Math.random() > 0.5 return Optional.of("Hello"); } return Optional.empty(); } public void myMethod(){ var maybeResult = someMethod(); //Some methods you can use. var result = maybeResult.orElse("Not found"); maybeResult.ifPresentOrElse(() -> {/*some logic*/}, () -> {/*some other logic*/}); System.out.println(result); }
Este artículo tienes más detalles acerca de como utilizar el Java Optional.
Evita estos anti-patrones con el Optional
Por favor!!! evite usar el método Optional.get()
, porque devolverá una excepción si el Optional está vacío, y lo que nos lleva a usar Optional.isPresent()
. Entonces, solo estamos reemplazando el chequeo de nulo por un cheque no vacío. En su lugar, usemos otros métodos como: orElse, orElseThrow, orElseGet, map.
No utilice Optional<T>
como tipo de retorno de un método que siempre devuelve un valor. No tiene sentido agregarle eso, solo debemos de retornar el valor. Lo mismo se aplica a las colecciones, no envuelva una colección en un Optional<T>
, simplemente devuelva una colección vacía.
No caiga en la tentación de usar Optional<T>
como parámetro de un método. Es posible que la llamada aún envíe un valor nulo
como parámetro, lo que requiere agregar comprobaciones de null
. También sea empático con otros desarrolladores, si tienen un String
y obliga a enviar una Optional<String>
, es posible que deban agregar líneas adicionales para envolver ese valor en una Optional<T>
.
Patrón para iterar con Stream
Más que un patrón de diseño, este es un patrón de programación, algunas buenas prácticas para aprovechar las nuevas características del lenguaje y hacer que nuestro código sea más legible. En el pasado usamos iteradores externos, donde controlamos cada paso en la iteración, usando los bucles while
y for
. Usamos declaraciones como break
o continue
, y agregamos lógica al bloque de código en las declaraciones de bucles.
//Old approach int count = 0; var myFruits = List.of("Apple", "Banana", "Watermelon", "Orange", "Pineapple", "Grape"); for(int i = 0; i < myList.size(); i++){ var fruit = myFruits.get(i); if(fruit.lenght() > 5){ count++; System.out.println(fruit.toUpperCase()); if(count > 2){ break; } } }
Sin embargo, Java proporciona un enfoque funcional para iterar colecciones, es el Stream API, que ayuda a la legibilidad de nuestro código y reduce la cantidad de líneas.
var myFruits = List.of("Apple", "Banana", "Watermelon", "Orange", "Pineapple", "Grape"); myFruits.stream() .filter(fruit -> fruit.lenght() > 5) .map(String::toUpperCase) .limit(2) .forEach(System.out::println);
Ambos fragmentos de código hacen lo mismo. Sin embargo, el que usa Stream API es más legible y requiere menos líneas de código para su mantenimiento. Estamos iterando una colección utilizando un “iterador interno” proporcionado por el lenguaje, indicando lo que se necesita y olvidándonos del cómo.
Evita estos Anti-Patrones con Stream
Stream API se basa en el estilo de programación funcional, y la programación funcional se basa en Expressions. Las expresiones se basan en la inmutabilidad de los datos.
//AVOID THIS var myFruits = List.of("Apple", "Banana", "Watermelon", "Orange", "Pineapple", "Grape"); var newList = new ArrayList<String>(); myFruits.stream() .filter(fruit -> fruit.lenght() > 5) .map(String::toUpperCase) .forEach(fruit -> newList.add(fruit)); //BAD IDEA //FOLLOW THIS var myFruits = List.of("Apple", "Banana", "Watermelon", "Orange", "Pineapple", "Grape"); var newList = myFruits.stream() .filter(fruit -> fruit.lenght() > 5) .map(String::toUpperCase) .toList(); //Best practice, use a collector to generate data from the stream
Además, Stream Pipeline debe ser reproducible y no depender de atributos o valores fuera de la canalización que puedan cambiar su estado en cualquier momento. Es por eso que una Expresión Lambda solo puede usar variables finales (o finales en la práctica) desde el exterior de la expresión.
La Programación Funcional enfatiza la inmutabilidad y la pureza, no porque esté de moda, sino porque es esencial para su supervivencia y eficiencia.
Patrón Estrategia Liviano
Comenzemos por explicar que es el Patrón Estrategia:
El patrón de estrategia permite modificar una pequeña parte del algoritmo en tiempo de ejecución mientras que el resto del algoritmo sigue siendo el mismo.
En el pasado, hemos utilizado el patrón estrategia de la siguiente manera:
public interface PrinterStrategy { void print(List<Integer> numbers); } public class TotalValuePrinter implements PrinterStrategy{ public void print(List<Integer> numbers){ var total = 0; for(Integer number: numbers){ total += number; } System.out.println(total); } } public class TotalEvenPrinter implements PrinterStrategy{ public void print(List<Integer> numbers){ var total = 0; for(Integer number: numbers){ if(number % 2 == 0){ total += number; } } System.out.println(total); } } public class TotalOddPrinter implements PrinterStrategy{ public void print(List<Integer> numbers){ var total = 0; for(Integer number: numbers){ if(number % 2 != 0){ total += number; } } System.out.println(total); } } public class MyService { private List<Integer> numberList; public PrinterContext(List<Integer> numberList){ this.numberList = numberList; } public void process(PrinterStrategy strategy){ //Some logic here strategy.print(numberList); //More logic here } } public class MyApp { public static void main(){ var numberList = List.of(1,2,3,4,5,6,7,8,9); var myService = new MyService(numberList); myService.process(new TotalValuePrinter()); //Runs the process algorithm with the total value myService.process(new TotalEvenPrinter()); //Runs the process algorithm with even total value myService.process(new TotalOddPrinter()); //Runs the process algorithm with odd total value } }
Este es un ejemplo muy simple del patrón de estrategia, tenemos una interfaz y algunas implementaciones. Entonces, hay un método que recibe una implementación como parámetro y así es como se puede “modificar” el algoritmo en tiempo de ejecución dependiendo de la implementación. Como podemos ver, requiere escribir muchas clases.
Usando expresiones lambda, podemos reducir el número de clases a solo unas pocas clases. En la mayoría de los casos, una estrategia es una implementación diferente para un método; en esos casos, Lambda Expression (la implementación de una interfaz funcional) es un buen caso de uso.
public class MyService { private List<Integer> numberList; public PrinterContext(List<Integer> numberList){ this.numberList = numberList; } public void process(Predicate<Integer> strategy){ numberList.stream() .filter(predicate) .forEach(System.out::println); } } public class MyApp { public static void main(){ var numberList = List.of(1,2,3,4,5,6,7,8,9); var myService = new MyService(numberList); myService.process(x => true); //Runs the algorithm for all the numbers myService.process(number => number % 2 == 0);//Runs the algorithm for even the numbers myService.process(number => number % 2 != 0);//Runs the algorithm for odd the numbers } }
No decimos que Lambda Expression está reemplazando el patrón original, hay escenarios más complejos (con más de 1 método) donde una expresión lambda no es adecuada. Pero proporciona un enfoque ligero para muchos escenarios.
Factory Method usando métodos default
Los patrones pueden evolucionar con el tiempo, aprovechando las nuevas características proporcionadas por el lenguaje. En esta oportunidad, usaríamos los métodos default
en las interfaces (el mismo enfoque puede funcionar en clases abstractas) para crear un nuevo patrón de fábrica, el “Patrón de método de fábrica“.
interface Person { Pet getPet(); default void play(){ System.out.println("Playing with "+ getPet()); } } interface Pet {} class Dog implements Pet{} class Cat implements Pet{}
Comencemos con la interfaz Person
que puede jugar con una máscota Pet
, y tenemos diferentes tipos de mascotas, como Dog
y Cat
. Entonces, el patrón comienza con tener un método predeterminado en la interfaz que define cómo jugar con una mascota, pero la mascota dependerá de la implementación de la persona.
class DogPerson implements Person { private Dog dog = new Dog(); Pet getPet(){ return dog; } } class CatLover implements Person { private Cat cat = new Cat(); Pet getPet(){ return cat; } } public class Sample{ public static void call(Person person){ person.play(); } public static void main(String [] args){ call(new DogPerson()); //Prints -> Playing with Dog@1234 call(new CatLover()); //Prints -> Playing with Cat@5678 } }
Ahora, la implementación de cada persona Person
utiliza la mascota Pet
que prefiera. Sin requerir ninguna clase externa para ello. Además, este mismo patrón se puede aplicar usando “clases abstractas”, sin interfaces y método default.
Abstract Factory vs Factory Method
Este no es un reemplazo para otros métodos de Factory, es solo una nueva opción que es más adecuada para usar en Java moderno para algunos escenarios.
Factory Method: Una clase o una interfaz se basa en una clase derivada para proporcionar la implementación, mientras que la base proporciona el comportamiento común. Utiliza la herencia como herramienta de diseño.
Abstract Factory:Utiliza la delegación como una herramienta de diseño, estás pasando parámetros a otro objeto.
Entonces, ambos patrones se tratan de factorizar instancias para nosotros, pero Abstract Factory se basa en una clase encargada de hacerlo. Factory Method se basa en un método que devuelve la instancia, lo que nos ayuda a reducir el código, no necesitamos crear una clase independiente.
Laziness usando Lambda Expressions
“Laziness” es una herramienta muy poderosa para delegar la responsabilidad del código que no se ejecutará de inmediato. Por lo tanto, permite hacer una lógica más eficiente.
Evaluación perezosa (Lazy evaluation) es a la programación funcional como el polimorfismo es a la programación orientada a objetos.
En Ciencias de la Computación podemos resolver casi cualquier problema introduciendo un nivel más de direccionamiento indirecto.
David Wheeler
Hablemos de indirección (direccionanmiento indirecto) …
- En el código de Imperativo, como C, los punteros dan el poder de direccionamiento indirecto.
- En el código orientado a objetos, como Java, las funciones de anulación otorgan el poder de la indirección. (porque está en tiempo de ejecución, no en tiempo de compilación, que se decide qué método ejecutar según la implementación de la clase)
- En código funcional, como Scala, las lambdas otorgan el poder de direccionamiento indirecto.(porque puede decidir cuándo ejecutar su función, ahora, o simplemente pasarla como un parámetro a otro método para que se ejecute más tarde, de modo que la lógica se evalúe con pereza).
Habiendo dicho eso, usemos Lambda Expressions para proporcionar una evaluación Lazy en nuestro código y hacer que nuestro código sea más eficiente.
public static int compute(int number){ System.out.println("computing ...."); //Imagine it takes some time to compute return number * 100; } public static void main(String [] args){ int value = 4; int temp = compute(value); //this is Eager, compute is evaluated right now if(value > 4 && temp > 100){ System.out.println("Path 1 with temp "+ temp); } else { System.out.println("Path 2"); } }
Running that logic, regardless of the value of the value
variable, it will always execute the compute
method, doing some heavy and unnecessary work in some scenarios. For example, if the value is 4
, temp
will never be used, and the resource to get its value were wasted.
Al ejecutar esa lógica, independientemente del valor de la variable de value
, siempre ejecutará el método compute
, lo que hará un trabajo pesado e innecesario en algunos escenarios. Por ejemplo, si el valor es 4 , nunca se usará temp
y se desperdiciará recursos para obtener su valor.
Podemos mejorar esa lógica con algunos cambios.
public Lazy<T> { private T instance; private Supplier<T> supplier; public Lazy(Supplier<T> supplier){ this.supplier = supplier; } public void get(){ if(instance == null){ instance = supplier.get(); } return instance; } } public static int compute(int number){ System.out.println("computing ...."); //Imagine it takes some time to compute return number * 100; } public static void main(String [] args){ int value = 4; Lazy<Integer> temp = new Lazy(() -> compute(value)); //This is lazy, not evaluated yet if(value > 4 && temp.get() > 100){ System.out.println("Path 1 with temp "+ temp); } else { System.out.println("Path 2"); } }
Ahora, el valor de temp
no se calcula inmediatamente. Solo está creando una expresión lambda que solo se ejecuta en caso de que se llame al método temp.get()
. Por lo tanto, no estamos llamando al método compute
siempre, y estamos ahorrando algunas tareas pesadas para ejecutar.
No es algo que necesitemos todos los días, pero es un enfoque útil para usar en algunos casos específicos. Podemos pasar un Supplier
u otra Expresión Lambda como parámetro de los métodos, en lugar del valor directo.
Patrón Decorador usand Expressiones Lambda
El patrón Decorator puede ser un patrón “aterrador” para muchos desarrolladores. Es posible que hayamos leído al respecto, pero generalmente no se usa. Este es un ejemplo del patrón decorador, se ve “feo”.
DataInputStream dis = new DataInputStream( new BufferedStream( new FileInputStream(....)));
El objetivo del patrón Decorator es agregar responsabilidades adicionales a un objeto. Eso es posible a través de alguna interfaz, herencia y composición de clases. En algunos casos, la implementación de este patrón puede ser difícil de entender a primera vista. Veamos un ejemplo:
interface Camera { Color snap(); } class DefaultCameraImpl implements Camera { @Override public Color snap(Color input){ return input; } } abstract class ColoredCamera implements Camera { private Camera camera; public ColoredCamera(Camera camera){ this.camera = camera; } @Override public Color snap(Color input){ camera.snap(); } } class BrighterCamera implements ColoredCamera { public BrighterCamera(Camera camera){ super(camera); } @Override public Color snap(Color input){ return super.snap(input).brigther(); } } class DarkerCamera implements ColoredCamera { public DarkerCamera(Camera camera){ super(camera); } @Override public Color snap(Color input){ return super.snap(input).darker(); } }
Y luego, podemos usar ese código de la siguiente manera.
public class MyApp { public static void main(String [] args){ Camera camera = new DarkerCamera(new BrighterCamera(new DefaultCameraImpl())); Color newColor = camera.snap(new Color(125,125,125)); System.out.println(newColor); } }
Una vez construido, el Patrón Decorador es muy útil y poderoso, se trata de componer una cadena de implementaciones agregando dinámicamente nuevas responsabilidades. Pero, seamos serios, requiere muchas clases para que funcione.
¿Y si pudiéramos deshacernos de esas clases? y crear un enfoque más simple con el mismo resultado. Eso se puede hacer usando la interfaz funcional Function
y Lambda Expressions.
public Camera { private Function<Color, Color> filters; public Camera(Function<Color, Color> ... filters){ this.filters = Stream.of(filters) .reduce(Function.identity(), Function::andThen); } public Color snap(Color input){ this.filters.apply(input); } } public class MyApp { public static void main(String [] args){ Camera camera = new Camera(Color::brighter, Color::darker); Color newColor = camera.snap(new Color(125,125,125)); System.out.println(newColor); } }
Enfoque más simple, mismo resultado, usando menos clases y código. La clave es usar el método andThen
para que la interfaz de Function
componga las funciones, lo que permite agregarlas de manera más dinámica.
Imaginemos que tiene una canalización de datos que fluyen en su aplicación, necesita modificar, validar, cifrar, descifrar y otras operaciones. Este es un caso de uso para el patrón decorador, puede hacerlo dinámicamente sin restringir los pasos o el orden de los mismos y, como beneficio adicional, no parece tan aterrador como el enfoque que usa clases.
Código Fluido
El código fluido está de moda, porque puede crear un objeto y ejecutarlo muy fácilmente. Por lo general, sigue un patrón Builder (más específicamente, el patrón de cascada del método). Veamos un ejemplo.
class Mailer { public Mailer from(String from){ System.out.println("From: "+from); return this; } public Mailer to(String to){ System.out.println("To: "+to); return this; } public Mailer subject(String subject){ System.out.println("Subject: "+subjet); return this; } public Mailer content(String content){ System.out.println("content: "+content); return this; } public void send(){ System.out.println("Sending ..."); } } public class Sample { public static void main(String [] args){ new Mailer() .from("builder@adamgamboa.dev") .to("adamgamboa@domain.dev") .subject("You can do it better") .content("This code is good, but it can be better") .send(); } }
Ese es un buen ejemplo de un código fluido. Pero tiene una desventaja, es esa nueva instancia de Mailer (new Mailer
) que hicimos, podemos guardarla en una variable y usarla más tarde, ¿debería estar bien? Hagamos algunos cambios menores para evitar esa situación.
class Mailer { private Mailer(){} //Private to avoid people creating instances public Mailer from(String from){ System.out.println("From: "+from); return this; } public Mailer to(String to){ System.out.println("To: "+to); return this; } public Mailer subject(String subject){ System.out.println("Subject: "+subjet); return this; } public Mailer content(String content){ System.out.println("content: "+content); return this; } public static void send(Consumer<Mailer> block){ var mailer = new Mailer(); block.accept(mailer); System.out.println("Sending ..."); } } public class Sample { public static void main(String [] args){ Mailer.send(mailer -> mailer .from("builder@adamgamboa.dev") .to("adamgamboa@domain.dev") .subject("This is better) .content("This code is good, you did it better")); } }
Por lo tanto, el desarrollador todavía usa nuestro código con un enfoque fluido, pero ya no es necesario que se encargue de crear una instancia de Mailer
.
Patrón de Execución al readedor del método
Este patrón es muy útil cuando tenemos un recurso que debe cerrarse al final de algunas operaciones.
class Resource implements AutoClosable { public void operation1(){ System.out.println("operation 1"); } public void operation2(){ System.out.println("operation 2"); } public void close(){ System.out.println("closing.."); } } public class Sample { public static void main(String[] args){ try(Resource resource = new Resource()){ resource.operation1(); resource.operation2(); } } }
Usando el feature ARM (Automatic Resource Management) en Java, con el Try-With-Resource, podríamos manejar eso. Pero, debido a que Resource
implementa el interface AutoClosable
, el try
deberá llamar al método close()
luego de todas las operaciones, incluso si sucede una excepción.
Pero, ese enfoque tiene un inconveniente, los desarrolladores pueden ser propensos a olvidarse de ejecutar las operaciones de recursos en la prueba con recursos. Veamos otro enfoque que reduce ese riesgo.
class Resource { private Resource(){} //Private to avoid instances public void operation1(){ System.out.println("operation 1"); } public void operation2(){ System.out.println("operation 2"); } private void close(){ System.out.println("closing.."); } public static use(Consumer<Resource> block){ Resource resource = new Resource(); //Uses the private constructor try{ block.accept(resource); } finally { resource.close(); } } } public class Sample { public static void main(String[] args){ Resource.use(resource -> { resource.operation1(); resource.operation2(); }); } }
Algunos de los pequeños cambios. El constructor del recurso es privado, y tenemos un método estático que usa recibir un bloque de código. Ese método siempre manejará la llamada al método de cierre. Este enfoque es muy útil para recursos como transacciones o para implementar algún tipo de programación orientada a aspectos (AOP) básica.
Creando una jerarquía cerrada con interfaces selladas
Las clases/interfaces selladas son una de las nuevas funciones proporcionadas por Java 17. Imagínese, estamos creando complementos o bibliotecas para otras personas, es posible que desee proporcionar interfaces para que las usen otras personas, pero no quiere que otras personas las amplíen. interfaces por su lado, eso es solo para su uso interno.
Esa es la razón para usar clases selladas, permitir la accesibilidad pública pero la extensibilidad privada.
public sealed interface TrafficLight {} final class ReadLight implements TrafficLight {} final class GreenLight implements TrafficLight {}
public Sample { public static void main(String [] args){ TrafficLight light = new ReadLight(); //This is valid } }
Pero debido a que TrafficLight
está sellado y ReadLight
y GreenLight
son final
, nuestra jerarquía está cerrada para la extensión. Otros desarrolladores no podrán extenderse desde ReadLight o GreenLight, ni crear una nueva implementación de TrafficLight
.
Anteriormiente escribí un articulo sobre ello, puede ver más detalles sobre Classes Selladas en Java.
Conclusión
Es bueno revisar algunos de los patrones que podemos aplicar en nuestro código, agregando nuevas funciones al lenguaje. Como podemos ver, aprovechando esas características, como la expresión lambda, podemos simplificar algunos de los patrones existentes que se usan en las aplicaciones Java.
Cuando usamos patrones, necesitamos usarlos solo para resolver un problema, no necesitamos usarlos en cualquier lugar solo porque son elegantes. Eso puede llevarnos a antipatrones o hacer que nuestra lógica sea excesiva para escenarios simples. Por lo tanto, NO FUERCE los PATRONES.
Referencias
- https://www.youtube.com/watch?v=yTuwi–LFsM
- https://refactoring.guru/design-patterns/java
- https://www.tutorialspoint.com/design_pattern/index.htm
- https://accu.org/journals/overload/11/57/radford_337/
- https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Optional.html
- https://blog.adamgamboa.dev/how-to-use-java-optional/
- https://blog.adamgamboa.dev/understanding-sealed-classes-in-java-17/