Reflexiones sobre la POO y el estado de los objetos.

El código del programa se escribe de arriba a abajo. Las instrucciones se leen y ejecutan en el mismo orden. Esto es lógico y todos han estado acostumbrados desde hace mucho tiempo. En algunos casos, puede cambiar el orden de las operaciones. Pero a veces la secuencia de llamadas a funciones es importante, aunque sintácticamente esto no es obvio. Resulta que el código parece funcionar, incluso después de reorganizar las llamadas, y el resultado es inesperado.


Una vez, un código similar me llamó la atención.


El problema


Una vez, hurgando en el código de otra persona en un proyecto conjunto, descubrí una función como:


public Product fill(Product product, Images images, Prices prices, Availabilities availabilities){ priceFiller.fill(product, prices); //do not move this line below availabilityFiller call, availabilities require prices availabilityFiller.fill(product, availabilities); imageFiller.fill(product, images); return product; } 

Por supuesto, no llama la atención el estilo más "elegante": las clases almacenan datos (POJO), las funciones cambian los objetos entrantes ...


En general, parece no ser nada. Tenemos un objeto en el que no hay suficientes datos, y hay datos en sí mismos que provienen de otras fuentes (servicios), que ahora colocaremos en este objeto para completarlo.


Pero hay algunos matices.


  1. No me gusta el hecho de que ni una sola función está escrita en el estilo de FP y modifica el objeto pasado como argumento.
    • Pero digamos que esto se hizo para reducir el tiempo de procesamiento y la cantidad de objetos creados.
  2. Solo un comentario en el código dice que la secuencia de llamadas es importante, y debe tener cuidado al incrustar el nuevo Filler
    • Pero la cantidad de personas que trabajan en el proyecto es más de 1 y no todos conocen este truco. Especialmente gente nueva en el equipo (no necesariamente en la empresa).

El último punto me protegió especialmente. Después de todo, la API está construida de tal manera que no sabemos qué ha cambiado en el objeto después de llamar a esta función. Por ejemplo, antes y después tenemos un método Product::getImages , que, antes de llamar a la función de fill , producirá una lista vacía y luego una lista con imágenes de nuestro producto.


Con Filler cosas son aún peores. AvailabilityFiller no deja en claro que espera que la información sobre el precio de los bienes ya esté incrustada en el objeto transferido.


Entonces, pensé en cómo podría proteger a mis colegas del uso erróneo de las funciones.


Soluciones propuestas


Primero, decidí discutir este caso con mis colegas. Desafortunadamente, todas las soluciones que propusieron no me parecieron el enfoque correcto.


Excepción de tiempo de ejecución


Una de las opciones propuestas era: y escribía en AvailabilityFiller al comienzo de la función Objects.requireNonNull(product.getPrices) y luego cualquier programador ya recibirá un error durante las pruebas locales.


  • pero el precio realmente puede no estar allí, si el servicio no estaba disponible o algún otro error, entonces el producto debería recibir el estado de "agotado". Tendremos que atribuir todo tipo de banderas o cualquier cosa para distinguir "sin datos" de "ni siquiera solicitado".
    • Si lanza una excepción en getPrices , crearemos los mismos problemas que Java moderno con listas
      • Suponga que se pasa una Lista a una función que ofrece el método get en su API ... Sé que no necesita cambiar los objetos transferidos, sino crear otros nuevos. Pero la conclusión es que la API nos ofrece dicho método, pero en tiempo de ejecución puede ocurrir un error si se trata de una lista inmutable, como la obtenida de Collectors.toList ()
  • Si otra persona utiliza AvailabilityFiller , el programador que escribió la llamada no comprenderá de inmediato cuál es el problema. Solo después del lanzamiento y depuración. Entonces todavía tiene que entender el código para descubrir dónde obtener los datos.

Prueba


"Y escribes una prueba que se romperá si cambias el orden de las llamadas". es decir Si todos los Filler devuelven un producto "nuevo", algo como esto resultará:


 given(priceFillerMock.fill(eq(productMock), any())).willReturn(productWithPricesMock); given(availabilityFillerMock.fill(eq(productMockWithPrices), any())).willReturn(productMockWithAvailabilities); given(imageFillerMock.fill(eq(productMockWithAvailabilities), any())).willReturn(productMockWithImages); var result = productFiller.fill(productMock, p1, p2, p3); assertThat("unexpected return value", result, is(productMockWithImages)); 

  • No me gustan las pruebas que son tan "White-Box"
  • Rompe con cada nuevo Filler
  • Se rompe al cambiar la secuencia de llamadas independientes
  • Nuevamente, no resuelve el problema de reutilizar AvailabilityFiller

Intentos propios para resolver el problema


Idea


Creo que ya has adivinado que me gustaría resolver el problema a nivel de compilación. Bueno, ¿por qué necesito, uno pregunta, un lenguaje compilado con un tipeo fuerte si no puedo evitar el error?


¿Y me preguntaba si un objeto sin datos adicionales y un objeto "extendido" pertenecen a la misma clase?


¿No sería correcto describir los diversos estados posibles de un objeto como clases o interfaces separadas?


Entonces mi idea fue esta:


 public Product fill(<? extends BaseProduct> product, Images images, Prices prices, Availabilities availabilities){ var p1 = priceFiller.fill(product, prices); var p2 = availabilityFiller.fill(p1, availabilities); return imageFiller.fill(p2, images); } PriceFiller public ProductWithPrices fill(<? extends BaseProduct> product, Prices prices) AvailabilityFiller public ProductWithAvailabilities fill(<? extends ProductWithPrices> product, Prices prices)  public <BaseProduct & PriceAware & AvailabilityAware> fill(<? extends BaseProduct & PriceAware> product, Prices prices) 

Es decir El producto definido originalmente es una instancia de una clase distinta de la devuelta, que ya muestra cambios en los datos.


Filler , en sus API, especifican exactamente qué datos necesitan y qué devuelven.


De esta manera puede evitar la secuencia de llamadas incorrecta.


Implementación


¿Cómo traducir esto en realidad en Java? (Recuerde que no es posible heredar de múltiples clases en Java).


La complejidad se agrega mediante operaciones independientes. Por ejemplo, se pueden agregar imágenes antes y después de agregar precios, así como al final de la función.
Entonces tal vez


 class ProductWithImages extends BaseProduct implements ImageAware{} class ProductWithImagesAndPrices extends BaseProduct implements ImageAware, PriceAware{} class Product extends BaseProduct implements ImageAware, PriceAware, AvailabilityAware{} 

¿Cómo describirlo todo?


¿Crear adaptadores?


 public ProductWithImagesAndPrices(<? extends BaseProduct & PriceAware> base){ this.base = base; this.images = Collections.emptyList(); } public long getId(){ return this.base.getId(); } public Price getPrice(){ return this.base.getPrice(); } public List<Image> getImages(){ return this.images; } 

Copiar datos / enlaces?


 public ProductWithImagesAndPrices(<? extends BaseProduct & PriceAware> base){ this.id = base.getId(); this.prices = base.getPrices(); this.images = Collections.emptyList(); } public List<Image> getImages(){ return this.images; } 

Como ya se notó, todo se reduce a una gran cantidad de código. Y esto a pesar del hecho de que en el ejemplo dejé solo 3 tipos de datos de entrada. En el mundo real, puede haber muchos más.


Resulta que los costos de escribir y mantener dicho código no se justifican a sí mismos, aunque la idea de dividir el estado en clases separadas me pareció muy atractiva.


Retiro


Si observa otros idiomas, entonces en algún lugar este problema es más fácil de resolver, pero en otro no.
Por ejemplo, en Go puede escribir una referencia a una clase extensible sin métodos de "copia" o "sobrecarga". Pero no se trata de ir

Otra digresión


Mientras escribía este artículo, surgió otra posible solución con Proxy , que requería escribir solo métodos nuevos, pero que requería una jerarquía de interfaces. En general, aterrador, enojado y no adecuado. Si alguien está repentinamente interesado:

Antes de acostarse y comer, no se recomienda mirar esto
 public class Application { public static void main(String[] args) { var baseProduct = new BaseProductProxy().create(new BaseProductImpl(100L)); var productWithPrices = fillPrices(baseProduct, BigDecimal.TEN); var productWithAvailabilities = fillAvailabilities(productWithPrices, "available"); var productWithImages = fillImages(productWithAvailabilities, List.of("url1, url2")); var product = productWithImages; System.out.println(product.getId()); System.out.println(product.getPrice()); System.out.println(product.getAvailability()); System.out.println(product.getImages()); } static <T extends BaseProduct> ImageAware fillImages(T base, List<String> images) { return (ImageAware) Proxy.newProxyInstance(base.getClass().getClassLoader(), new Class[]{ImageAware.class, BaseProduct.class}, new MyInvocationHandler<>(base, new ImageAware() { @Override public List<String> getImages() { return images; } })); } static <T extends BaseProduct> PriceAware fillPrices(T base, BigDecimal price) { return (PriceAware) Proxy.newProxyInstance(base.getClass().getClassLoader(), new Class[]{PriceAware.class}, new MyInvocationHandler<>(base, new PriceAware() { @Override public BigDecimal getPrice() { return price; } })); } static AvailabilityAware fillAvailabilities(PriceAware base, String availability) { return (AvailabilityAware) Proxy.newProxyInstance(base.getClass().getClassLoader(), new Class[]{AvailabilityAware.class}, new MyInvocationHandler<>(base, new AvailabilityAware() { @Override public String getAvailability() { return base.getPrice().intValue() > 0 ? availability : "sold out"; } })); } static class BaseProductImpl implements BaseProduct { private final long id; BaseProductImpl(long id) { this.id = id; } @Override public long getId() { return id; } } static class BaseProductProxy { BaseProduct create(BaseProduct base) { return (BaseProduct) Proxy.newProxyInstance(this.getClass().getClassLoader(), new Class[]{BaseProduct.class}, new MyInvocationHandler<>(base, base)); } } public interface BaseProduct { default long getId() { return -1L; } } public interface PriceAware extends BaseProduct { default BigDecimal getPrice() { return BigDecimal.ZERO; } } public interface AvailabilityAware extends PriceAware { default String getAvailability() { return "sold out"; } } public interface ImageAware extends AvailabilityAware { default List<String> getImages() { return Collections.emptyList(); } } static class MyInvocationHandler<T extends BaseProduct, U extends BaseProduct> implements InvocationHandler { private final U additional; private final T base; MyInvocationHandler(T base, U additional) { this.additional = additional; this.base = base; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (Arrays.stream(additional.getClass().getInterfaces()).anyMatch(i -> i == method.getDeclaringClass())) { return method.invoke(additional, args); } var baseMethod = Arrays.stream(base.getClass().getMethods()).filter(m -> m.getName().equals(method.getName())).findFirst(); if (baseMethod.isPresent()) { return baseMethod.get().invoke(base, args); } throw new NoSuchMethodException(method.getName()); } } } 

Conclusión


Que resulta Por un lado, hay un enfoque interesante que aplica clases separadas a un "objeto" en diferentes estados y garantiza la prevención de errores causados ​​por una secuencia incorrecta de llamadas a métodos que modifican este objeto.


Por otro lado, este enfoque te hace escribir tanto código que inmediatamente quieres rechazarlo. Un montón de interfaces y clases solo dificulta la comprensión del proyecto.


En mi otro proyecto, todavía intenté usar este enfoque. Y al principio en el nivel de la interfaz, todo estaba bien. Escribí las funciones:


 <T extends Foo> List<T> firstStep(List<T> ts){} <T extends Foo & Bar> List<T> nStep(List<T> ts){} <T extends Foo> List<T> finalStep(List<T> ts){} 

Después de haber indicado que cierto paso de procesamiento de datos requiere información adicional que no es necesaria ni al comienzo del procesamiento ni al final.


Usando mock ', logré probar el código. Pero cuando se trataba de la implementación y la cantidad de datos y varias fuentes comenzaron a crecer, rápidamente me di por vencido y rehice todo a un aspecto "normal". Todo funciona y nadie se queja. Resulta que la eficacia y la simplicidad del código triunfa sobre la "prevención" de errores, y puede rastrear la secuencia correcta de llamadas manualmente, incluso si el error se manifiesta solo en la etapa de prueba manual.


Quizás si retrocediera un paso y mirara el código desde el otro lado, tendría soluciones completamente diferentes. Pero sucedió que me interesé en esta línea de comentarios en particular.


Ya al ​​final del artículo, pensando en el hecho de que, dado que no es bueno describir los configuradores en las interfaces, puede imaginar el ensamblaje de los datos del producto en forma de un Builder , que devuelve una interfaz diferente después de agregar los datos definidos. Nuevamente, todo depende de la complejidad de la lógica de construir objetos. Si trabajó con Spring Security, entonces está familiarizado con este tipo de solución.


Para mi ejemplo, dice:


Solución basada en patrones de constructor
 public class Application_2 { public static void main(String[] args) { var product = new Product.Builder() .id(1000) .price(20) .availability("available") .images(List.of("url1, url2")) .build(); System.out.println(product.getId()); System.out.println(product.getAvailability()); System.out.println(product.getPrice()); System.out.println(product.getImages()); } static class Product { private final int price; private final long id; private final String availability; private final List<String> images; private Product(int price, long id, String availability, List<String> images) { this.price = price; this.id = id; this.availability = availability; this.images = images; } public int getPrice() { return price; } public long getId() { return id; } public String getAvailability() { return availability; } public List<String> getImages() { return images; } public static class Builder implements ProductBuilder, ProductWithPriceBuilder { private int price; private long id; private String availability; private List<String> images; @Override public ProductBuilder id(long id) { this.id = id; return this; } @Override public ProductWithPriceBuilder price(int price) { this.price = price; return this; } @Override public ProductBuilder availability(String availability) { this.availability = availability; return this; } @Override public ProductBuilder images(List<String> images) { this.images = images; return this; } public Product build(){ var av = price > 0 && availability != null ? availability : "sold out"; return new Product(price, id, av, images); } } public interface ProductBuilder { ProductBuilder id(long id); ProductBuilder images(List<String> images); ProductWithPriceBuilder price(int price); Product build(); } public interface ProductWithPriceBuilder{ ProductBuilder availability(String availability); } } } 

Entonces eso:


  • Escribir características limpias
  • Escribe un código hermoso y claro
  • Recuerde que la brevedad es una hermana y lo más importante es que el código funciona
  • Siéntase libre de cuestionar cosas, buscar otras formas e incluso lanzar soluciones alternativas, para el caso
  • ¡No te quedes callado! Al explicar un problema a otros, nacen mejores soluciones (Rubber Duck Driven Developent)

Gracias por su atencion

Source: https://habr.com/ru/post/478826/


All Articles