La programmazione funzionale è una valida alternativa ai modelli di iniezione di dipendenza?


21

Di recente ho letto un libro intitolato Programmazione funzionale in C # e mi viene in mente che la natura immutabile e apolide della programmazione funzionale realizza risultati simili ai modelli di iniezione di dipendenza ed è forse anche un approccio migliore, soprattutto per quanto riguarda i test unitari.

Sarei grato se qualcuno che avesse esperienza con entrambi gli approcci potesse condividere i propri pensieri ed esperienze per rispondere alla domanda principale: la Programmazione Funzionale è una valida alternativa ai modelli di iniezione di dipendenza?


10
Questo non ha molto senso per me, l'immutabilità non rimuove le dipendenze.
Telastyn,

Sono d'accordo che non rimuove le dipendenze. Probabilmente ho capito che non è corretto, ma ho fatto questa deduzione perché se non riesco a cambiare l'oggetto originale, deve necessariamente passarlo (iniettarlo) a qualsiasi funzione che lo utilizza.
Matt Cashatt,


5
C'è anche come ingannare i programmatori OO nella programmazione funzionale amorevole , che è in realtà un'analisi dettagliata di DI da una prospettiva OO e FP.
Robert Harvey,

1
Questa domanda, gli articoli a cui si collega e la risposta accettata possono anche essere utili: stackoverflow.com/questions/11276319/… Ignora la parola Monade spaventosa. Come sottolinea Runar nella sua risposta, in questo caso non è un concetto complesso (solo una funzione).
itsbruce

Risposte:


27

La gestione delle dipendenze è un grosso problema in OOP per i seguenti due motivi:

  • Lo stretto accoppiamento di dati e codice.
  • Uso ubiquitario di effetti collaterali.

La maggior parte dei programmatori OO ritiene che lo stretto accoppiamento di dati e codice sia del tutto vantaggioso, ma comporta un costo. La gestione del flusso di dati attraverso i livelli è una parte inevitabile della programmazione in qualsiasi paradigma. L'accoppiamento di dati e codice aggiunge l'ulteriore problema che se si desidera utilizzare una funzione in un determinato punto, è necessario trovare un modo per portare il suo oggetto a quel punto.

L'uso di effetti collaterali crea difficoltà simili. Se usi un effetto collaterale per alcune funzionalità, ma vuoi essere in grado di scambiarne l'implementazione, praticamente non hai altra scelta che iniettare quella dipendenza.

Consideriamo ad esempio un programma di spammer che raschia le pagine Web per gli indirizzi e-mail, quindi le invia per e-mail. Se hai una mentalità DI, in questo momento stai pensando ai servizi che incapsulerai dietro le interfacce e quali servizi verranno iniettati dove. Lascerò quel disegno come esercizio per il lettore. Se hai una mentalità FP, in questo momento stai pensando agli ingressi e alle uscite per il livello più basso di funzioni, come:

  • Immettere un indirizzo per la pagina Web, generare il testo di quella pagina.
  • Inserisci il testo di una pagina, genera un elenco di collegamenti da quella pagina.
  • Inserisci il testo di una pagina, visualizza un elenco di indirizzi e-mail su quella pagina.
  • Inserisci un elenco di indirizzi e-mail, genera un elenco di indirizzi e-mail con i duplicati rimossi.
  • Inserisci un indirizzo e-mail, invia un'e-mail di spam per quell'indirizzo.
  • Inserisci un'email di spam, invia i comandi SMTP per inviare l'e-mail.

Quando si pensa in termini di input e output, non ci sono dipendenze di funzioni, ma solo dipendenze di dati. Questo è ciò che li rende così facili da testare l'unità. Il tuo livello successivo organizza l'output di una funzione da inserire nell'input del successivo e può facilmente sostituire le varie implementazioni secondo necessità.

In un senso molto reale, la programmazione funzionale ti spinge naturalmente a invertire sempre le dipendenze delle tue funzioni, e quindi di solito non devi prendere alcuna misura speciale per farlo dopo il fatto. Quando lo fai, strumenti come le funzioni di ordine superiore, le chiusure e l'applicazione parziale rendono più facile eseguire con meno boilerplate.

Si noti che non sono le dipendenze stesse ad essere problematiche. Sono le dipendenze che indicano la direzione sbagliata. Il livello successivo può avere una funzione come:

processText = spamToSMTP . emailAddressToSpam . removeEmailDups . textToEmailAddresses

Va perfettamente bene che questo livello abbia dipendenze codificate in questo modo, perché il suo unico scopo è incollare insieme le funzioni del livello inferiore. Scambiare un'implementazione è semplice come creare una composizione diversa:

processTextFancy = spamToSMTP . emailAddressToFancySpam . removeEmailDups . textToEmailAddresses

Questa facile ricomposizione è resa possibile dalla mancanza di effetti collaterali. Le funzioni del livello inferiore sono completamente indipendenti l'una dall'altra. Il livello successivo può scegliere quale processTextviene effettivamente utilizzato in base a una configurazione utente:

actuallyUsedProcessText = if (config == "Fancy") then processTextFancy else processText

Ancora una volta, non è un problema perché tutte le dipendenze indicano un modo. Non è necessario invertire alcune dipendenze per farle puntare tutte allo stesso modo, perché le funzioni pure ci hanno già costretto a farlo.

Nota che potresti renderlo molto più accoppiato passando configal livello più basso invece di controllarlo in alto. FP non ti impedisce di farlo, ma tende a renderlo molto più fastidioso se ci provi.


3
"L'uso di effetti collaterali crea difficoltà simili. Se usi un effetto collaterale per alcune funzionalità, ma vuoi essere in grado di scambiarne l'implementazione, praticamente non hai altra scelta che iniettare quella dipendenza." Non credo che gli effetti collaterali abbiano nulla a che fare con questo. Se si desidera scambiare implementazioni in Haskell, è comunque necessario eseguire l'iniezione delle dipendenze . Desugar le classi di tipi e stai passando un'interfaccia come primo argomento di ogni funzione.
Doval,

2
Il nocciolo della questione è che quasi tutte le lingue ti costringono a referenziare hard code ad altri moduli di codice, quindi l'unico modo per scambiare le implementazioni è usare l'invio dinamico ovunque, e poi sei bloccato a risolvere le tue dipendenze in fase di esecuzione. Un sistema di moduli consentirebbe di esprimere il grafico delle dipendenze al momento del controllo del tipo.
Doval,

@ Doval - Grazie per i tuoi commenti interessanti e stimolanti. Potrei averti frainteso, ma ho ragione a dedurre dai tuoi commenti che se dovessi usare uno stile funzionale di programmazione su uno stile DI (nel senso tradizionale di C #), eviterei possibili frustrazioni di debug associate al runtime risoluzione delle dipendenze?
Matt Cashatt,

@MatthewPatrickCashatt Non è una questione di stile o paradigma, ma di caratteristiche del linguaggio. Se il linguaggio non supporta i moduli come cose di prima classe, dovrai fare un po 'di invio dinamico e iniezione di dipendenze per scambiare le implementazioni, perché non c'è modo di esprimere staticamente le dipendenze. Per dirla in modo un po 'diverso, se il tuo programma C # usa stringhe, ha una dipendenza codificata System.String. Un sistema di modulo ti permetterebbe di sostituire System.Stringcon una variabile in modo tale che la scelta dell'implementazione della stringa non sia codificata, ma ancora risolta al momento della compilazione.
Doval,

8

la Programmazione funzionale è una valida alternativa ai modelli di iniezione di dipendenza?

Questo mi sembra una domanda strana. Gli approcci di programmazione funzionale sono in gran parte tangenziali all'iniezione di dipendenza.

Certo, avere uno stato immutabile può spingerti a non "imbrogliare" avendo effetti collaterali o usando lo stato di classe come un contratto implicito tra le funzioni. Rende più esplicito il passaggio di dati, che suppongo sia la forma più elementare di iniezione di dipendenza. E il concetto di programmazione funzionale del passaggio di funzioni rende tutto molto più semplice.

Ma non rimuove le dipendenze. Le tue operazioni necessitano ancora di tutti i dati / operazioni di cui avevano bisogno quando il tuo stato era mutabile. E hai ancora bisogno di ottenere quelle dipendenze lì in qualche modo. Quindi non direi che gli approcci di programmazione funzionale sostituiscono il DI, quindi non esistono alternative.

Semmai, ti hanno appena mostrato quanto un cattivo codice OO possa creare dipendenze implicite di cui i programmatori raramente pensano.


Grazie ancora per aver contribuito alla conversazione, Telastyn. Come hai sottolineato, la mia domanda non è molto ben costruita (le mie parole), ma grazie al feedback qui sto iniziando a capire un po 'meglio ciò che mi sta scatenando nel cervello su tutto questo: siamo tutti d'accordo (Penso) che i test unitari possano essere un incubo senza DI. Sfortunatamente, l'uso di DI, specialmente con i contenitori IoC, può creare una nuova forma di incubo di debug grazie al fatto che risolve le dipendenze in fase di esecuzione. Simile a DI, FP semplifica i test delle unità, ma senza problemi di dipendenza dal runtime.
Matt Cashatt,

(continua dall'alto). . Questa è la mia attuale comprensione comunque. Per favore fatemi sapere se mi manca il segno. Non mi dispiace ammettere che sono un semplice mortale tra i giganti qui!
Matt Cashatt,

@MatthewPatrickCashatt - DI non implica necessariamente problemi di dipendenza dal runtime, che come noti sono orribili.
Telastyn,

7

La rapida risposta alla tua domanda è: No .

Ma come altri hanno affermato, la domanda sposa due concetti, in qualche modo non correlati.

Facciamo questo passo per passo.

DI risulta in uno stile non funzionale

Nel cuore della programmazione delle funzioni ci sono funzioni pure: funzioni che mappano l'input sull'output, in modo da ottenere sempre lo stesso output per un determinato input.

DI generalmente indica che l'unità non è più pura poiché l'output può variare a seconda dell'iniezione. Ad esempio, nella seguente funzione:

const bookSeats = ( seatCount, getBookedSeatCount ) => { ... }

getBookedSeatCount(una funzione) può variare producendo risultati diversi per lo stesso input dato. Questo rende anche bookSeatsimpuro.

Ci sono eccezioni per questo: è possibile iniettare uno dei due algoritmi di ordinamento che implementano la stessa mappatura input-output, sebbene usando algoritmi diversi. Ma queste sono eccezioni.

Un sistema non può essere puro

Il fatto che un sistema non possa essere puro è ugualmente ignorato come è affermato nelle fonti di programmazione funzionale.

Un sistema deve avere effetti collaterali con esempi ovvi che sono:

  • UI
  • Banca dati
  • API (nell'architettura client-server)

Quindi parte del tuo sistema deve comportare effetti collaterali e quella parte può anche coinvolgere uno stile imperativo o uno stile OO.

Il paradigma shell-core

Prendendo in prestito i termini del superbo discorso di Gary Bernhardt sui confini , una buona architettura di sistema (o modulo) includerà questi due livelli:

  • Nucleo
    • Funzioni pure
    • branching
    • Nessuna dipendenza
  • Conchiglia
    • Impure (effetti collaterali)
    • Nessuna ramificazione
    • dipendenze
    • Può essere imperativo, coinvolgere lo stile OO, ecc.

La chiave da asporto è "dividere" il sistema nella sua parte pura (il nucleo) e nella parte impura (il guscio).

Sebbene offra una soluzione (e una conclusione) leggermente imperfetta, questo articolo di Mark Seemann propone lo stesso concetto. L'implementazione di Haskell è particolarmente approfondita in quanto mostra che tutto può essere fatto usando FP.

DI e FP

L'impiego di DI è perfettamente ragionevole anche se la maggior parte dell'applicazione è pura. La chiave è confinare il DI all'interno della shell impura.

Un esempio saranno gli stub API: vuoi la vera API in produzione, ma usa gli stub nei test. L'adesione al modello shell-core aiuterà molto qui.

Conclusione

Quindi FP e DI non sono esattamente alternative. Probabilmente avrai entrambi nel tuo sistema e il consiglio è di assicurare la separazione tra la parte pura e impura del sistema, dove FP e DI risiedono rispettivamente.


Quando si fa riferimento al paradigma shell-core, come si potrebbe ottenere nessuna ramificazione nella shell? Posso pensare a molti esempi in cui un'applicazione dovrebbe fare una cosa impura o un'altra in base a un valore. Questa regola di non ramificazione è applicabile in lingue come Java?
jrahhali,

@jrahhali Per i dettagli, vedi la discussione di Gary Bernhardt (collegata nella risposta).
Izhaki,

un'altra serie relavent Seemann blog.ploeh.dk/2017/01/27/…
jk.

1

Dal punto di vista OOP le funzioni possono essere considerate come interfacce a metodo singolo.

L'interfaccia è un contratto più forte di una funzione.

Se stai usando un approccio funzionale e fai molti DI, rispetto all'utilizzo di un approccio OOP otterrai più candidati per ogni dipendenza.

void DoStuff(Func<DateTime> getDateTime) {}; //Anything that satisfies the signature can be injected.

vs

void DoStuff(IDateTimeProvider dateTimeProvider) {}; //Only types implementing the interface can be injected.

3
Qualsiasi classe può essere spostata per implementare l'interfaccia in modo che il "contratto più forte" non sia molto più forte. Ancora più importante, dare ad ogni funzione un tipo diverso rende impossibile eseguire la composizione delle funzioni al limite.
Doval,

Programmazione funzionale non significa "Programmazione con funzioni di ordine superiore", si riferisce a un concetto molto più ampio, le funzioni di ordine superiore sono solo una tecnica utile nel paradigma.
Jimmy Hoffa,
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.