Patrón de construcción: ¿Cuándo fallar?

45

Al implementar el Patrón de construcción, a menudo me confundo con cuándo dejar que falle el edificio e incluso me las arreglo para tomar diferentes posiciones sobre el asunto cada pocos días.

Primero alguna explicación:

  • Si falla temprano, quiero decir que la construcción de un objeto debería fallar tan pronto como se pase un parámetro no válido. Entonces dentro del SomeObjectBuilder.
  • Al fallar tarde quiero decir que construir un objeto solo puede fallar en la build()llamada que implícitamente llama a un constructor del objeto a construir.

Entonces algunos argumentos:

  • A favor de fallar tarde: una clase de constructor no debe ser más que una clase que simplemente contiene valores. Además, conduce a una menor duplicación de código.
  • A favor de fallar temprano: un enfoque general en la programación de software es que desea detectar problemas lo antes posible y, por lo tanto, el lugar más lógico para verificar sería en la clase de constructor 'constructor', 'setters' y, en última instancia, en el método de compilación.

¿Cuál es el consenso general sobre esto?

skiwi
fuente
8
No veo ninguna ventaja en fallar tarde. Lo que alguien dice que debería ser una clase de constructor no tiene prioridad sobre un buen diseño, y atrapar errores temprano siempre es mejor que atraparlos tarde.
Doval
3
Otra forma de ver esto es que el constructor puede no saber qué datos son válidos. Fallar temprano en este caso se trata más de fallar tan pronto como sepa que hay un error. Si no falla temprano, el constructor devolverá un nullobjeto cuando haya un problema build().
Chris
Si no agrega una forma de emitir una advertencia y una oferta significa arreglarlo dentro del generador, no tiene sentido fallar tarde.
Mark

Respuestas:

34

Veamos las opciones, donde podemos colocar el código de validación:

  1. Dentro de los setters en constructor.
  2. Dentro del build()método.
  3. Dentro de la entidad construida: se invocará en el build()método cuando se cree la entidad.

La opción 1 nos permite detectar problemas antes, pero puede haber casos complicados en los que podemos validar la entrada solo teniendo el contexto completo, por lo tanto, haciendo al menos parte de la validación en el build()método. Por lo tanto, elegir la opción 1 conducirá a un código inconsistente con parte de la validación realizada en un lugar y otra parte realizada en otro lugar.

La opción 2 no es significativamente peor que la opción 1, porque, por lo general, los configuradores en el generador se invocan justo antes que build(), especialmente, en las interfaces fluidas. Por lo tanto, todavía es posible detectar un problema lo suficientemente temprano en la mayoría de los casos. Sin embargo, si el generador no es la única forma de crear un objeto, dará lugar a la duplicación del código de validación, ya que deberá tenerlo en todas partes donde cree un objeto. La solución más lógica en este caso será colocar la validación lo más cerca posible del objeto creado, es decir, dentro de él. Y esta es la opción 3 .

Desde el punto de vista SÓLIDO, poner la validación en el generador también viola SRP: la clase del generador ya tiene la responsabilidad de agregar los datos para construir un objeto. La validación es establecer contratos en su propio estado interno, es una nueva responsabilidad verificar el estado de otro objeto.

Por lo tanto, desde mi punto de vista, no solo es mejor fallar tarde desde la perspectiva del diseño, sino que también es mejor fallar dentro de la entidad construida, en lugar de hacerlo en el propio constructor.

UPD: este comentario me recordó una posibilidad más, cuando la validación dentro del generador (opción 1 o 2) tiene sentido. Tiene sentido si el constructor tiene sus propios contratos en los objetos que está creando. Por ejemplo, suponga que tenemos un generador que construye una cadena con contenido específico, por ejemplo, una lista de rangos de números 1-2,3-4,5-6. Este constructor puede tener un método como addRange(int min, int max). La cadena resultante no sabe nada sobre estos números, ni debería tener que saberlo. El propio constructor define el formato de la cadena y las restricciones en los números. Por lo tanto, el método addRange(int,int)debe validar los números de entrada y lanzar una excepción si max es menor que min.

Dicho esto, la regla general será validar solo los contratos definidos por el propio constructor.

Ivan Gammel
fuente
Creo que vale la pena señalar que, si bien la Opción 1 puede conducir a tiempos de verificación "inconsistentes", todavía se puede ver como coherente si todo es "lo antes posible". Es un poco más fácil hacer "lo antes posible" más definido si se utiliza la variante del constructor, el StepBuilder.
Joshua Taylor
Si un generador de URI arroja una excepción si se pasa una cadena nula, ¿se trata de una violación de SOLID? Basura
Gusdor
@Gusdor sí, si arroja una excepción en sí. Sin embargo, desde el punto de vista del usuario, un constructor lanza todas las opciones como una excepción.
Ivan Gammel
Entonces, ¿por qué no tener un validate () llamado por build ()? De esa manera hay poca duplicación, consistencia y no hay violación de SRP. También permite validar los datos sin intentar compilar, y la validación está cerca de la creación.
StellarVortex
@StellarVortex en este caso se validará dos veces, una vez en builder.build () y, si los datos son válidos y procedemos al constructor del objeto, en ese constructor.
Ivan Gammel
34

Dado que usa Java, considere la orientación autorizada y detallada proporcionada por Joshua Bloch en el artículo Creación y destrucción de objetos Java (la fuente en negrita en la cita a continuación es mía):

Como un constructor, un constructor puede imponer invariantes en sus parámetros. El método de construcción puede verificar estos invariantes. Es fundamental que se verifiquen después de copiar los parámetros del generador al objeto, y que se verifiquen en los campos del objeto en lugar de los campos del generador (Elemento 39). Si se viola alguna invariante, el método de construcción debería arrojar un IllegalStateException(Elemento 60). El método de detalle de la excepción debe indicar qué invariante se viola (elemento 63).

Otra forma de imponer invariantes que involucran múltiples parámetros es hacer que los métodos de establecimiento tomen grupos enteros de parámetros en los que deben mantenerse algunos invariantes. Si el invariante no está satisfecho, el método setter arroja un IllegalArgumentException. Esto tiene la ventaja de detectar la falla invariante tan pronto como se pasan los parámetros no válidos, en lugar de esperar a que se invoque la compilación.

Nota según la explicación del editor en este artículo, los "elementos" en la cita anterior se refieren a las reglas presentadas en Effective Java, Second Edition .

El artículo no profundiza en explicar por qué esto se recomienda, pero si lo piensa, las razones son bastante evidentes. El consejo genérico para comprender esto se proporciona allí mismo en el artículo, en la explicación de cómo se conecta el concepto de constructor con el del constructor, y se espera que los invariantes de clase se verifiquen en el constructor, no en ningún otro código que pueda preceder / preparar su invocación.

Para una comprensión más concreta de por qué sería incorrecto verificar invariantes antes de invocar una construcción, considere un ejemplo popular de CarBuilder . Los métodos de construcción se pueden invocar en un orden arbitrario y, como resultado, uno no puede saber realmente si un parámetro en particular es válido hasta la construcción.

Considere que el auto deportivo no puede tener más de 2 asientos, ¿cómo podría uno saber si setSeats(4)está bien o no? Es solo en la construcción cuando uno puede saber con certeza si setSportsCar()se invocó o no, lo que significa si lanzar TooManySeatsExceptiono no.

mosquito
fuente
3
+1 por recomendar qué tipos de excepción lanzar, exactamente lo que estaba buscando.
Xantix
No estoy seguro de obtener la alternativa. Parece estar hablando únicamente de cuándo los invariantes solo pueden validarse en grupos. El constructor acepta atributos únicos cuando no involucran a ningún otro, y solo acepta grupos de atributos cuando el grupo tiene una invariante en sí mismo. En este caso, ¿debería el atributo único generar una excepción antes de la compilación?
Didier A.
19

Los valores no válidos que no son válidos porque no se toleran deben darse a conocer de inmediato en mi opinión. En otras palabras, si acepta solo números positivos y se pasa un número negativo, no hay necesidad de esperar hasta que build()se llame. No consideraría estos los tipos de problemas que "esperarías" que sucedan, ya que es un requisito previo para comenzar con el método. En otras palabras, es probable que no dependa de la falla en establecer ciertos parámetros. Lo más probable es que presumas que los parámetros son correctos o que realices alguna comprobación tú mismo.

Sin embargo, para problemas más complicados que no se validan tan fácilmente, es mejor que se den a conocer cuando llame build(). Un buen ejemplo de esto podría ser utilizar la información de conexión que proporciona para establecer una conexión a una base de datos. En este caso, aunque técnicamente podría verificar tales condiciones, ya no es intuitivo y solo complica su código. Desde mi punto de vista, estos también son los tipos de problemas que podrían suceder y que realmente no puedes anticipar hasta que lo pruebes. Es una especie de juego la diferencia entre una cadena con una expresión regular para ver si podía ser analizada como un int y simplemente tratando de analizarlo, el manejo de las excepciones posibles que pueden ocurrir como una consecuencia.

En general, no me gusta lanzar excepciones al establecer parámetros, ya que significa tener que atrapar cualquier excepción lanzada, por lo que tiendo a favorecer la validación build(). Por esta razón, prefiero usar RuntimeException ya que nuevamente, los errores en los parámetros pasados ​​generalmente no deberían suceder.

Sin embargo, esta es más una mejor práctica que otra cosa. Espero haber respondido a su pregunta.

Neil
fuente
11

Hasta donde yo sé, la práctica general (no estoy seguro si hay consenso) es fallar tan pronto como sea posible descubrir un error. Esto también dificulta el mal uso involuntario de su API.

Si es un atributo trivial que se puede verificar en la entrada, como una capacidad o longitud que no debería ser negativa, entonces es mejor que falle inmediatamente. Retener el error aumenta la distancia entre el error y la retroalimentación, lo que hace que sea más difícil encontrar la fuente del problema.

Si tiene la desgracia de encontrarse en una situación en la que la validez de un atributo depende de otros, entonces tiene dos opciones:

  • Requiere que ambos (o más) atributos se suministren simultáneamente (es decir, invocación de método único).
  • Pruebe la validez tan pronto como sepa que no hay más cambios entrantes: cuándo build()se llama más o menos.

Como con la mayoría de las cosas, esta es una decisión tomada en un contexto. Si el contexto hace que resulte incómodo o complicado fallar temprano, se puede hacer una compensación para diferir los cheques a un momento posterior, pero la falla rápida debería ser la opción predeterminada.

JvR
fuente
Entonces, para resumir, ¿está diciendo que es razonable validar lo antes posible todo lo que podría haberse cubierto en un objeto / tipo primitivo? Me gusta unsigned, @NonNulletc.
skiwi
2
@skiwi Bastante, sí. Verificaciones de dominio, verificaciones nulas, ese tipo de cosas. No recomendaría poner mucho más que eso: los constructores son generalmente cosas simples.
JvR
1
Vale la pena señalar que si la validez de un parámetro depende del valor de otro, uno solo puede rechazar legítimamente un valor de parámetro si uno sabe que el otro está "realmente" establecido . Si es permisible establecer un valor de parámetro varias veces [con la última configuración teniendo prioridad], entonces, en algunos casos, la forma más natural de configurar un objeto puede ser configurar el parámetro Xa un valor que no es válido dado el valor actual de Y, pero antes de llamar a build()set Ya un valor que sea Xválido.
supercat
Si, por ejemplo, uno está construyendo a Shapey el constructor tiene WithLefty WithRightpropiedades, y uno desea ajustar un constructor para construir un objeto en un lugar diferente, requiriendo que WithRightse llame primero al mover un objeto a la derecha y WithLeftal moverlo a la izquierda, agregaría una complejidad innecesaria en comparación con permitir WithLeftestablecer el borde izquierdo a la derecha del borde derecho anterior siempre que se WithRightsolucione el borde derecho antes de que buildse llame.
supercat
0

La regla básica es "fallar temprano".

La regla un poco más avanzada es "fallar lo antes posible".

Si una propiedad es intrínsecamente inválida ...

CarBuilder.numberOfWheels( -1 ). ...  

... entonces lo rechazas de inmediato.

Otros casos pueden necesitar que los valores se verifiquen en combinación y podrían colocarse mejor en el método build ():

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