Builder Pattern: quando fallire?


45

Quando implemento il Builder Pattern, spesso mi trovo confuso su quando lasciare che l'edificio fallisca e riesco persino a prendere diverse posizioni sulla questione ogni pochi giorni.

Prima una spiegazione:

  • Se fallisco presto intendo che la costruzione di un oggetto dovrebbe fallire non appena viene passato un parametro non valido. Quindi all'interno di SomeObjectBuilder.
  • Con il fallimento in ritardo intendo che la costruzione di un oggetto può fallire solo nella build()chiamata che chiama implicitamente un costruttore dell'oggetto da costruire.

Quindi alcuni argomenti:

  • A favore del fallimento in ritardo: una classe builder non dovrebbe essere altro che una classe che contiene semplicemente valori. Inoltre, riduce la duplicazione del codice.
  • A favore del fallimento precoce: un approccio generale nella programmazione del software è che si desidera rilevare i problemi il più presto possibile e quindi il posto più logico da controllare sarebbe nel costruttore "classe", "setter" e infine nel metodo di compilazione.

Qual è il consenso generale al riguardo?


8
Non vedo alcun vantaggio nel fallire tardi. Ciò che qualcuno dice che una classe di costruttori "dovrebbe" essere non ha la precedenza sul buon design, e catturare i bug in anticipo è sempre meglio che catturare i bug in ritardo.
Doval,

3
Un altro modo di vedere questo è che il costruttore potrebbe non sapere quali siano i dati validi. Fallire presto in questo caso è più una questione di fallire non appena sai che c'è un errore. Non fallire presto, sarebbe il costruttore a restituire un nulloggetto quando si verificava un problema build().
Chris,

Se non aggiungi un modo per emettere un avviso e offrire mezzi per risolvere il problema all'interno del builder, non ha senso fallire in ritardo.
Segna il

Risposte:


34

Diamo un'occhiata alle opzioni, dove possiamo posizionare il codice di validazione:

  1. Dentro i setter nel costruttore.
  2. All'interno del build()metodo.
  3. All'interno dell'entità costruita: verrà invocata con build()metodo quando l'entità viene creata.

L'opzione 1 ci consente di rilevare i problemi in precedenza, ma possono esserci casi complicati in cui possiamo convalidare l'input solo con il contesto completo, quindi, facendo almeno parte della convalida nel build()metodo. Pertanto, la scelta dell'opzione 1 porterà a un codice incoerente con parte della convalida eseguita in un posto e un'altra parte eseguita in un altro posto.

L'opzione 2 non è significativamente peggiore dell'opzione 1, perché, di solito, i setter nel builder vengono invocati proprio prima build()dell'interfaccia fluida, specialmente. Pertanto, è ancora possibile rilevare un problema abbastanza presto nella maggior parte dei casi. Tuttavia, se il builder non è l'unico modo per creare un oggetto, porterà alla duplicazione del codice di convalida, poiché dovrai averlo ovunque dove crei un oggetto. La soluzione più logica in questo caso sarà quella di mettere la validazione il più vicino possibile all'oggetto creato, cioè al suo interno. E questa è l' opzione 3 .

Dal punto di vista SOLIDO, mettere la validazione nel builder viola anche SRP: la classe builder ha già la responsabilità di aggregare i dati per costruire un oggetto. La convalida sta stabilendo contratti sul proprio stato interno, è una nuova responsabilità controllare lo stato di un altro oggetto.

Quindi, dal mio punto di vista, non solo è meglio fallire in ritardo dal punto di vista del design, ma è anche meglio fallire all'interno dell'entità costruita, piuttosto che nel costruttore stesso.

UPD: questo commento mi ha ricordato un'altra possibilità, quando la convalida all'interno del builder (opzione 1 o 2) ha senso. Ha senso se il costruttore ha i suoi contratti sugli oggetti che sta creando. Ad esempio, supponiamo che abbiamo un builder che costruisce una stringa con contenuto specifico, ad esempio un elenco di intervalli di numeri 1-2,3-4,5-6. Questo builder può avere un metodo simile addRange(int min, int max). La stringa risultante non sa nulla di questi numeri, né dovrebbe saperlo. Il costruttore stesso definisce il formato della stringa e i vincoli sui numeri. Pertanto, il metodo addRange(int,int)deve convalidare i numeri di input e generare un'eccezione se max è inferiore a min.

Detto questo, la regola generale sarà quella di validare solo i contratti definiti dal costruttore stesso.


Penso che valga la pena notare che mentre l' opzione 1 può portare a tempi di controllo "incoerenti", può comunque essere considerata coerente se tutto è "il più presto possibile". È un po 'più semplice rendere più preciso "il più presto possibile" se si utilizza invece la variante del builder, StepBuilder.
Joshua Taylor,

Se un builder URI genera un'eccezione se viene passata una stringa null, si tratta di una violazione di SOLID? Spazzatura
Gusdor,

@Gusdor sì, se genera un'eccezione stessa. Tuttavia, dal punto di vista dell'utente, tutte le opzioni sembrano un'eccezione generata da un builder.
Ivan Gammel,

Quindi perché non avere un validate () che viene chiamato da build ()? In questo modo c'è poca duplicazione, coerenza e nessuna violazione di SRP. Inoltre, consente di convalidare i dati senza tentare di crearli e la convalida è vicina alla creazione.
StellarVortex,

@StellarVortex in questo caso verrà convalidato due volte - una volta in builder.build () e, se i dati sono validi e procediamo al costruttore dell'oggetto, in quel costruttore.
Ivan Gammel,

34

Dato che usi Java, prendi in considerazione le istruzioni autorevoli e dettagliate fornite da Joshua Bloch nell'articolo Creazione e distruzione di oggetti Java (il carattere in grassetto nella citazione seguente è mio):

Come un costruttore, un costruttore può imporre invarianti ai suoi parametri. Il metodo build può controllare questi invarianti. È fondamentale che vengano controllati dopo aver copiato i parametri dal builder sull'oggetto e che siano controllati sui campi oggetto anziché sui campi builder (Articolo 39). Se vengono violati invarianti, il metodo di costruzione dovrebbe lanciare un IllegalStateException(Articolo 60). Il metodo di dettaglio dell'eccezione dovrebbe indicare quale invariante è stato violato (elemento 63).

Un altro modo per imporre invarianti che coinvolgono più parametri è quello di fare in modo che i metodi setter prendano interi gruppi di parametri su cui alcuni invarianti devono essere in possesso. Se l'invariante non è soddisfatto, il metodo setter genera un IllegalArgumentException. Ciò ha il vantaggio di rilevare l'errore invariante non appena vengono passati i parametri non validi, invece di attendere che venga invocata la build.

Nota secondo la spiegazione dell'editor in questo articolo, gli "articoli" nella citazione sopra si riferiscono alle regole presentate in Effective Java, Second Edition .

L'articolo non approfondisce la spiegazione del perché questo è raccomandato, ma se ci pensi, i motivi sono abbastanza evidenti. Un suggerimento generico sulla comprensione di questo è fornito proprio qui nell'articolo, nella spiegazione di come il concetto di costruttore è collegato a quello del costruttore - e si prevede che gli invarianti di classe siano controllati nel costruttore, non in nessun altro codice che possa precedere / preparare la sua invocazione.

Per una comprensione più concreta del perché controllare gli invarianti prima di invocare una build sarebbe sbagliato, prendere in considerazione un esempio popolare di CarBuilder . I metodi del builder possono essere invocati in un ordine arbitrario e, di conseguenza, non si può davvero sapere se un determinato parametro è valido fino alla compilazione.

Considera che l'auto sportiva non può avere più di 2 posti, come si può sapere se setSeats(4)va bene o no? È solo nella build quando si può sapere con certezza se è setSportsCar()stato invocato o meno, il che significa se lanciare TooManySeatsExceptiono meno.


3
+1 per raccomandare quali tipi di eccezione lanciare, esattamente quello che stavo cercando.
Xantix,

Non sono sicuro di avere l'alternativa. Sembra che stia parlando di quando gli invarianti possono essere validati solo in gruppi. Il builder accetta singoli attributi quando non coinvolgono altri e accetta solo gruppi di attributi quando il gruppo ha un invariante su se stesso. In questo caso, il singolo attributo dovrebbe generare un'eccezione prima della compilazione?
Didier A.

19

A mio avviso, i valori non validi che non sono validi perché non sono tollerati dovrebbero essere resi noti immediatamente. In altre parole, se si accettano solo numeri positivi e viene passato un numero negativo, non è necessario attendere fino alla build()chiamata. Non considererei questi tipi di problemi che "ti aspetteresti" si verifichino, dato che è un prerequisito per chiamare il metodo per cominciare. In altre parole, probabilmente non dipenderesti dalla mancata impostazione di determinati parametri. Più probabilmente supponeresti che i parametri siano corretti o eseguiresti un controllo da solo.

Tuttavia, per problemi più complessi che non sono così facilmente convalidati potrebbe essere meglio essere resi noti quando chiami build(). Un buon esempio di ciò potrebbe essere l'utilizzo delle informazioni di connessione fornite per stabilire una connessione a un database. In questo caso, mentre tecnicamente potresti verificare tali condizioni, non è più intuitivo e complica solo il tuo codice. A mio avviso, questi sono anche i tipi di problemi che potrebbero effettivamente verificarsi e che non puoi davvero prevedere fino a quando non lo provi. È una sorta di differenza tra la corrispondenza di una stringa con un'espressione regolare per vedere se potrebbe essere analizzata come int e il semplice tentativo di analizzarla, gestendo eventuali eccezioni che possono verificarsi di conseguenza.

In genere non mi piace lanciare eccezioni quando si impostano i parametri poiché significa dover rilevare qualsiasi eccezione generata, quindi tendo a favorire la convalida build(). Quindi, per questo motivo, preferisco usare RuntimeException poiché, in genere, non dovrebbero verificarsi errori nei parametri passati.

Tuttavia, questa è più una buona pratica che altro. Spero che risponda alla tua domanda.


11

Per quanto ne so, la pratica generale (non sono sicuro che ci sia consenso) è quella di fallire non appena è possibile scoprire fattibilmente un errore. Ciò rende anche più difficile abusare involontariamente l'API.

Se si tratta di un attributo banale che può essere verificato sull'input, come una capacità o una lunghezza che dovrebbe essere non negativa, allora è meglio fallire immediatamente. Tenendo premuto l'errore aumenta la distanza tra errore e feedback, il che rende più difficile trovare la fonte del problema.

Se hai la sfortuna di trovarti in una situazione in cui la validità di un attributo dipende da altri, allora hai due possibilità:

  • Richiede che entrambi (o più) attributi siano forniti simultaneamente (cioè invocazione con metodo singolo).
  • Verifica la validità non appena sai che non sono in arrivo ulteriori modifiche: quando build()viene chiamato.

Come per la maggior parte delle cose, questa è una decisione presa in un contesto. Se il contesto lo rende inopportuno o complicato fallire presto, può essere fatto un compromesso per rinviare i controlli a un momento successivo, ma il fallimento dovrebbe essere l'impostazione predefinita.


Quindi, per riassumere, stai dicendo che è ragionevole convalidare il prima possibile tutto ciò che avrebbe potuto essere coperto in un oggetto / tipo primitivo? Mi piace unsigned, @NonNullecc.
Skiwi,

2
@skiwi Praticamente sì. Controlli di dominio, controlli null, quel genere di cose. Non consiglierei di aggiungere molto di più: i costruttori sono generalmente cose semplici.
JvR,

1
Vale la pena notare che se la validità di un parametro dipende dal valore di un altro, si può legittimamente rifiutare un valore di parametro solo se si sa che l'altro è "realmente" stabilito . Se è consentito impostare un valore di parametro più volte [con l'ultima impostazione che ha la precedenza], in alcuni casi il modo più naturale per impostare un oggetto potrebbe essere quello di impostare il parametro Xsu un valore non valido dato il valore attuale di Y, ma prima di chiamare build()impostato Ysu un valore che renderebbe Xvalido.
supercat,

Se ad esempio si sta costruendo un Shapee il costruttore ha WithLefte WithRightproprietà e si desidera regolare un costruttore per costruire un oggetto in un posto diverso, richiedendo che WithRightvenga chiamato per primo quando si sposta un oggetto a destra e WithLeftquando si sposta a sinistra, si aggiungerebbe inutile complessità rispetto a consentire WithLeftdi impostare il bordo sinistro a destra del vecchio bordo destro, a condizione che venga WithRightfissato il bordo destro prima che buildvenga chiamato.
supercat,

0

La regola di base è "fallire presto".

La regola leggermente più avanzata è "fallire il prima possibile".

Se una proprietà è intrinsecamente non valida ...

CarBuilder.numberOfWheels( -1 ). ...  

... poi lo rifiuti immediatamente.

In altri casi potrebbe essere necessario verificare i valori in combinazione e potrebbero essere posizionati meglio nel metodo build ():

CarBuilder.numberOfWheels( 0 ).type( 'Hovercraft' ). ...  
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.