Come trasformare un programma OO in uno funzionale?


26

Ho difficoltà a trovare risorse su come scrivere programmi in uno stile funzionale. L'argomento più avanzato che ho trovato discusso online è stato l'uso della tipizzazione strutturale per ridurre le gerarchie di classi; la maggior parte si occupa solo di come utilizzare la mappa / piega / riduci / etc per sostituire i loop imperativi.

Quello che mi piacerebbe davvero trovare è una discussione approfondita di un'implementazione OOP di un programma non banale, i suoi limiti e come riformattare in uno stile funzionale. Non solo un algoritmo o una struttura di dati, ma qualcosa con diversi ruoli e aspetti - forse un videogioco. A proposito, ho letto la Programmazione Funzionale del Mondo Reale di Tomas Petricek, ma sono rimasto a desiderare di più.


6
non penso sia possibile. devi riprogettare (e riscrivere) di nuovo tutto.
Bryan Chen

18
-1, questo post è distorto dal presupposto sbagliato che OOP e stile funzionale siano contrari. Questi sono principalmente concetti ortogonali e IMHO è un mito che non lo sono. "Funzionale" è più contrario a "Procedurale" ed entrambi gli stili possono essere utilizzati in combinazione con OOP.
Doc Brown,

11
@DocBrown, OOP fa troppo affidamento su uno stato mutevole. Gli oggetti apolidi non si adattano bene all'attuale pratica di progettazione OOP.
SK-logic

9
@ SK-logic: la chiave non sono oggetti stateless, ma oggetti immutabili. E anche quando gli oggetti sono mutabili, possono spesso essere utilizzati in una parte funzionale del sistema purché non vengano modificati all'interno del contesto dato. Inoltre, immagino che tu sappia che oggetti e chiusure sono intercambiabili. Quindi tutto ciò dimostra che OOP e "funzionale" non sono contrari.
Doc Brown,

12
@DocBrown: penso che i costrutti del linguaggio siano ortogonali, mentre le mentalità tendono a scontrarsi. Le persone OOP tendono a chiedersi "quali sono gli oggetti e come collaborano?"; le persone funzionali tendono a chiedersi "quali sono i miei dati e come voglio trasformarli?". Queste non sono le stesse domande e portano a risposte diverse. Penso anche che tu abbia letto male la domanda. Non è "sbavature OOP e regole FP, come faccio a sbarazzarmi di OOP?", È "Ottengo OOP e non ottengo FP, c'è un modo per trasformare un programma OOP in uno funzionale, quindi posso ottenere qualche intuizione? ".
Michael Shaw,

Risposte:


31

Definizione di programmazione funzionale

L'introduzione a La gioia di Clojure dice quanto segue:

La programmazione funzionale è uno di quei termini informatici che ha una definizione amorfa. Se chiedi a 100 programmatori la loro definizione, probabilmente riceverai 100 risposte diverse ...

La programmazione funzionale riguarda e facilita l'applicazione e la composizione delle funzioni ... Perché un linguaggio sia considerato funzionale, la sua nozione di funzione deve essere di prima classe. Le funzioni di prima classe possono essere archiviate, passate e restituite come qualsiasi altro dato. Al di là di questo concetto chiave, [le definizioni di FP potrebbero includere] purezza, immutabilità, ricorsione, pigrizia e trasparenza referenziale.

Programmazione in Scala 2a edizione p. 10 ha la seguente definizione:

La programmazione funzionale è guidata da due idee principali. La prima idea è che le funzioni sono valori di prima classe ... Puoi passare funzioni come argomenti ad altre funzioni, restituirle come risultati da funzioni o memorizzarle in variabili ...

La seconda idea principale della programmazione funzionale è che le operazioni di un programma dovrebbero mappare i valori di input sui valori di output anziché modificare i dati in atto.

Se accettiamo la prima definizione, l'unica cosa che devi fare per rendere il tuo codice "funzionale" è capovolgere i tuoi loop. La seconda definizione include l'immutabilità.

Funzioni di prima classe

Immagina di ottenere attualmente un Elenco di passeggeri dal tuo oggetto Bus e di iterare su di esso diminuendo il conto bancario di ciascun passeggero in base all'importo della tariffa del bus. Il modo funzionale per eseguire questa stessa azione sarebbe di avere un metodo su Bus, forse chiamato forEachPassenger che accetta una funzione di un argomento. Quindi Bus verrebbe ripetuto sui propri passeggeri, tuttavia ciò è meglio realizzato e il codice cliente che addebita la tariffa per la corsa verrebbe messo in funzione e passato a per ogni passeggero. Ecco! Stai utilizzando la programmazione funzionale.

Imperativo:

for (Passenger p : Bus.getPassengers()) {
    p.debit(fare);
}

Funzionale (usando una funzione anonima o "lambda" in Scala):

myBus = myBus.forEachPassenger(p:Passenger -> { p.debit(fare) })

Versione scala più zuccherina:

myBus = myBus.forEachPassenger(_.debit(fare))

Funzioni non di prima classe

Se la tua lingua non supporta funzioni di prima classe, questo può diventare molto brutto. In Java 7 o versioni precedenti, devi fornire un'interfaccia "Oggetto funzionale" come questa:

// Java 8 has java.util.function.Consumer, but in earlier
// versions you have to roll your own:
public interface Consumer<T> {
    public void accept(T t);
}

Quindi la classe Bus fornisce un iteratore interno:

public void forEachPassenger(Consumer<Passenger> c) {
    for (Passenger p : passengers) {
        c.accept(p);
    }
}

Infine, si passa un oggetto funzione anonimo al bus:

// Java 8 has syntactic sugar to make this look more like
// the Scala solution, but earlier versions require manually
// instantiating a "Function Object," in this case, a
// Consumer:
Bus.forEachPassenger(new Consumer<Passenger>() {
    @Override
    public void accept(final Passenger p) {
        p.debit(fare);
    }
}

Java 8 consente alle variabili locali di acquisire l'ambito di una funzione anonima, ma nelle versioni precedenti, tali varianti devono essere dichiarate definitive. Per ovviare a questo problema potrebbe essere necessario creare una classe wrapper MutableReference. Ecco una classe specifica per intero che ti consente di aggiungere un contatore di cicli al codice sopra:

public static class MutableIntWrapper {
    private int i;
    private MutableIntWrapper(int in) { i = in; }
    public static MutableIntWrapper ofZero() {
        return new MutableIntWrapper(0);
    }
    public int value() { return i; }
    public void increment() { i++; }
}

final MutableIntWrapper count = MutableIntWrapper.ofZero();
Bus.forEachPassenger(new Consumer<Passenger>() {
    @Override
    public void accept(final Passenger p) {
        p.debit(fare);
        count.increment();
    }
}

System.out.println(count.value());

Anche con questa bruttezza, a volte è utile eliminare la logica complicata e ripetuta dai loop diffusi nel programma fornendo un iteratore interno.

Questa bruttezza è stata risolta in Java 8, ma la gestione delle eccezioni verificate all'interno di una funzione di prima classe è ancora davvero brutta e Java porta ancora il presupposto della mutabilità in tutte le sue raccolte. Il che ci porta agli altri obiettivi spesso associati a FP:

Immutabilità

L'articolo 13 di Josh Bloch è "Preferisci l'immutabilità". Nonostante i discorsi comuni della spazzatura al contrario, OOP può essere fatto con oggetti immutabili, e così facendo lo rende molto meglio. Ad esempio, String in Java è immutabile. StringBuffer, OTOH deve essere modificabile per creare una stringa immutabile. Alcune attività, come lavorare con i buffer, richiedono intrinsecamente la mutabilità.

Purezza

Ogni funzione dovrebbe almeno essere memorizzabile - se le dai gli stessi parametri di input (e non dovrebbe avere input oltre ai suoi argomenti reali), dovrebbe produrre lo stesso output ogni volta senza causare "effetti collaterali" come cambiare lo stato globale, eseguendo I / O, o generando eccezioni.

È stato detto che nella Programmazione Funzionale "di solito è necessario un po 'di male per portare a termine il lavoro". 100% di purezza non è generalmente l'obiettivo. Ridurre al minimo gli effetti collaterali è.

Conclusione

In realtà, tra tutte le idee sopra, l'immutabilità è stata la più grande vittoria singola in termini di applicazioni pratiche per semplificare il mio codice - OOP o FP. Passare le funzioni agli iteratori è la seconda vittoria più grande. La documentazione di Java 8 Lambdas ha la migliore spiegazione del perché. La ricorsione è ottima per l'elaborazione degli alberi. La pigrizia ti consente di lavorare con infinite collezioni.

Se ti piace la JVM, ti consiglio di dare un'occhiata a Scala e Clojure. Entrambe sono interpretazioni approfondite della programmazione funzionale. Scala è sicuro per i tipi con una sintassi un po 'tipo C, sebbene abbia davvero la stessa sintassi in comune con Haskell come con C. Clojure non è sicuro per i tipi ed è un Lisp. Di recente ho pubblicato un confronto tra Java, Scala e Clojure per quanto riguarda uno specifico problema di refactoring. Il confronto tra Logan Campbell usando il Gioco della vita include anche Haskell e il tipo Clojure.

PS

Jimmy Hoffa ha sottolineato che la mia classe di autobus è mutevole. Piuttosto che correggere l'originale, penso che ciò dimostrerà esattamente il tipo di refactoring di cui questa domanda si occupa. Questo può essere risolto rendendo ogni metodo su Bus una fabbrica per produrre un nuovo Bus, ogni metodo su Passeggero una fabbrica per produrre un nuovo Passeggero. Quindi ho aggiunto un tipo restituito a tutto ciò che significa che copierò java.util.function.Function di Java 8 invece dell'interfaccia Consumer:

public interface Function<T,R> {
    public R apply(T t);
    // Note: I'm leaving out Java 8's compose() method here for simplicity
}

Quindi sul bus:

public Bus mapPassengers(Function<Passenger,Passenger> c) {
    // I have to use a mutable collection internally because Java
    // does not have immutable collections that return modified copies
    // of themselves the way the Clojure and Scala collections do.
    List<Passenger> newPassengers = new ArrayList(passengers.size());
    for (Passenger p : passengers) {
        newPassengers.add(c.apply(p));
    }
    return Bus.of(driver, Collections.unmodifiableList(passengers));
}

Infine, l'oggetto funzione anonimo restituisce lo stato delle cose modificato (un nuovo autobus con nuovi passeggeri). Ciò presuppone che p.debit () ora restituisca un nuovo passeggero immutabile con meno soldi dell'originale:

Bus b = b.mapPassengers(new Function<Passenger,Passenger>() {
    @Override
    public Passenger apply(final Passenger p) {
        return p.debit(fare);
    }
}

Spero che ora tu possa prendere la tua decisione su quanto funzionale vuoi rendere il tuo linguaggio imperativo e decidere se sarebbe meglio ridisegnare il tuo progetto usando un linguaggio funzionale. In Scala o Clojure, le collezioni e le altre API sono progettate per semplificare la programmazione funzionale. Entrambi hanno un'ottima interoperabilità Java, quindi puoi mescolare e abbinare le lingue. In effetti, per l'interoperabilità di Java, Scala compila le sue funzioni di prima classe in classi anonime che sono quasi compatibili con le interfacce funzionali di Java 8. Puoi leggere i dettagli in Scala nella setta Depth. 1.3.2 .


Apprezzo lo sforzo, l'organizzazione e la comunicazione chiara in questa risposta; ma devo prendere un piccolo problema con alcuni dei tecnici. Uno dei tasti come menzionato nella parte superiore è la composizione delle funzioni, questo risale al motivo per cui l'incapsulamento delle funzioni all'interno degli oggetti non ha scopo: Se una funzione è all'interno di un oggetto, deve essere lì per agire su quell'oggetto; e se agisce su quell'oggetto, deve cambiare i suoi interni. Ora perdonerò che non tutti richiedono trasparenza referenziale o immutabilità, ma se cambia l'oggetto sul posto non è più necessario restituirlo
Jimmy Hoffa,

E non appena una funzione non restituisce un valore, improvvisamente la funzione non può essere composta con gli altri e perdi tutta l'astrazione della composizione funzionale. Si potrebbe fare in modo che la funzione cambi l'oggetto in posizione e quindi restituisca l'oggetto, ma se lo fa, perché non fare semplicemente in modo che la funzione prenda l'oggetto come parametro e lo liberi dai confini del suo oggetto genitore? Liberato dall'oggetto genitore sarà in grado di funzionare anche su altri tipi, che è un'altra parte importante di FP che ti manca: digitare l'astrazione. il tuo forEachPasenger funziona solo contro i passeggeri ...
Jimmy Hoffa,

1
Il motivo per cui si astraggono le cose da mappare e ridurre e queste funzioni non sono destinate a contenere oggetti è che possono essere utilizzate su una miriade di tipi attraverso il polimorfismo parametrico. È la conflagrazione di queste varie astrazioni che non trovi nei linguaggi OOP che definisce veramente FP e lo spinge ad avere valore. Non è che la pigrizia, la trasparenza referenziale, l'immutabilità o persino il sistema di tipi HM siano necessari per creare FP, quelle cose sono piuttosto effetti collaterali della creazione di linguaggi proposti per la composizione funzionale in cui le funzioni possono astrarre sui tipi in generale
Jimmy Hoffa,

@JimmyHoffa Hai fatto una critica molto chiara al mio esempio. Sono stato sedotto nella mutabilità dall'interfaccia Consumer di Java8. Inoltre, la definizione chouser / fogus di FP non includeva l'immutabilità e in seguito ho aggiunto la definizione di Odersky / Spoon / Venners. Ho lasciato l'esempio originale, ma ho aggiunto una nuova versione immutabile in una sezione "PS" in fondo. È brutto. Ma penso che dimostri le funzioni che agiscono sugli oggetti per produrre nuovi oggetti piuttosto che cambiare gli interni degli originali. Ottimo commento!
GlenPeterson,

1
Questa conversazione continua su The Whiteboard: chat.stackexchange.com/transcript/message/11702383#11702383
GlenPeterson,

12

Ho esperienza personale "realizzando" questo. Alla fine, non ho trovato qualcosa che fosse puramente funzionale, ma ho trovato qualcosa di cui sono felice. Ecco come l'ho fatto:

  • Converti tutto lo stato esterno in un parametro della funzione. EG: se il metodo di un oggetto viene modificato x, fallo in modo che al metodo venga passato un xinvece di chiamare this.x.
  • Rimuovi il comportamento dagli oggetti.
    1. Rendere i dati dell'oggetto accessibili al pubblico
    2. Converti tutti i metodi in funzioni che l'oggetto chiama.
    3. Il codice client che chiama l'oggetto chiama la nuova funzione passando i dati dell'oggetto. EG: convertire x.methodThatModifiesTheFooVar()infooFn(x.foo)
    4. Rimuovere il metodo originale dall'oggetto
  • Sostituire il maggior numero di cicli iterativi, come si può con una maggiore funzioni di ordine piace map, reduce, filter, etc.

Non riuscivo a liberarmi dello stato mutevole. Era semplicemente troppo non idiomatico nella mia lingua (JavaScript). Ma, facendo passare e / o restituendo tutto lo stato, è possibile testare ogni funzione. Ciò è diverso da OOP in cui l'impostazione dello stato richiederebbe troppo tempo o la separazione delle dipendenze spesso richiede prima di modificare il codice di produzione.

Inoltre, potrei sbagliarmi sulla definizione, ma penso che le mie funzioni siano referenzialmente trasparenti: le mie funzioni avranno lo stesso effetto dato lo stesso input.

modificare

Come puoi vedere qui , non è possibile creare un oggetto veramente immutabile in JavaScript. Se sei diligente e hai il controllo di chi chiama il tuo codice, puoi farlo creando sempre un nuovo oggetto invece di mutare quello attuale. Non valeva la pena per me.

Ma se stai usando Java puoi usare queste tecniche per rendere immutabili le tue classi.


+1 A seconda di cosa esattamente stai cercando di fare, questo è probabilmente il massimo che puoi fare senza apportare modifiche al design che andrebbero oltre il semplice "refactoring".
Evicatos,

@Evicatos: Non so, se JavaScript avesse un supporto migliore per lo stato immutabile, penso che la mia soluzione sarebbe funzionale come si otterrebbe in un linguaggio funzionale dinamico come Clojure. Qual è un esempio di qualcosa che richiederebbe qualcosa oltre al semplice refactoring?
Daniel Kaplan,

Penso che sbarazzarsi dello stato mutevole si qualificherebbe. Non penso sia solo una questione di migliore supporto nel linguaggio, penso che passare da mutevole a immutabile richiederà fondamentalmente sempre cambiamenti architetturali fondamentali che essenzialmente costituiscono una riscrittura. Ymmv a seconda della tua definizione di refactoring però.
Evicatos,

@Evicatos vedi la mia modifica
Daniel Kaplan,

1
@tieTYT sì, è triste che JS sia così mutevole, ma almeno Clojure può compilare in JavaScript: github.com/clojure/clojurescript
GlenPeterson,

3

Non penso che sia davvero possibile riformattare completamente il programma: dovresti riprogettare e reimplementare il paradigma corretto.

Ho visto il refactoring del codice definito come "una tecnica disciplinata per ristrutturare un corpo di codice esistente, alterando la sua struttura interna senza cambiare il suo comportamento esterno".

Potresti rendere alcune cose più funzionali, ma al suo interno hai ancora un programma orientato agli oggetti. Non puoi semplicemente cambiare piccoli frammenti per adattarlo a un diverso paradigma.


Aggiungerei che un buon primo segno è quello di lottare per la trasparenza referenziale. Una volta ottenuto questo, ottieni circa il 50% dei vantaggi della programmazione funzionale.
Daniel Gratzer,

3

Penso che questa serie di articoli sia esattamente quello che vuoi:

Retrogames puramente funzionali

http://prog21.dadgum.com/23.html Parte 1

http://prog21.dadgum.com/24.html Parte 2

http://prog21.dadgum.com/25.html Parte 3

http://prog21.dadgum.com/26.html Parte 4

http://prog21.dadgum.com/37.html Follow-up

Il riassunto è:

L'autore suggerisce un ciclo principale con effetti collaterali (gli effetti collaterali devono accadere da qualche parte, giusto?) E la maggior parte delle funzioni restituisce piccoli record immutabili che descrivono in dettaglio come hanno cambiato lo stato del gioco.

Naturalmente quando scrivi un programma del mondo reale mescolerai e abbinerai diversi stili di programmazione, usando ognuno di quelli che ti aiutano di più. Tuttavia è una buona esperienza di apprendimento provare a scrivere un programma nel modo più funzionale / immutabile e anche a scriverlo nel modo più semplice, usando solo variabili globali :-) (fallo come esperimento, non in produzione, per favore)


2

Probabilmente dovresti capovolgere tutto il codice poiché OOP e FP hanno due approcci opposti all'organizzazione del codice.

OOP organizza il codice attorno a tipi (classi): classi diverse possono implementare la stessa operazione (un metodo con la stessa firma). Di conseguenza, OOP è più appropriato quando l'insieme di operazioni non cambia molto mentre è possibile aggiungere nuovi tipi molto spesso. Si consideri ad esempio una libreria GUI in cui ogni widget ha un insieme fisso di metodi ( hide(), show(), paint(), move(), e così via), ma nuovi widget possono essere aggiunti come la biblioteca è esteso. In OOP è facile aggiungere un nuovo tipo (per una data interfaccia): è sufficiente aggiungere una nuova classe e implementare tutti i suoi metodi (modifica del codice locale). D'altro canto, l'aggiunta di una nuova operazione (metodo) a un'interfaccia può richiedere la modifica di tutte le classi che implementano tale interfaccia (anche se l'ereditarietà può ridurre la quantità di lavoro).

FP organizza il codice attorno alle operazioni (funzioni): ogni funzione implementa alcune operazioni che possono trattare tipi diversi in modi diversi. Questo di solito si ottiene inviando il tipo tramite pattern matching o qualche altro meccanismo. Di conseguenza, FP è più appropriato quando l'insieme di tipi è stabile e nuove operazioni vengono aggiunte più spesso. Prendi ad esempio un set fisso di formati di immagine (GIF, JPEG, ecc.) E alcuni algoritmi che desideri implementare. Ogni algoritmo può essere implementato da una funzione che si comporta diversamente in base al tipo di immagine. L'aggiunta di un nuovo algoritmo è semplice perché è sufficiente implementare una nuova funzione (modifica del codice locale). L'aggiunta di un nuovo formato (tipo) richiede la modifica di tutte le funzioni implementate finora per supportarlo (modifica non locale).

In conclusione: OOP e FP sono sostanzialmente diversi nel modo in cui organizzano il codice, e cambiare un design OOP in un design FP implicherebbe cambiare tutto il codice per riflettere questo. Può essere un esercizio interessante, però. Vedi anche queste note di lettura del libro SICP citato da Mikemay, in particolare le diapositive da 13.1.5 a 13.1.10.

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.