Sum Types vs Polymorphism

7

L'anno scorso ho fatto il salto e ho imparato un linguaggio di programmazione funzionale (F #) e una delle cose più interessanti che ho trovato è come influisce sul modo in cui progetto il software OO. Le due cose che mi mancano di più nelle lingue OO sono la corrispondenza dei pattern e i tipi di somma. Ovunque guardi, vedo situazioni che sarebbero banalmente modellate con un'unione discriminata, ma sono riluttante a crowbar in qualche implementazione OO DU che sembra innaturale al paradigma.

In genere questo mi porta a creare tipi intermedi per gestire le relazioni or che un tipo di somma gestirà per me. Sembra anche portare a una buona quantità di ramificazioni. Se leggo persone come Misko Hevery , egli suggerisce che un buon design OO può minimizzare la ramificazione attraverso il polimorfismo.

Una delle cose che evito il più possibile nel codice OO è i tipi con valori di null . Ovviamente la relazione or può essere modellata da un tipo con un valore null e un valore non- null , ma questo significa test null ovunque. Esiste un modo per modellare i tipi eterogenei ma associati logicamente in modo polimorfico? Strategie o modelli di progettazione sarebbero molto utili, o semplicemente modi per pensare a tipi eterogenei e associati generalmente nel paradigma OO.

    
posta Patrick D 12.03.2018 - 11:30
fonte

3 risposte

9

Come te, vorrei che le unioni discriminate fossero più prevalenti; tuttavia, la ragione per cui sono utili nella maggior parte dei linguaggi funzionali è che forniscono una corrispondenza esauriente di pattern, e senza questo sono solo una sintassi carina: non solo la corrispondenza dei pattern: esaustivo pattern matching, così che il codice non compilare se non si copre ogni possibilità: questo è ciò che ti dà potere.

L'unico modo per fare qualcosa di utile con un tipo di somma è decomporlo e diramarlo in base al tipo (ad esempio, per la corrispondenza del modello). Il bello delle interfacce è che non ti importa di che tipo è qualcosa, perché sai che puoi trattarlo come iface : nessuna logica univoca necessaria per ogni tipo: nessuna ramificazione.

Questo non è un "codice funzionale ha più ramificazioni, il codice OO ha meno", questo è un "linguaggi funzionali" sono più adatti ai domini in cui si hanno i sindacati - che richiedono branching - e "lingue OO" sono migliori adatto al codice in cui è possibile esporre un comportamento comune come un'interfaccia comune, che potrebbe sembrare una minore ramificazione ". La ramificazione è una funzione del tuo design e del dominio. Molto semplicemente, se i tuoi "tipi eterogenei ma associati logicamente" non possono esporre un'interfaccia comune, devi eseguire il branch / pattern-match su di essi. Questo è un problema di dominio / design.

Ciò a cui potrebbe riferirsi Misko è l'idea generale che se può esporre i tuoi tipi come un'interfaccia comune, allora usare le funzionalità OO (interfacce / polimorfismo) migliorerà la tua vita mettendo comportamento specifico nel tipo piuttosto che nel codice che consuma.

È importante riconoscere che interfacce e unioni sono l'una dell'altra l'una dell'altra: un'interfaccia definisce alcune cose che il tipo deve implementare, e l'unione definisce alcune cose il consumatore deve considerare. Se si aggiunge un metodo a un'interfaccia, il contratto è stato modificato e ora è necessario aggiornare ogni tipo precedentemente implementato. Se aggiungi un nuovo tipo a un sindacato, hai modificato quel contratto e ora ogni modello esaustivo di corrispondenza sull'unione deve essere aggiornato. Riempiono ruoli diversi, e mentre a volte può essere possibile implementare un sistema 'in entrambi i modi', con cui si procede è una decisione progettuale: nessuno dei due è intrinsecamente migliore.

Un vantaggio derivante dall'uso di interfacce / polimorfismo è che il codice che consuma è più estensibile: è possibile passare un tipo che non è stato definito in fase di progettazione, purché esponga l'interfaccia concordata. Il rovescio della medaglia, con un'unione statica, è possibile sfruttare comportamenti che non sono stati considerati in fase di progettazione scrivendo nuovi abbinamenti di pattern esaustivi, a patto che si attengano al contratto del sindacato.

Per quanto riguarda il "Modello oggetto nullo": questo non è un proiettile argentato, e non sostituisce null controlli. Tutto ciò fornisce un modo per evitare alcuni controlli "nulli" in cui il comportamento "nullo" può essere esposto dietro un'interfaccia comune. Se non puoi esporre il comportamento "nullo" dietro l'interfaccia del tipo, allora penserai "Vorrei davvero poter modellare in modo esaustivo questa corrispondenza" e finirò per eseguire un controllo di "ramificazione".

    
risposta data 12.03.2018 - 18:28
fonte
2

Esiste un modo abbastanza "standard" di codificare i tipi di somma in un linguaggio orientato agli oggetti.

Ecco due esempi:

type Either<'a, 'b> = Left of 'a | Right of 'b

In C #, potremmo renderlo come:

interface Either<A, B> {
    C Match<C>(Func<A, C> left, Func<B, C> right);
}

class Left<A, B> : Either<A, B> {
    private readonly A a;
    public Left(A a) { this.a = a; }
    public C Match<C>(Func<A, C> left, Func<B, C> right) {
        return left(a);
    }
}

class Right<A, B> : Either<A, B> {
    private readonly B b;
    public Right(B b) { this.b = b; }
    public C Match<C>(Func<A, C> left, Func<B, C> right) {
        return right(b);
    }
}

F # di nuovo:

type List<'a> = Nil | Cons of 'a * List<'a>

C # di nuovo:

interface List<A> {
    B Match<B>(B nil, Func<A, List<A>, B> cons);
}

class Nil<A> : List<A> {
    public Nil() {}
    public B Match<B>(B nil, Func<A, List<A>, B> cons) {
        return nil;
    }
}

class Cons<A> : List<A> {
    private readonly A head;
    private readonly List<A> tail;
    public Cons(A head, List<A> tail) {
        this.head = head;
        this.tail = tail;
    }
    public B Match<B>(B nil, Func<A, List<A>, B> cons) {
        return cons(head, tail);
    }
}

La codifica è completamente meccanica. Questa codifica produce un risultato che presenta la maggior parte degli stessi vantaggi e svantaggi dei tipi di dati algebrici. Puoi anche riconoscerlo come una variazione del pattern Visitor. Potremmo raccogliere i parametri in Match insieme in un'interfaccia che potremmo chiamare un visitatore.

Dal lato dei vantaggi, questo ti dà una codifica basata sui principi dei tipi di somma. (È la codifica di Scott .) Ti offre una "corrispondenza di pattern" esaustiva anche se solo uno "strato" "di abbinamento alla volta. Match è in qualche modo un'interfaccia "completa" per questi tipi e qualsiasi operazione aggiuntiva che possiamo desiderare può essere definita in termini di esso. Presenta una prospettiva diversa su molti modelli OO come il Pattern di oggetto Nullo e il Pattern di stato come ho indicato sulla risposta di Ryathal, così come il pattern Visitor e il Pattern composito. Il tipo Option / Maybe è simile a un modello oggetto Null generico. Il pattern composito è simile alla codifica type Tree<'a> = Leaf of 'a | Children of List<Tree<'a>> . Lo State Pattern è fondamentalmente una codifica di un'enumerazione.

Dal lato degli svantaggi, mentre lo scrivevo il metodo Match pone alcuni vincoli su ciò che le sottoclassi possono essere significativamente aggiunte, specialmente se vogliamo mantenere la proprietà di sostituibilità di Liskov. Ad esempio, l'applicazione di questa codifica a un tipo di enumerazione non consente di estendere in modo significativo l'enumerazione. Se si desidera estendere l'enumerazione, è necessario modificare tutti i chiamanti e gli implementatori ovunque, proprio come se si stesse utilizzando enum e switch . Detto questo, questa codifica è leggermente più flessibile dell'originale. Ad esempio, possiamo aggiungere un implementatore Append di List che contiene solo due elenchi che ci forniscono append di tempo costante. Questo sarebbe comportarsi come le liste aggiunte insieme ma sarebbero rappresentate in un modo diverso.

Naturalmente, molti di questi problemi hanno a che fare con il fatto che Match è in qualche modo (concettualmente ma intenzionalmente) legato alle sottoclassi. Se usiamo metodi che non sono così specifici, otteniamo disegni OO più tradizionali e recuperiamo l'estensibilità, ma perdiamo la "completezza" dell'interfaccia e quindi perdiamo la capacità di definire qualsiasi operazione su questo tipo in termini di interfaccia. Come accennato altrove, questa è una manifestazione del Problema di espressione .

Probabilmente, disegni come quelli sopra possono essere usati sistematicamente per eliminare completamente la necessità di ramificazioni che raggiungono sempre un OO ideale. Smalltalk, ad esempio, utilizza questo modello spesso incluso per le booleane stesse. Ma come suggerisce la discussione precedente, questa "eliminazione della ramificazione" è abbastanza illusoria. Abbiamo appena implementato la ramificazione in un modo diverso e ha ancora molte delle stesse proprietà.

    
risposta data 12.03.2018 - 22:44
fonte
1

La gestione di null può essere eseguita con il modello di oggetto nullo . L'idea è di creare un'istanza dei tuoi oggetti che restituisca valori predefiniti per ogni membro e che abbia metodi che non eseguono nulla, ma che non escludano errori. Questo non elimina completamente le verifiche nulle, ma significa che devi solo controllare i null alla creazione dell'oggetto e restituire il tuo oggetto nullo.

Il modello di stato è un modo per ridurre al minimo la ramificazione e offrire alcuni dei vantaggi della corrispondenza dei modelli. Di nuovo spinge la logica di ramificazione alla creazione dell'oggetto. Ogni stato è un'implementazione separata di un'interfaccia di base, quindi tutto il codice che consuma deve solo chiamare DoStuff () e viene chiamato il metodo corretto. Alcune lingue aggiungono anche la corrispondenza del modello come caratteristica, C # è un esempio.

    
risposta data 12.03.2018 - 13:36
fonte

Leggi altre domande sui tag