Builder Pattern: quando fallire?

44

Quando si implementa il Pattern Builder, mi trovo spesso confuso con quando lasciare che l'edificio fallisca e riesco persino a prendere posizioni diverse sull'argomento ogni pochi giorni.

Prima alcune spiegazioni:

  • Con non in anticipo intendo che la creazione di un oggetto dovrebbe fallire non appena viene passato un parametro non valido. Quindi all'interno di SomeObjectBuilder .
  • Con non in ritardo intendo che la creazione di un oggetto può fallire solo con la chiamata di build() che chiama implicitamente un costruttore dell'oggetto da costruire.

Quindi alcuni argomenti:

  • A favore di un ritardo insufficiente: una classe di builder non dovrebbe essere più di una classe che contiene semplicemente dei valori. Inoltre, porta a una minore duplicazione del codice.
  • A favore del fallimento anticipato: un approccio generale nella programmazione del software è che si desidera rilevare i problemi il prima possibile e quindi il posto più logico da controllare sarebbe nel costruttore della classe builder, nei "setter" e, in definitiva, nel metodo di costruzione.

Qual è il consenso generale su questo?

    
posta skiwi 28.05.2014 - 13:43
fonte

5 risposte

34

Diamo un'occhiata alle opzioni, dove possiamo inserire il codice di convalida:

  1. All'interno dei setter nel builder.
  2. Nel metodo build() .
  3. All'interno dell'entità costruita: verrà invocato nel metodo build() quando viene creata l'entità.

L'opzione 1 ci consente di rilevare i problemi in precedenza, ma ci possono essere casi complicati quando possiamo convalidare l'input solo con il contesto completo, quindi, eseguendo almeno parte della convalida nel metodo build() . Pertanto, scegliendo l'opzione 1 si otterrà un codice incoerente con una parte della convalida eseguita in un posto e un'altra parte in un'altra posizione.

L'opzione 2 non è significativamente peggiore dell'opzione 1, perché, in genere, i setter nel builder vengono richiamati subito prima del build() , in particolare nelle interfacce fluenti. 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, questo porterà alla duplicazione del codice di convalida, perché sarà necessario averlo ovunque ovunque si crei un oggetto. La soluzione più logica in questo caso sarà quella di mettere la convalida il più vicino possibile all'oggetto creato, cioè al suo interno. E questa è l'opzione 3 .

Dal punto di vista SOLID, mettere la convalida in 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 builder stesso.

UPD: questo commento mi ha ricordato un'altra possibilità, quando la validazione all'interno del builder (opzione 1 o 2) ha senso. Ha senso se il costruttore ha i propri contratti sugli oggetti che sta creando. Ad esempio, supponiamo di avere un builder che costruisce una stringa con contenuto specifico, ad esempio l'elenco di intervalli numerici 1-2,3-4,5-6 . Questo builder può avere un metodo come addRange(int min, int max) . La stringa risultante non sa nulla di questi numeri, né dovrebbe sapere. 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 è minore di min.

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

    
risposta data 28.05.2014 - 15:49
fonte
32

Dato che usi Java, considera la guida autorevole e dettagliata fornita da Joshua Bloch nell'articolo Creazione e distruzione di oggetti Java (il font in grassetto qui sotto è mio):

Like a constructor, a builder can impose invariants on its parameters. The build method can check these invariants. It is critical that they be checked after copying the parameters from the builder to the object, and that they be checked on the object fields rather than the builder fields (Item 39). If any invariants are violated, the build method should throw an IllegalStateException (Item 60). The exception's detail method should indicate which invariant is violated (Item 63).

Another way to impose invariants involving multiple parameters is to have setter methods take entire groups of parameters on which some invariant must hold. If the invariant isn't satisfied, the setter method throws an IllegalArgumentException. This has the advantage of detecting the invariant failure as soon as the invalid parameters are passed, instead of waiting for build to be invoked.

Nota in base alla spiegazione dell'editor in questo articolo, gli "articoli" nella citazione sopra si riferiscono alle regole presentate in Java efficace, seconda edizione .

L'articolo non approfondisce la spiegazione del perché è raccomandato, ma se ci pensi, le ragioni sono piuttosto evidenti. Suggerimento generico sulla comprensione di ciò è fornito proprio lì nell'articolo, nella spiegazione su come il concetto di costruttore è connesso a quello del costruttore - e gli invarianti di classe dovrebbero essere controllati nel costruttore, non in qualsiasi altro codice che possa precedere / preparare la sua invocazione.

Per una comprensione più concreta del perché il controllo degli invarianti prima di invocare una build sarebbe sbagliato, si consideri un esempio popolare di CarBuilder . I metodi Builder possono essere richiamati in un ordine arbitrario e, di conseguenza, non è possibile sapere se un particolare parametro è valido fino alla build.

Considera che l'auto sportiva non può avere più di 2 posti, come si può sapere se setSeats(4) è ok o no? È solo alla build quando uno può sapere con certezza se setSportsCar() è stato invocato o meno, ovvero se lanciare TooManySeatsException o meno.

    
risposta data 28.05.2014 - 16:04
fonte
19

I valori non validi che non sono validi perché non sono tollerati dovrebbero essere resi noti immediatamente secondo me. In altre parole, se si accettano solo numeri positivi e viene passato un numero negativo, non è necessario attendere fino a quando viene chiamato build() . Non considererei questi i tipi di problemi che ci si aspetterebbe che si verifichino, poiché è un prerequisito per chiamare il metodo per cominciare. In altre parole, non è probabile che dipenda dal mancato settaggio di determinati parametri. Più probabilmente presumerai che i parametri siano corretti o eseguirai un controllo da solo.

Tuttavia, per problemi più complicati che non sono così facilmente convalidati potrebbe essere meglio rendersi 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 potrebbe tecnicamente controllare queste condizioni, non è più intuitivo e complica solo il tuo codice. Per come la vedo io, questi sono anche i tipi di problemi che potrebbero effettivamente accadere e che non puoi davvero prevedere fino a quando non provi. È una sorta di differenza tra l'abbinamento di una stringa con un'espressione regolare per vedere se potrebbe essere analizzato come un int e semplicemente provare ad analizzarlo, gestendo tutte le potenziali eccezioni che possono verificarsi come conseguenza.

In genere non mi piace lanciare eccezioni quando si impostano i parametri poiché significa dover rilevare eventuali eccezioni generate, quindi tendo a favorire la convalida in build() . Quindi, per questo motivo, preferisco utilizzare RuntimeException poiché di nuovo, gli errori nei parametri passati non dovrebbero generalmente accadere.

Tuttavia, questa è una pratica migliore di qualsiasi altra cosa. Spero che risponda alla tua domanda.

    
risposta data 28.05.2014 - 14:30
fonte
11

Per quanto ne so, la pratica generale (non è sicuro se c'è consenso) è di fallire il più presto possibile scoprire un errore. Ciò rende anche più difficile l'uso non intenzionale della tua API.

Se si tratta di un attributo banale che può essere controllato su input, come una capacità o una lunghezza che dovrebbe essere non negativa, allora è meglio fallire immediatamente. Trattenendo l'errore si 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 gli attributi (o più) siano forniti simultaneamente (vale a dire invocazione di un metodo singolo).
  • Verifica la validità appena sai che non ci sono più cambiamenti in arrivo: quando viene chiamato build() .

Come molte altre cose, questa è una decisione presa in un contesto. Se il contesto rende difficile o complicato fallire presto, è possibile effettuare un compromesso per posticipare i controlli in un secondo momento, ma il fail-fast dovrebbe essere l'impostazione predefinita.

    
risposta data 28.05.2014 - 14:43
fonte
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 ). ...  

... quindi lo rifiuti immediatamente.

Altri casi potrebbero aver bisogno che i valori siano controllati in combinazione e potrebbero essere posizionati meglio nel metodo build ():

CarBuilder.numberOfWheels( 0 ).type( 'Hovercraft' ). ...  
    
risposta data 19.07.2017 - 15:20
fonte

Leggi altre domande sui tag