Il contratto semantico di un'interfaccia (OOP) è più informativo di una firma di funzione (FP)?

16

Si dice che se si prendono i principi SOLIDI i loro estremi, si finisce con la programmazione funzionale . Sono d'accordo con questo articolo, ma penso che alcune semantiche si perdano nella transizione dall'interfaccia / oggetto alla funzione / chiusura, e voglio sapere come la programmazione funzionale può mitigare la perdita.

Dall'articolo:

Furthermore, if you rigorously apply the Interface Segregation Principle (ISP), you'll understand that you should favour Role Interfaces over Header Interfaces.

If you keep driving your design towards smaller and smaller interfaces, you'll eventually arrive at the ultimate Role Interface: an interface with a single method. This happens to me a lot. Here's an example:

public interface IMessageQuery
{
    string Read(int id);
}

Se prendo una dipendenza su IMessageQuery , parte del è che la chiamata Read(id) cercherà e restituirà un messaggio con l'ID specificato.

Confronta questo con la dipendenza dalla sua equivalente firma funzionale, int -> string . Senza ulteriori spunti, questa funzione potrebbe essere un semplice ToString() . Se hai implementato IMessageQuery.Read(int id) con ToString() , potrei accusarti di essere volutamente sovversivo!

Quindi, cosa possono fare i programmatori funzionali per preservare la semantica di un'interfaccia ben denominata? È convenzionale, ad esempio, creare un tipo di record con un singolo membro?

type MessageQuery = {
    Read: int -> string
}
    
posta AlexFoxGill 16.06.2015 - 15:54
fonte

5 risposte

8

Come dice Telastyn, confrontando le definizioni statiche delle funzioni:

public string Read(int id) { /*...*/ }

a

let read (id:int) = //...

Non hai davvero perso nulla passando da OOP a FP.

Tuttavia, questa è solo una parte della storia, poiché le funzioni e le interfacce non sono solo citate nelle loro definizioni statiche. Sono anche passati in giro . Diciamo che il nostro MessageQuery è stato letto da un altro pezzo di codice, un MessageProcessor . Quindi abbiamo:

public void ProcessMessage(int messageId, IMessageQuery messageReader) { /*...*/ }

Ora non possiamo direttamente vedere il nome del metodo IMessageQuery.Read o il suo parametro int id , ma possiamo arrivarci facilmente attraverso il nostro IDE. Più in generale, il fatto che stiamo passando un IMessageQuery piuttosto che una semplice interfaccia con un metodo una funzione da int a stringa significa che stiamo mantenendo quel metadata di nome parametro id associato a questa funzione.

D'altra parte, per la nostra versione funzionale abbiamo:

let read (id:int) (messageReader : int -> string) = // ...

Quindi cosa abbiamo conservato e perso? Bene, abbiamo ancora il nome del parametro messageReader , che probabilmente rende superfluo il nome del tipo (l'equivalente a IMessageQuery ). Ma ora abbiamo perso il nome del parametro id nella nostra funzione.

Ci sono due modi principali intorno a questo:

  • In primo luogo, leggendo quella firma, puoi già fare una buona idea su cosa succederà. Mantenendo le funzioni brevi, semplici e coese e utilizzando una buona denominazione, è molto più facile intuire o trovare queste informazioni. Una volta che abbiamo iniziato a leggere la funzione stessa, sarebbe ancora più semplice.

  • In secondo luogo, è considerato design idiomatico in molti linguaggi funzionali per creare piccoli tipi di wrapping primitivi. In questo caso, accade il contrario - invece di sostituire un nome di tipo con un nome di parametro (% da% di% a% diIMessageQuery) possiamo sostituire un nome di parametro con un nome di tipo. Ad esempio, messageReader potrebbe essere racchiuso in un tipo chiamato int :

    type Id = Id of int
    

    Ora la nostra firma Id diventa:

    let read (id:int) (messageReader : Id -> string) = // ...
    

    Il che è altrettanto informativo di quello che avevamo prima.

    Come nota a margine, questo ci fornisce anche parte della protezione del compilatore che avevamo in OOP. Mentre la versione OOP assicurava che abbiamo preso in modo specifico un read piuttosto che una qualsiasi vecchia funzione IMessageQuery , qui abbiamo una protezione simile (ma diversa) che stiamo prendendo un int -> string piuttosto che qualsiasi vecchio Id -> string .

Sarei riluttante a dire con il 100% di fiducia che queste tecniche saranno sempre altrettanto valide e complete di informazioni complete disponibili su un'interfaccia, ma penso che dagli esempi sopra riportati, si possa dire che la maggior parte tempo, probabilmente possiamo fare altrettanto bene.

    
risposta data 16.06.2015 - 16:39
fonte
10

Quando faccio FP, tendo ad usare tipi semantici più specifici.

Ad esempio, il tuo metodo per me diventerebbe qualcosa del tipo:

read: MessageId -> Message

Questo comunica molto più dello stile OO (/ java) in stile ThingDoer.doThing()

    
risposta data 16.06.2015 - 19:30
fonte
9

So, what can functional programmers do to preserve the semantics of a well-named interface?

Utilizza funzioni con un buon nome.

IMessageQuery::Read: int -> string diventa semplicemente ReadMessageQuery: int -> string o qualcosa di simile.

La cosa fondamentale da notare è che i nomi sono solo contratti nel senso più stretto della parola. Funzionano solo se tu e un altro programmatore derivano le stesse implicazioni dal nome e obbedisci a loro. Per questo motivo, puoi davvero usare qualsiasi nome che comunichi quel comportamento implicito. OO e la programmazione funzionale hanno i loro nomi in luoghi leggermente diversi e in forme leggermente diverse, ma la loro funzione è la stessa.

Is the semantic contract of an interface (OOP) more informative than a function signature (FP)?

Non in questo esempio. Come ho spiegato sopra, una singola classe / interfaccia con una singola funzione non è significativamente più informativa di una funzione autonoma con un nome simile.

Una volta che ottieni più di una funzione / campo / proprietà in una classe, puoi dedurre più informazioni su di loro perché puoi vedere la loro relazione. È discutibile se è più informativo delle funzioni autonome che prendono gli stessi parametri / funzioni simili o funzioni autonome organizzate per namespace o modulo.

Personalmente, non penso che OO sia significativamente più informativo, anche in esempi più complessi.

    
risposta data 16.06.2015 - 16:01
fonte
0

Non sono d'accordo sul fatto che una singola funzione non possa avere un "contratto semantico". Considera queste leggi per foldr :

foldr f z nil = z
foldr f z (singleton x) = f x z
foldr f z (xn <> ys) = foldr f (foldr f z ys) xn

In che senso non è semantico, o non un contratto? Non è necessario definire un tipo per un "folder", soprattutto perché foldr è determinato in modo univoco da tali leggi. Sai esattamente cosa farà.

Se vuoi svolgere una funzione di qualche tipo, puoi fare la stessa cosa:

-- The first argument 'f' must satisfy for all x, y, z
-- > f x x = true
-- > f x y = true and f y x = true implies x = y
-- > f x y = true and f y z = true implies f x z = true
sort :: forall 'a. (a -> a -> bool.t) -> list.t a -> list.t a;

Devi solo nominare e acquisire quel tipo se hai bisogno dello stesso contratto più volte:

-- Type of functions 'f' satisfying, for all x, y, z
-- > f x x = true
-- > f x y = true and f y x = true implies x = y
-- > f x y = true and f y z = true implies f x z = true
type comparison.t 'a = a -> a -> bool.t;

Il type-checker non imporrà alcuna semantica che assegni a un tipo, quindi fare un nuovo tipo per ogni contratto è solo boilerplate.

    
risposta data 16.06.2015 - 19:54
fonte
-1

Praticamente tutti i linguaggi funzionali tipizzati staticamente hanno un modo per creare alias tipi di base in un modo che richiede di dichiarare esplicitamente il proprio intento semantico. Alcune delle altre risposte hanno fornito esempi. In pratica, i programmatori esperti con esperienza hanno bisogno di molto buoni motivi per usare questi tipi di wrapper, perché danneggiano composibilità e riusabilità.

Ad esempio, supponiamo che un cliente abbia voluto un'implementazione di una query di messaggi che è stata supportata da un elenco. In Haskell, l'implementazione potrebbe essere semplice come questa:

messages = ["Message 0", "Message 1", "Message 2"]
messageQuery = (messages !!)

Usando newtype Message = Message String questo sarebbe molto meno semplice, lasciando questa implementazione come qualcosa del tipo:

messages = map Message ["Message 0", "Message 1", "Message 2"]
messageQuery (Id index) = messages !! index

Potrebbe non sembrare un grosso problema, ma devi fare quella conversione di tipo avanti e indietro ovunque oppure impostare un livello limite nel tuo codice dove tutto quanto sopra è Int -> String , quindi lo converti in Id -> Message per passare al livello sottostante. Diciamo che volevo aggiungere internazionalizzazione, o formattarlo in maiuscolo, o aggiungere contesto di registrazione, o qualsiasi altra cosa. Queste operazioni sono tutte semplici da comporre con un Int -> String , e fastidiose con un Id -> Message . Non è che non ci siano mai casi in cui le maggiori restrizioni di tipo sono desiderabili, ma è meglio che il fastidio valga la pena.

Puoi usare un tipo sinonimo invece di un wrapper (in Haskell type invece di newtype ), che è molto più comune e non richiede le conversioni dappertutto, ma non fornisce le garanzie di tipo statico o come la tua versione OOP, solo un po 'di incapsulamento. I wrappers di tipo sono usati principalmente laddove non ci si aspetta che il client manipoli il valore, semplicemente lo memorizzano e lo restituiscono. Ad esempio, un handle di file.

Nulla può impedire a un cliente di essere "sovversivo". Stai solo creando dei cerchi da attraversare, per ogni singolo cliente. Un semplice simulato per un test di unità comune spesso richiede un comportamento strano che non ha senso nella produzione. Le tue interfacce dovrebbero essere scritte in modo che non si preoccupino, se possibile.

    
risposta data 16.06.2015 - 21:40
fonte

Leggi altre domande sui tag