Padrão do Construtor: Quando falhar?

45

Ao implementar o Padrão do Construtor, muitas vezes me vejo confuso sobre quando deixar a construção falhar e até consigo tomar posições diferentes sobre o assunto a cada poucos dias.

Primeiro alguma explicação:

  • Com a falha inicial, quero dizer que a construção de um objeto deve falhar assim que um parâmetro inválido for passado. Assim, dentro do SomeObjectBuilder.
  • Com o atraso tardio, quero dizer que a construção de um objeto só pode falhar na build()chamada que implicitamente chama um construtor do objeto a ser construído.

Depois, alguns argumentos:

  • A favor de falhar tarde: Uma classe de construtor não deve ser mais do que uma classe que simplesmente mantém valores. Além disso, leva a menos duplicação de código.
  • A favor da falha inicial: Uma abordagem geral na programação de software é que você deseja detectar problemas o mais cedo possível e, portanto, o local mais lógico para verificar seria na classe do construtor 'construtor', 'setters' e, finalmente, no método de construção.

Qual é o consenso geral sobre isso?

skiwi
fonte
8
Não vejo vantagem em falhar tarde. O que alguém diz que uma classe de construtor "deveria" ser não tem precedência sobre um bom design, e detectar bugs mais cedo é sempre melhor do que detectar bugs mais tarde.
Doval 28/05
3
Outra maneira de analisar isso é que o construtor pode não saber quais são os dados válidos. Falhar no início deste caso é mais sobre falhar assim que você souber que há um erro. Se não falhar cedo, o construtor retornará um nullobjeto quando houver um problema build().
28414 Chris
Se você não adicionar uma maneira de emitir um aviso e oferecer meios para corrigir no construtor, não há sentido em falhar tarde.
Mark

Respostas:

34

Vamos dar uma olhada nas opções, onde podemos colocar o código de validação:

  1. Dentro dos levantadores no construtor.
  2. Dentro do build()método.
  3. Dentro da entidade construída: será invocado no build()método quando a entidade estiver sendo criada.

A opção 1 nos permite detectar problemas mais cedo, mas pode haver casos complicados em que podemos validar a entrada apenas com o contexto completo, fazendo assim pelo menos parte da validação no build()método. Assim, escolher a opção 1 levará a um código inconsistente, com parte da validação sendo feita em um local e outra parte sendo feita em outro local.

A opção 2 não é significativamente pior que a opção 1, porque, geralmente, os setters no construtor são chamados imediatamente antes build(), especialmente, em interfaces fluentes. Assim, ainda é possível detectar um problema com antecedência suficiente na maioria dos casos. No entanto, se o construtor não for a única maneira de criar um objeto, isso levará à duplicação do código de validação, porque você precisará tê-lo em todos os lugares em que criar um objeto. A solução mais lógica nesse caso será colocar a validação o mais próximo possível do objeto criado, ou seja, dentro dele. E esta é a opção 3 .

Do ponto de vista do SOLID, colocar a validação no construtor também viola o SRP: a classe do construtor já tem a responsabilidade de agregar os dados para construir um objeto. A validação está estabelecendo contratos em seu próprio estado interno; é uma nova responsabilidade verificar o estado de outro objeto.

Assim, do meu ponto de vista, não só é melhor falhar tarde da perspectiva do design, mas também é melhor falhar dentro da entidade construída do que no próprio construtor.

UPD: esse comentário me lembrou mais uma possibilidade, quando a validação dentro do construtor (opção 1 ou 2) faz sentido. Faz sentido se o construtor tiver seus próprios contratos nos objetos que está criando. Por exemplo, suponha que tenhamos um construtor que construa uma sequência com conteúdo específico, por exemplo, lista de intervalos de números 1-2,3-4,5-6. Este construtor pode ter um método como addRange(int min, int max). A sequência resultante não sabe nada sobre esses números, nem precisa saber. O próprio construtor define o formato da sequência e as restrições nos números. Portanto, o método addRange(int,int)deve validar os números de entrada e gerar uma exceção se max for menor que min.

Dito isto, a regra geral será validar apenas os contratos definidos pelo próprio construtor.

Ivan Gammel
fonte
Eu acho que vale a pena notar que, embora a Opção 1 possa levar a tempos de verificação "inconsistentes", ela ainda pode ser vista como consistente se tudo for "o mais cedo possível". É um pouco mais fácil tornar "o mais cedo possível" mais definitivo se a variante do construtor, o StepBuilder, for usada.
Joshua Taylor
Se um construtor de URI lança uma exceção se uma cadeia nula for passada, isso é uma violação do SOLID? Rubbish
Gusdor
@ Gusdor sim, se ele lançar uma exceção em si. No entanto, do ponto de vista do usuário, todas as opções parecem uma exceção lançada por um construtor.
Ivan Gammel
Então, por que não ter um validate () chamado por build ()? Dessa forma, há pouca duplicação, consistência e nenhuma violação do SRP. Também possibilita validar os dados sem tentar construir, e a validação está próxima da criação.
StellarVortex
@StellarVortex, nesse caso, será validado duas vezes - uma vez no builder.build () e, se os dados forem válidos e continuarmos como construtor de objeto, nesse construtor.
precisa
34

Como você usa Java, considere as orientações autorizadas e detalhadas fornecidas por Joshua Bloch no artigo Criando e destruindo objetos Java (a fonte em negrito na citação abaixo é minha):

Como um construtor, um construtor pode impor invariantes em seus parâmetros. O método de construção pode verificar esses invariantes. É essencial que eles sejam verificados depois de copiar os parâmetros do construtor para o objeto e que sejam verificados nos campos do objeto em vez dos campos do construtor (Item 39). Se quaisquer invariantes forem violados, o método de construção deve lançar um IllegalStateException(Item 60). O método de detalhe da exceção deve indicar qual invariante é violado (Item 63).

Outra maneira de impor invariantes envolvendo vários parâmetros é fazer com que os métodos setter usem grupos inteiros de parâmetros nos quais alguns invariantes devem se manter. Se a invariante não for satisfeita, o método setter lança um IllegalArgumentException. Isso tem a vantagem de detectar a falha invariável assim que os parâmetros inválidos forem transmitidos, em vez de esperar que a compilação seja chamada.

Observe que, de acordo com a explicação do editor deste artigo, "itens" na citação acima se referem às regras apresentadas no Effective Java, Second Edition .

O artigo não explica detalhadamente por que isso é recomendado, mas se você pensar nisso, os motivos são bastante aparentes. Uma dica genérica para entender isso é fornecida logo no artigo, na explicação de como o conceito de construtor está conectado ao de construtor - e espera-se que os invariantes de classe sejam verificados no construtor, e não em qualquer outro código que possa preceder / preparar sua invocação.

Para um entendimento mais concreto sobre por que a verificação de invariantes antes de invocar uma construção estaria errada, considere um exemplo popular do CarBuilder . Os métodos do construtor podem ser chamados em uma ordem arbitrária e, como resultado, não se pode realmente saber se um parâmetro específico é válido até a compilação.

Considere que o carro esportivo não pode ter mais de 2 assentos, como saber se setSeats(4)está bem ou não? É apenas na construção que se pode saber com certeza se setSportsCar()foi invocado ou não, ou seja, se deve ser lançado TooManySeatsExceptionou não.

mosquito
fonte
3
+1 por recomendar que tipos de exceção lançar, exatamente o que eu estava procurando.
Xantix
Não tenho certeza de ter a alternativa. Parece estar falando apenas de quando invariantes só podem ser validados em grupos. O construtor aceita atributos únicos quando eles não envolvem nenhum outro e só aceita grupos de atributos quando o grupo tem uma invariante em si. Nesse caso, o único atributo deve lançar uma exceção antes da compilação?
Didier A.
19

Valores inválidos que são inválidos porque não são tolerados devem ser divulgados imediatamente na minha opinião. Em outras palavras, se você aceitar apenas números positivos e um número negativo for passado, não será necessário esperar até que build()seja chamado. Eu não consideraria esses os tipos de problemas que você "esperaria" que acontecessem, pois é um pré-requisito para chamar o método para começar. Em outras palavras, você provavelmente não dependeria da falha em definir determinados parâmetros. É mais provável que você presuma que os parâmetros estejam corretos ou faça alguma verificação por conta própria.

No entanto, para problemas mais complicados que não são tão facilmente validados, pode ser melhor conhecido quando você liga build(). Um bom exemplo disso pode ser o uso das informações de conexão fornecidas para estabelecer uma conexão com um banco de dados. Nesse caso, embora você possa tecnicamente verificar essas condições, ele não é mais intuitivo e apenas complica seu código. Na minha opinião, esses também são os tipos de problemas que podem realmente acontecer e que você não pode realmente antecipar até tentar. É meio que a diferença entre combinar uma string com uma expressão regular para ver se ela pode ser analisada como int e simplesmente tentar analisá-la, lidando com possíveis exceções que possam ocorrer como consequência.

Geralmente, eu não gosto de lançar exceções ao definir parâmetros, pois significa ter que capturar qualquer exceção lançada, por isso tendem a favorecer a validação build(). Portanto, por esse motivo, prefiro usar o RuntimeException, pois erros nos parâmetros passados ​​geralmente não devem ocorrer.

No entanto, essa é mais uma prática recomendada do que qualquer coisa. Espero que isso responda à sua pergunta.

Neil
fonte
11

Tanto quanto eu sei, a prática geral (não tenho certeza se há consenso) é falhar o mais cedo possível. Isso também dificulta o uso indevido acidental de sua API.

Se é um atributo trivial que pode ser verificado na entrada, como capacidade ou comprimento que não deve ser negativo, é melhor falhar imediatamente. Adiar o erro aumenta a distância entre erro e feedback, o que torna mais difícil encontrar a fonte do problema.

Se você tem a infelicidade de estar em uma situação em que a validade de um atributo depende de outros, então você tem duas opções:

  • Exija que ambos (ou mais) atributos sejam fornecidos simultaneamente (ou seja, invocação de método único).
  • Teste a validade assim que você souber que não haverá mais alterações: quando build()é chamado assim.

Como na maioria das coisas, essa é uma decisão tomada em um contexto. Se o contexto tornar difícil ou complicado a falha precoce, uma compensação pode ser feita para adiar as verificações posteriormente, mas a falha rápida deve ser o padrão.

JvR
fonte
Então, para resumir, você está dizendo que é razoável validar o mais cedo possível tudo o que poderia ter sido coberto em um objeto / tipo primitivo? Como unsigned, @NonNulletc.
skiwi
2
@skiwi Praticamente sim. Verificações de domínio, verificações nulas, esse tipo de coisa. Eu não defenderia colocar muito mais nisso do que isso: construtores geralmente são coisas simples.
JVR
1
Vale a pena notar que, se a validade de um parâmetro depende do valor de outro, só é possível rejeitar legitimamente um valor de parâmetro se um souber que o outro está "realmente" estabelecido . Se for permitido definir um valor de parâmetro várias vezes [com a última configuração prevalecendo], em alguns casos, a maneira mais natural de configurar um objeto pode ser configurá-lo Xcom um valor inválido, considerando o valor atual de Y, mas antes de chamar build()definido Ycomo um valor que tornaria Xválido.
Supercat
Se por exemplo um é a construção de um Shapeeo construtor tem WithLefte WithRightpropriedades, e se deseja ajustar um construtor para construir um objeto em um lugar diferente, exigindo que WithRightser chamado pela primeira vez ao mover um certo objeto e WithLeftao movê-lo para a esquerda, iria adicionar complexidade desnecessária comparado com a permissão WithLeftpara definir a borda esquerda à direita da borda direita antiga, desde que WithRighta borda direita seja corrigida antes build.
Supercat
0

A regra básica é "falhar cedo".

A regra um pouco mais avançada é "falhar o mais cedo possível".

Se uma propriedade é intrinsecamente inválida ...

CarBuilder.numberOfWheels( -1 ). ...  

... então você rejeita imediatamente.

Outros casos podem precisar que os valores sejam verificados em combinação e podem ser melhor colocados no método build ():

CarBuilder.numberOfWheels( 0 ).type( 'Hovercraft' ). ...  
Phill W.
fonte