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


16

Alcuni sostengono che se si prendono i principi SOLID ai massimi livelli, si finisce con la programmazione funzionale . Sono d'accordo con questo articolo ma penso che alcune semantiche si perdano nel passaggio dall'interfaccia / oggetto alla funzione / chiusura e voglio sapere come la Programmazione funzionale può mitigare la perdita.

Dall'articolo:

Inoltre, se applichi rigorosamente il principio di segregazione dell'interfaccia (ISP), capirai che dovresti favorire le interfacce di ruolo rispetto alle interfacce di intestazione.

Se continui a guidare il tuo progetto verso interfacce sempre più piccole, alla fine arriverai all'interfaccia ruolo definitiva: un'interfaccia con un solo metodo. Questo mi succede molto. Ecco un esempio:

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

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

Confronta questo a prendere una dipendenza da sua firma funzionali equivalente, int -> string. Senza ulteriori spunti, questa funzione potrebbe essere semplice ToString(). Se ti avessi implementato IMessageQuery.Read(int id)con un, ToString()potrei accusarti di essere deliberatamente sovversivo!

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

type MessageQuery = {
    Read: int -> string
}

5
Un'interfaccia OOP è più simile alla classe di caratteri FP , non a una singola funzione.
9000,

2
Puoi avere troppe cose positive, applicare l'ISP 'rigorosamente' per finire con 1 metodo per interfaccia sta andando troppo lontano.
gbjbaanb,

3
@gbjbaanb in realtà la maggior parte delle mie interfacce ha un solo metodo con molte implementazioni. Più si applicano i principi SOLIDI, più si possono vedere i vantaggi. Ma questo è fuori tema per questa domanda
AlexFoxGill,

1
@jk .: Beh, in Haskell, sono le classi di tipo, in OCaml usi un modulo o un funzione, in Clojure usi un protocollo. In ogni caso, di solito non si limita l'analogia dell'interfaccia a una singola funzione.
9000

2
Without any additional clues... forse è per questo che la documentazione fa parte del contratto ?
SJuan76,

Risposte:


8

Come dice Telastyn, confrontando le definizioni statiche delle funzioni:

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

per

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

Non hai davvero perso nulla passando da OOP a FP.

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

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

Ora non possiamo vedere direttamente il nome del metodo IMessageQuery.Reado il suo parametro int id, ma possiamo arrivarci molto facilmente attraverso il nostro IDE. Più in generale, il fatto che stiamo passando un'interfaccia IMessageQuerypiuttosto che una qualsiasi con un metodo una funzione da int a stringa significa che stiamo mantenendo i idmetadati del nome di parametro associati a questa funzione.

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

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

Quindi cosa abbiamo mantenuto e perso? Bene, abbiamo ancora il nome del parametro messageReader, che probabilmente rende IMessageQuerysuperfluo il nome del tipo (l'equivalente a ). Ma ora abbiamo perso il nome del parametro idnella nostra funzione.


Ci sono due modi principali per aggirare questo:

  • In primo luogo, leggendo quella firma, puoi già fare un'ipotesi abbastanza buona su cosa succederà. Mantenendo le funzioni brevi, semplici e coerenti e usando una buona denominazione, rendi molto più facile intuire o trovare queste informazioni. Una volta che abbiamo iniziato a leggere la funzione vera e propria, sarebbe ancora più semplice.

  • In secondo luogo, è considerato design idiomatico in molti linguaggi funzionali per creare piccoli tipi per avvolgere i primitivi. In questo caso, accade il contrario: invece di sostituire un nome di tipo con un nome di parametro ( IMessageQuerya messageReader), possiamo sostituire un nome di parametro con un nome di tipo. Ad esempio, intpotrebbe essere racchiuso in un tipo chiamato Id:

    type Id = Id of int
    

    Ora la nostra readfirma diventa:

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

    Che è altrettanto informativo come quello che avevamo prima.

    Come nota a margine, questo ci fornisce anche una parte della protezione del compilatore che avevamo in OOP. Considerando che la versione OOP ha assicurato che prendessimo specificamente una funzione IMessageQuerypiuttosto che una vecchia int -> string, qui abbiamo una protezione simile (ma diversa) che stiamo prendendo Id -> stringpiuttosto che una qualsiasi vecchia int -> string.


Sarei riluttante a dire con la certezza del 100% che queste tecniche saranno sempre altrettanto efficaci e istruttive come avere tutte le informazioni disponibili su un'interfaccia, ma penso dagli esempi precedenti, puoi dire che la maggior parte delle volte, noi probabilmente può fare altrettanto un buon lavoro.


1
Questa è la mia risposta preferita - che FP incoraggia l'uso di tipi semantici
AlexFoxGill

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 un bel po 'più della OO (/ java) in stile ThingDoer.doThing()stile


2
+1. Inoltre, puoi codificare molte più proprietà nel sistema di tipi in linguaggi FP digitati come Haskell. Se quello fosse un tipo di haskell, saprei che non sta facendo alcun IO (e quindi probabilmente facendo riferimento a qualche mappatura in memoria degli ID ai messaggi da qualche parte). Nelle lingue OOP, non ho queste informazioni: questa funzione potrebbe far apparire un database come parte della sua chiamata.
Jack,

1
Non sono esattamente chiaro sul perché questo dovrebbe comunicare più dello stile Java. Cosa read: MessageId -> Messageti dice che string MessageReader.GetMessage(int messageId)non lo fa?
Ben Aaronson,

@BenAaronson il rapporto segnale-rumore è migliore. Se si tratta di un metodo di istanza OO non ho idea di cosa stia facendo sotto il cofano, potrebbe avere qualsiasi tipo di dipendenza che non sia espressa. Come menziona Jack, quando hai tipi più forti, hai più garanzie.
Daenyth,

9

Quindi, cosa possono fare i programmatori funzionali per preservare la semantica di un'interfaccia ben definita?

Utilizzare funzioni ben denominate.

IMessageQuery::Read: int -> stringdiventa semplicemente ReadMessageQuery: int -> stringo qualcosa di simile.

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

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

Non in questo esempio. Come ho spiegato sopra, una singola classe / interfaccia con una singola funzione non è significativamente più istruttiva di una funzione autonoma altrettanto ben denominata.

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

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


2
E i nomi dei parametri?
Ben Aaronson,

@BenAaronson - e loro? Sia OO che i linguaggi funzionali ti consentono di specificare i nomi dei parametri, quindi è una spinta. Sto solo usando la scorciatoia della firma del tipo qui per essere coerente con la domanda. In una lingua reale sarebbe simile aReadMessageQuery id = <code to go fetch based on id>
Telastyn,

1
Bene, se una funzione accetta un'interfaccia come parametro, quindi chiama un metodo su quell'interfaccia, i nomi dei parametri per il metodo dell'interfaccia sono prontamente disponibili. Se una funzione accetta un'altra funzione come parametro, non è facile assegnare i nomi dei parametri per quella passata in funzione
Ben Aaronson,

1
Sì, @BenAaronson questa è una delle informazioni perse nella firma della funzione. Ad esempio in let consumer (messageQuery : int -> string) = messageQuery 5, il idparametro è solo un int. Immagino che un argomento sia che dovresti passare un Id, non un int. In effetti, ciò rappresenterebbe una risposta decente in sé.
AlexFoxGill

@AlexFoxGill In realtà stavo solo scrivendo qualcosa in questo senso!
Ben Aaronson,

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 è un contratto semantico o no? Non è necessario definire un tipo per un "foldrer", soprattutto perché foldrè determinato in modo univoco da tali leggi. Sai esattamente cosa sta per fare.

Se vuoi assumere 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 controllo del tipo non imporrà alcuna semantica assegnata a un tipo, quindi crearne uno nuovo per ogni contratto è solo una piastra di caldaia.


1
Non credo che il tuo primo punto affronti la domanda: è una definizione di funzione, non una firma. Ovviamente guardando l'implementazione di una classe o di una funzione puoi dire cosa fa - la domanda ti chiede se puoi ancora conservare la semantica a un livello di astrazione (un'interfaccia o una firma di funzione)
AlexFoxGill

@AlexFoxGill - non è una definizione di funzione, anche se determina in modo univoco foldr. Suggerimento: la definizione di foldrha due equazioni (perché?) Mentre la specifica fornita sopra ne ha tre.
Jonathan Cast

Quello che voglio dire è che foldr è una funzione non una firma di funzione. Le leggi o la definizione non fanno parte della firma
AlexFoxGill

-1

Praticamente tutti i linguaggi funzionali tipicamente statici hanno un modo di aliasare i tipi di base in un modo che richiede di dichiarare esplicitamente il tuo intento semantico. Alcune delle altre risposte hanno fornito esempi. In pratica, con esperienza programmatori funzionali hanno bisogno di molto buone ragioni per utilizzare questi tipi di wrapper, perché fanno male componibilità e riutilizzabilità.

Ad esempio, supponiamo che un client desiderasse un'implementazione di una query di messaggi supportata da un elenco. In Haskell, l'implementazione potrebbe essere semplice come questa:

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

L'uso di newtype Message = Message Stringquesto sarebbe molto meno semplice, lasciando questa implementazione simile a:

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

Potrebbe non sembrare un grosso problema, ma o devi fare quel tipo di conversione avanti e indietro ovunque , o impostare un livello limite nel tuo codice in cui tutto è sopra, Int -> Stringquindi convertirlo in Id -> Messageper passare al livello sottostante. Supponiamo che volessi aggiungere l'internazionalizzazione o formattarla in tutti i caratteri maiuscoli o aggiungere un contesto di registrazione o altro. Queste operazioni sono tutte semplicissime da comporre con an Int -> Stringe fastidiose con an Id -> Message. Non è che non ci siano mai casi in cui sono auspicabili le maggiori restrizioni sul tipo, ma è meglio che il fastidio valga la pena svantaggiarsi.

Puoi utilizzare un sinonimo di tipo anziché un wrapper (in Haskell typeanziché newtype), che è molto più comune e non richiede le conversioni ovunque, ma non fornisce garanzie di tipo statico come la tua versione OOP, solo un po 'di incapsulamento. I wrapper di tipo vengono utilizzati principalmente laddove non è previsto che il client manipoli il valore, lo memorizzano e lo restituiscono. Ad esempio, un handle di file.

Nulla può impedire a un client di essere "sovversivo". Stai solo creando dei cerchi da saltare, per ogni singolo cliente. Una semplice simulazione di un test unitario comune richiede spesso comportamenti strani che non hanno senso nella produzione. Le tue interfacce dovrebbero essere scritte in modo che non se ne curino, se possibile.


1
Sembra più un commento sulle risposte di Ben / Daenyth - che puoi usare tipi semantici ma non a causa dell'inconveniente? Non ti ho sottovalutato, ma non è una risposta a questa domanda.
AlexFoxGill,

-1 Non danneggerebbe affatto la componibilità o la riusabilità. Se Ide Messagesono semplici wrapper per Inte String, è banale convertirli tra loro.
Michael Shaw,

Sì, è banale, ma è comunque fastidioso fare ovunque. Ho aggiunto un esempio e un po 'descrivendo la differenza tra l'uso di sinonimi di tipo e wrapper.
Karl Bielefeldt,

Sembra una cosa di dimensioni. Nei piccoli progetti, questi tipi di wrapper probabilmente non sono altrettanto utili. Nei progetti di grandi dimensioni, è naturale che i confini siano una piccola parte del codice complessivo, quindi fare questi tipi di conversioni al confine e passare in giro tipi avvolti ovunque non è troppo arduo. Inoltre, come ha detto Alex, non vedo come questa sia una risposta alla domanda.
Ben Aaronson,

Ho spiegato come farlo, quindi perché i programmatori funzionali non lo farebbero. Questa è una risposta, anche se non è quella che la gente voleva. Non ha nulla a che fare con le dimensioni. L'idea che sia in qualche modo una buona cosa rendere intenzionalmente più difficile riutilizzare il codice è decisamente un concetto OOP. Stai creando un tipo di prodotto che aggiunge un valore? Tutto il tempo. Creare un tipo esattamente come un tipo ampiamente utilizzato, tranne che puoi usarlo solo in una libreria? Praticamente inaudito, tranne forse nel codice FP che interagisce pesantemente con il codice OOP, o scritto da qualcuno che conosce principalmente i linguaggi OOP.
Karl Bielefeldt,
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.