Come gestire i "campi condizionali" in Java?

2

Ho incontrato diverse situazioni in cui un POJO in cui il valore di un campo è significativo dipende dal valore di un altro campo. Un esempio, usando Lombok (che cerchiamo di usare per evitare il boilerplate):

@Data
@Builder
public class SomePojo {
    // an enum whose values are DEFAULT_LOCATION, STANDARD_INPUT, FILE
    private final SourceType sourceType;

    // meaningful only if sourceType == FILE
    private final String path;

    private final String attributes;  
}

(Questo è solo un esempio, il caso che sto guardando non ha nulla a che fare con i file. Se fa la differenza, lo scopo della classe è quello di restituire un metodo che deve restituire diversi pezzi di informazioni al chiamante.)

Sebbene la classe possa essere utilizzata così com'è, quali sono le migliori pratiche per affrontare questa situazione? In particolare:

  1. Devo scrivere un metodo getter personalizzato per path che genera un'eccezione se sourceType != FILE ?

  2. Devo scrivere un builder personalizzato che genera un'eccezione se il builder prova a impostare path quando imposta sourceType su qualcosa di diverso da FILE ?

  3. Devo scrivere equals() personalizzato e hashCode() che non guardano path se sourceType != FILE ?

  4. Il mio costruttore o costruttore ha impostato path su un valore fisso se sourceType != FILE ? Così facendo eliminerebbero la necessità di% speciale equals() e hashCode() .

  5. Devo rendere il percorso un Optional<String> ? Basterebbe fare questo senza fare nulla di # 1-4?

  6. È preferibile definire una nuova gerarchia di classi per incapsulare i campi sourceType e path (quindi ci sarebbero tre sottoclassi di qualche classe base e solo una di esse avrebbe un path )? Supponiamo che non ci siano metodi polimorfici in questa gerarchia.

MORE: Sono d'accordo con il commento che se un metodo restituisce un record variante, ci sono buone possibilità che il metodo stia facendo troppo e che sia necessario controllarlo. Ma questo non è sempre il caso. Un esempio molto semplice sarebbe un metodo che cerca una stringa per vedere se contiene una sottostringa e restituisce la posizione della sottostringa se presente. (Il metodo Java per fare ciò restituisce un valore speciale come "posizione" se la sottostringa non è presente, che sarebbe una cattiva pratica perché può introdurre errori se un chiamante non riesce a controllare questo caso e tratta la posizione risultante come numero. Optional sarebbe di aiuto in questo caso, ma non nel caso in cui ci siano più di due stati da restituire.) Questo sembra essere un caso di uso naturale per il ritorno (FOUND, position ) o (NOT_FOUND ) che non avrebbe una posizione. Il caso con cui sto lavorando non è così semplice, ma è stato esaminato a fondo ed è già stato strappato un paio di volte dai colleghi nelle revisioni del design, quindi sono abbastanza sicuro che non è un metodo che fa troppo .

    
posta ajb 10.08.2017 - 22:56
fonte

1 risposta

5

tl; dr Come altri hanno già detto, # 6 è, design-saggio, l'approccio migliore e probabilmente l'approccio migliore anche su altri assi. Il resto di questo presenta vari modi di fare # 6 e come puoi scegliere tra loro.

In una lingua con tipi di dati algebrici (o simili) come i più diffusi linguaggi funzionali tipizzati staticamente (Haskell, SML, O'Caml, F #) ma anche Scala, Swift, Rust, sarebbe semplice rappresentare tale tipo . Nella sintassi Haskell:

data Source
  = StdIn { attributes :: String }
  | DefaultLocation { attributes :: String }
  | File { attributes :: String, path :: String }

Questo può essere codificato praticamente in tutti i linguaggi orientati agli oggetti usando la spedizione dinamica:

interface Source {
    string getAttributes();
}
class StdInSource implements Source {
    private string attributes;
    StdIn(string attributes) { this.attributes = attributes; }
    string getAttributes() { return this.attributes; }
}
// Similarly for DefaultLocationSource and FileSource, the latter taking also the path

Ovviamente, come è, l'unica cosa che puoi fare dato un Source è getAttributes e probabilmente vuoi fare di più. Con i tipi di dati algebrici, è possibile eseguire lo schema di corrispondenza per recuperare le informazioni in ciascun caso e inviare il caso. Questo può essere catturato nella resa di Java:

interface Source {
    <A> A match(Function<string, A> stdInCase, 
                Function<string, A> defaultLocationCase,
                BiFunction<string, string, A> fileCase);
}

// With now code like:
class StdInSource implements Source {
    // same as before
    <A> A match(Function<string, A> stdInCase, 
                Function<string, A> defaultLocationCase,
                BiFunction<string, string, A> fileCase) {
        return stdInCase.apply(this.attributes);
    }
}

Questo approccio cattura più o meno il comportamento del tipo di dati algebrico, ma cattura anche alcuni aspetti negativi. Il metodo match è fondamentalmente il metodo accept del pattern Visitor. Se raggruppassimo tutti quei Function s in una classe invece di avere un gruppo di parametri, l'oggetto risultante sarebbe fondamentalmente un visitatore. Il pattern Visitor porta ad alcuni vincoli sull'estensibilità, approssimativamente corrispondenti alla natura chiusa dei tipi di dati algebrici.

Nel caso ideale per la programmazione orientata agli oggetti, ci sarebbe un servizio generale che si desidera fornire da Source e qualsiasi implementatore dell'interfaccia sarebbero dettagli di implementazione che non contano per te. Diciamo che ti interessa solo ottenere un flusso di input da queste fonti, quindi puoi facilmente creare un'interfaccia come:

interface Source {
    InputStream getStream();
}

Con tale interfaccia, è completamente semplice aggiungere un'opzione aggiuntiva, ad esempio, per la lettura da una fonte in memoria. Se hai bisogno di sapere se un Source è un file o meno quindi sai di chiudere il file o qualcosa del genere, questa decisione dovrebbe essere inserita negli oggetti aggiungendo un metodo close all'interfaccia Source e lasciando decidere gli implementatori cosa fare con esso. Il consiglio OOP standard è quello di scaricare sempre queste decisioni sugli oggetti e questo è spesso un buon consiglio. Può diventare assurdo però. Stai per creare una sottoclasse di Boolean ogni volta che esegui un if ?

In parole povere, il mio punto di vista è che alcuni oggetti sono "dati". Questi dovrebbero essere "oggetti di valore"; immutabile, inerte e per lo più trasparente. Altri oggetti rappresentano "servizi" e dovrebbero essere attivi, incapsulati e spesso dotati di stato. A mio parere, la pratica OOP si concentra in gran parte su quest'ultima prospettiva. L'approccio al tipo di dati algebrico è più appropriato per oggetti "dati", e spesso inappropriati per oggetti "servizio". D'altra parte, spingere le decisioni (e il comportamento) su oggetti "dati" è spesso l'opposto di ciò che si vuole fare. In questo senso, è interessante vedere quale sarebbe un approccio FP all'estensibilità.

La cosa fondamentale è che i "dati" non hanno un comportamento. Il modo in cui un dato viene interpretato dipende dal consumatore. Solo perché ho File { attributes = "", path = "foo" } non significa che un consumatore deve aprire il file "pippo". Questa potrebbe essere l'interpretazione "standard", ma altre interpretazioni possono esistere fianco a fianco. È un tema comune nella pratica FP utilizzare i dati per descrivere ciò che si vuole fare, e solo successivamente interpretarlo. Un vantaggio di questo approccio è che diventa molto facile fare trasformazioni / analisi "globali" del "piano". Quindi il "piano" è "dati" -y, ma l'interprete di "il piano" è "servizio" -y. In effetti, è facile avere un'interfaccia semplice e generica alla versione getStream di Source ; vale a dire, interpret(Plan thePlan) .

Le tecniche FP orientate ai dati sono buone per la costruzione / trasformazione / ottimizzazione / analisi di una descrizione di cosa fare che può quindi essere utilizzato per orchestrare il comportamento di oggetti attivi implementati con tecniche OOP orientate ai servizi. Ciò libera gli oggetti attivi dal dover apprendere sul loro contesto e reagire in modo appropriato, il che è spesso un codice complicato e che distrugge la modularità. Ad esempio, è molto facile prendere una lista di (descrizioni di) trasformazioni che rappresentano una pipeline e riconoscere dove certe trasformazioni possono essere combinate insieme o cancellate. È molto difficile, d'altra parte, per una particolare (istanza di) trasformazione indicare che in realtà annulla il lavoro della sua trasformazione upstream, e che la trasformazione upstream dovrebbe essere rimossa.

    
risposta data 11.08.2017 - 05:37
fonte

Leggi altre domande sui tag