Reflexões sobre POO e o estado dos objetos

O código do programa é escrito de cima para baixo. As instruções são lidas e executadas na mesma ordem. Isso é lógico e todo mundo está acostumado a isso. Em alguns casos, você pode alterar a ordem das operações. Mas, às vezes, a sequência de chamadas de função é importante, embora sintaticamente isso não seja óbvio. Acontece que o código parece funcionar, mesmo depois de reorganizar as chamadas, e o resultado é inesperado.


Uma vez, um código semelhante chamou minha atenção.


O problema


Uma vez, vasculhando o código de outra pessoa em um projeto conjunto, descobri uma função 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; } 

Obviamente, nem o estilo mais "elegante" chama sua atenção: classes armazenam dados (POJO), funções alteram objetos recebidos ...


Em geral, parece não ser nada. Temos um objeto no qual não há dados suficientes, e existem dados em si provenientes de outras fontes (serviços), que agora colocaremos nesse objeto para completá-lo.


Mas existem algumas nuances.


  1. Não gosto do fato de que nenhuma função seja escrita no estilo de FP e modifica o objeto passado como argumento.
    • Mas digamos que isso foi feito para reduzir o tempo de processamento e o número de objetos criados.
  2. Apenas um comentário no código diz que a sequência de chamadas é importante e você deve ter cuidado ao incorporar o novo Filler
    • Mas o número de pessoas trabalhando no projeto é maior que 1 e nem todo mundo sabe sobre esse truque. Especialmente novas pessoas na equipe (não necessariamente na empresa).

O último ponto me guardou especialmente. Afinal, a API é construída de tal maneira que não sabemos o que mudou no objeto depois de chamar essa função. Por exemplo, antes e depois de termos o método Product::getImages , que, antes de chamar a função de fill , produzirá uma lista vazia e, em seguida, uma lista com imagens para o nosso produto.


Com o Filler coisas são ainda piores. AvailabilityFiller não deixa claro que espera que as informações sobre o preço das mercadorias já estejam incorporadas no objeto transferido.


E então pensei em como poderia proteger meus colegas do uso incorreto de funções.


Soluções propostas


Primeiro, decidi discutir esse caso com meus colegas. Infelizmente, todas as soluções que eles propuseram não me pareceram a abordagem correta.


Runtimeexception


Uma das opções propostas era: e você escreve no AvailabilityFiller no início da função Objects.requireNonNull(product.getPrices) e qualquer programador já receberá um erro durante os testes locais.


  • mas o preço realmente pode não estar lá, se o serviço não estiver disponível ou em algum outro erro, o produto deverá receber o status "fora de estoque". Teremos que atribuir todos os tipos de sinalizadores ou qualquer coisa para distinguir "sem dados" de "nem mesmo solicitado".
    • Se você lançar uma exceção no próprio getPrices , criaremos os mesmos problemas do Java moderno com listas
      • Suponha que uma Lista seja passada para uma função que ofereça o método get em sua API ... Sei que você não precisa alterar os objetos transferidos, mas criar novos. Mas o ponto principal é que a API nos oferece esse método, mas em tempo de execução pode ocorrer um erro se for uma lista imutável, como a obtida em Collectors.toList ()
  • se o AvailabilityFiller for usado por outra pessoa, o programador que escreveu a chamada não entenderá imediatamente qual é o problema. Somente após o lançamento e depuração. Então ele ainda precisa entender o código para descobrir onde obter os dados.

Teste


"E você escreve um teste que será interrompido se você alterar a ordem das chamadas." isto é se todos os Filler retornarem um produto "novo", algo como isso acontecerá:


 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)); 

  • Eu não gosto de testes que são tão "caixa branca"
  • Rompe com cada novo Filler
  • Interrompe ao alterar a sequência de chamadas independentes
  • Novamente, ele não resolve o problema de reutilizar o próprio AvailabilityFiller

Próprias tentativas de resolver o problema


Idéia


Acho que você já adivinhou que gostaria de resolver o problema no nível da compilação. Bem, por que eu preciso, um pergunta, de uma linguagem compilada com digitação forte, se não consigo evitar o erro.


E me perguntei se um objeto sem dados adicionais e um objeto "estendido" pertencem à mesma classe?


Não seria correto descrever os vários estados possíveis de um objeto como classes ou interfaces separadas?


Então, minha ideia foi a seguinte:


 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) 

I.e. o produto originalmente definido é uma instância de uma classe diferente da retornada, que já mostra alterações de dados.


Filler , em suas APIs, especificam exatamente quais dados eles precisam e quais retornam.


Dessa forma, você pode impedir a sequência de chamadas incorreta.


Implementação


Como traduzir isso em realidade em Java? (Lembre-se de que a herança de várias classes em Java não é possível.)


A complexidade é adicionada por operações independentes. Por exemplo, as imagens podem ser adicionadas antes e depois da adição de preços, bem como no final da função.
Então talvez


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

Como descrever tudo?


Criar 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 dados / links?


 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 já é perceptível, tudo se resume a uma enorme quantidade de código. E isso apesar do fato de que no exemplo deixei apenas 3 tipos de dados de entrada. No mundo real, pode haver muito mais.


Acontece que os custos de escrever e manter esse código não se justificam, embora a ideia de dividir o estado em classes separadas parecesse muito atraente para mim.


Retiro


Se você olhar para outros idiomas, em algum lugar esse problema é mais fácil de resolver, mas em outro lugar não.
Por exemplo, no Go, você pode escrever uma referência a uma classe extensível sem os métodos de "cópia" ou "sobrecarga". Mas não é sobre Go

Outra digressão


Enquanto escrevia este artigo, outra solução possível surgiu com o Proxy , que pedia apenas a criação de novos métodos, mas exigia uma hierarquia de interfaces. Em geral, assustador, irritado e inadequado. Se alguém estiver subitamente interessado:

Antes de ir para a cama e comer, não é recomendável olhar para este
 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()); } } } 

Conclusão


O que acontece? Por um lado, existe uma abordagem interessante que aplica classes separadas a um "objeto" em diferentes estados e garante a prevenção de erros causados ​​por uma sequência incorreta de chamadas a métodos que modificam esse objeto.


Por outro lado, essa abordagem faz você escrever tanto código que deseja recusá-lo imediatamente. Um monte de interfaces e classes apenas dificulta a compreensão do projeto.


No meu outro projeto, eu ainda tentei usar essa abordagem. E, a princípio, no nível da interface, tudo estava bem. Eu escrevi as funções:


 <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){} 

Tendo assim indicado que uma determinada etapa do processamento de dados requer informações adicionais que não são necessárias nem no início do processamento nem no seu final.


Usando mock 'e, consegui testar o código. Mas quando se tratava de implementação e a quantidade de dados e várias fontes começaram a crescer, rapidamente desisti e refiz tudo em uma aparência "normal". Tudo funciona e ninguém reclama. Acontece que a eficiência e a simplicidade do código triunfam sobre a "prevenção" de erros, e você pode rastrear a sequência correta de chamadas manualmente, mesmo que o erro se manifeste apenas no estágio de teste manual.


Talvez se eu desse um passo para trás e visse o código do outro lado, teria soluções completamente diferentes. Mas aconteceu que fiquei interessado nessa linha comentada em particular.


Já no final do artigo, pensando no fato de que, como não é legal descrever setters em interfaces, você pode imaginar a montagem de dados do produto na forma de um Builder , que retorna uma interface diferente após adicionar os dados definidos. Novamente, tudo depende da complexidade da lógica da construção de objetos. Se você trabalhou com o Spring Security, está familiarizado com esse tipo de solução.


Para o meu exemplo, ele diz:


Solução baseada em padrões do construtor
 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); } } } 

Para que:


  • Escreva recursos limpos
  • Escreva um código bonito e claro
  • Lembre-se de que a brevidade é uma irmã e, mais importante, que o código funciona
  • Sinta-se à vontade para questionar as coisas, procurar outras maneiras e até criar soluções alternativas, nesse caso
  • Não fique calado! Ao explicar um problema para outras pessoas, surgem melhores soluções (Desenvolvido por Rubber Duck Driven Developent)

Obrigado pela atenção.

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


All Articles