Wie kann man ein OO-Programm in ein funktionales umgestalten?

26

Ich habe Schwierigkeiten, Ressourcen zum Schreiben von Programmen in einem funktionalen Stil zu finden. Das am weitesten fortgeschrittene Thema, das ich online finden konnte, war die Verwendung von struktureller Typisierung, um Klassenhierarchien zu reduzieren. Die meisten beschäftigen sich nur mit der Verwendung von map / fold / reduction / etc, um imperative Schleifen zu ersetzen.

Was ich wirklich gerne finden würde, ist eine eingehende Diskussion über die OOP-Implementierung eines nicht trivialen Programms, seine Einschränkungen und wie man es in einem funktionalen Stil umgestaltet. Nicht nur ein Algorithmus oder eine Datenstruktur, sondern etwas mit verschiedenen Rollen und Aspekten - vielleicht ein Videospiel. Übrigens habe ich Real-World Functional Programming von Tomas Petricek gelesen, aber ich möchte noch mehr.

Asik
quelle
6
Ich glaube nicht, dass es möglich ist. Sie müssen alles neu entwerfen (und neu schreiben).
Bryan Chen
18
-1, dieser Beitrag ist durch die falsche Annahme voreingenommen, dass OOP und funktionaler Stil entgegengesetzt sind. Das sind meist orthogonale Konzepte, und meiner Meinung nach ist es ein Mythos, dass sie es nicht sind. "Funktional" steht eher im Gegensatz zu "Prozedural", und beide Stile können in Verbindung mit OOP verwendet werden.
Doc Brown
11
@DocBrown, OOP ist zu stark auf einen veränderlichen Zustand angewiesen. Zustandslose Objekte passen nicht gut in die aktuelle OOP-Entwurfspraxis.
SK-logic
9
@ SK-Logik: Die Schlüssel sind keine zustandslosen Objekte, sondern unveränderliche Objekte. Und selbst wenn Objekte veränderlich sind, können sie häufig in einem funktionalen Teil des Systems verwendet werden, solange sie nicht im gegebenen Kontext geändert werden. Außerdem wissen Sie, dass Gegenstände und Verschlüsse austauschbar sind. Das alles zeigt also, dass OOP und "funktional" nicht gegensätzlich sind.
Doc Brown
12
@DocBrown: Ich denke, die Sprachkonstrukte sind orthogonal, während die Denkweisen dazu neigen, sich zu überschneiden. OOP-Leute neigen dazu zu fragen: "Was sind die Objekte und wie arbeiten sie zusammen?"; Funktionale Personen neigen dazu zu fragen: "Was sind meine Daten und wie möchte ich sie umwandeln?". Das sind nicht die gleichen Fragen, und sie führen zu unterschiedlichen Antworten. Ich denke auch, dass Sie die Frage falsch verstanden haben. Es geht nicht um "OOP-Sabber und FP-Regeln, wie werde ich OOP los?", Sondern um "Ich erhalte OOP und ich erhalte kein FP". Gibt es eine Möglichkeit, ein OOP-Programm in ein funktionierendes umzuwandeln, damit ich es bekommen kann? Einsicht? "
Michael Shaw

Antworten:

31

Definition der funktionalen Programmierung

In der Einführung zu The Joy of Clojure heißt es:

Funktionale Programmierung ist einer der Computerausdrücke, die eine amorphe Definition haben. Wenn Sie 100 Programmierer nach ihrer Definition fragen, erhalten Sie wahrscheinlich 100 verschiedene Antworten ...

Funktionale Programmierung betrifft und erleichtert die Anwendung und Zusammensetzung von Funktionen ... Damit eine Sprache als funktional angesehen werden kann, muss ihr Funktionsbegriff erstklassig sein. Erstklassige Funktionen können wie alle anderen Daten gespeichert, übergeben und zurückgegeben werden. Über dieses Kernkonzept hinaus können [Definitionen von FP] Reinheit, Unveränderlichkeit, Rekursion, Faulheit und referenzielle Transparenz umfassen.

Programmierung in Scala 2nd Edition p. 10 hat die folgende Definition:

Die funktionale Programmierung basiert auf zwei Hauptideen. Die erste Idee ist, dass Funktionen erstklassige Werte sind ... Sie können Funktionen als Argumente an andere Funktionen übergeben, sie als Ergebnisse von Funktionen zurückgeben oder sie in Variablen speichern ...

Die zweite Hauptidee der funktionalen Programmierung besteht darin, dass die Operationen eines Programms Eingabewerte auf Ausgabewerte abbilden sollten, anstatt Daten an Ort und Stelle zu ändern.

Wenn wir die erste Definition akzeptieren, müssen Sie nur Ihre Schleifen umdrehen, um Ihren Code "funktionsfähig" zu machen. Die zweite Definition beinhaltet die Unveränderlichkeit.

Erstklassige Funktionen

Stellen Sie sich vor, Sie erhalten derzeit eine Passagierliste von Ihrem Busobjekt und iterieren darüber, wobei Sie das Bankkonto jedes Passagiers um den Betrag des Busfahrpreises verringern. Die funktionale Möglichkeit, diese Aktion auszuführen, besteht darin, eine Methode auf dem Bus zu haben, die möglicherweise forEachPassenger heißt und eine Funktion eines Arguments übernimmt. Dann würde Bus über seine Fahrgäste iterieren, was jedoch am besten erreicht wird, und Ihr Kundencode, der den Fahrpreis berechnet, würde in eine Funktion gestellt und an forEachPassenger weitergeleitet. Voila! Sie verwenden funktionale Programmierung.

Imperativ:

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

Funktional (unter Verwendung einer anonymen Funktion oder "Lambda" in Scala):

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

Mehr zuckerhaltige Scala-Version:

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

Nicht erstklassige Funktionen

Wenn Ihre Sprache keine erstklassigen Funktionen unterstützt, kann dies sehr hässlich werden. In Java 7 oder früher müssen Sie eine "Functional Object" -Schnittstelle wie die folgende bereitstellen:

// 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);
}

Dann stellt die Bus-Klasse einen internen Iterator bereit:

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

Zuletzt übergeben Sie ein anonymes Funktionsobjekt an den 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);
    }
}

Mit Java 8 können lokale Variablen im Rahmen einer anonymen Funktion erfasst werden. In früheren Versionen müssen solche Variablen jedoch als final deklariert werden. Um dies zu umgehen, müssen Sie möglicherweise eine MutableReference-Wrapper-Klasse erstellen. Hier ist eine ganzzahlspezifische Klasse, mit der Sie dem obigen Code einen Schleifenzähler hinzufügen können:

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());

Trotz dieser Hässlichkeit ist es manchmal vorteilhaft, komplizierte und wiederholte Logik aus Schleifen zu entfernen, die in Ihrem Programm verteilt sind, indem ein interner Iterator bereitgestellt wird.

Diese Hässlichkeit wurde in Java 8 behoben, aber die Behandlung von überprüften Ausnahmen in einer erstklassigen Funktion ist immer noch sehr hässlich, und Java geht in all seinen Sammlungen immer noch von Mutabilität aus. Was uns zu den anderen Zielen bringt, die oft mit FP verbunden sind:

Unveränderlichkeit

Josh Blochs Punkt 13 lautet "Prefer Immutability". Trotz alltäglicher gegenteiliger Abfälle kann OOP mit unveränderlichen Objekten durchgeführt werden, und dies macht es viel besser. Beispielsweise ist String in Java unveränderlich. StringBuffer, OTOH muss veränderbar sein, um einen unveränderlichen String zu erstellen. Einige Aufgaben, z. B. die Arbeit mit Puffern, erfordern von Natur aus eine gewisse Veränderbarkeit.

Reinheit

Jede Funktion sollte zumindest einprägsam sein - wenn Sie ihr dieselben Eingabeparameter geben (und sie sollte außer den eigentlichen Argumenten keine Eingabe haben), sollte sie jedes Mal dieselbe Ausgabe erzeugen, ohne "Nebenwirkungen" wie das Ändern des globalen Zustands zu verursachen und I auszuführen / O oder Ausnahmen werfen.

Es wurde gesagt, dass in der funktionalen Programmierung "etwas Böses erforderlich ist, um die Arbeit zu erledigen". 100% ige Reinheit ist in der Regel nicht das Ziel. Minimierung von Nebenwirkungen ist.

Fazit

Von allen oben genannten Ideen war die Unveränderlichkeit der größte Gewinn in Bezug auf praktische Anwendungen zur Vereinfachung meines Codes - ob OOP oder FP. Die Weitergabe von Funktionen an Iteratoren ist der zweitgrößte Gewinn. Die Dokumentation zu Java 8 Lambdas bietet die beste Erklärung dafür. Rekursion ist ideal für die Verarbeitung von Bäumen. Mit Laziness können Sie mit unendlichen Sammlungen arbeiten.

Wenn Sie die JVM mögen, empfehle ich Ihnen einen Blick auf Scala und Clojure. Beide sind aufschlussreiche Interpretationen der funktionalen Programmierung. Scala ist typsicher mit etwas C-ähnlicher Syntax, obwohl es mit Haskell wirklich so viel Syntax gemeinsam hat wie mit C. Clojure ist nicht typsicher und es ist ein Lisp. Ich habe kürzlich einen Vergleich von Java, Scala und Clojure in Bezug auf ein bestimmtes Refactoring-Problem veröffentlicht. Logan Campbells Vergleich mit dem Game of Life beinhaltet auch Haskell und Clojure.

PS

Jimmy Hoffa wies darauf hin, dass meine Busklasse veränderlich ist. Anstatt das Original zu reparieren, denke ich, wird dies genau die Art der Umgestaltung demonstrieren, um die es in dieser Frage geht. Dies kann behoben werden, indem jede Methode im Bus zu einer Fabrik gemacht wird, um einen neuen Bus zu produzieren, und jede Methode im Passagier zu einer Fabrik, um einen neuen Passagier zu produzieren. Daher habe ich zu allem einen Rückgabetyp hinzugefügt, was bedeutet, dass ich die java.util.function.Function von Java 8 anstelle der Consumer-Schnittstelle kopiere:

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

Dann im 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));
}

Schließlich gibt das anonyme Funktionsobjekt den geänderten Zustand der Dinge zurück (einen neuen Bus mit neuen Fahrgästen). Dies setzt voraus, dass p.debit () jetzt einen neuen unveränderlichen Passagier mit weniger Geld als das Original zurückgibt:

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

Hoffentlich können Sie jetzt selbst entscheiden, wie funktional Ihre imperative Sprache sein soll, und entscheiden, ob es besser ist, Ihr Projekt mit einer funktionalen Sprache neu zu gestalten. In Scala oder Clojure wurden Sammlungen und andere APIs entwickelt, um die funktionale Programmierung zu vereinfachen. Beide haben ein sehr gutes Java-Interop, sodass Sie Sprachen mischen und zuordnen können. Aus Gründen der Java-Interoperabilität kompiliert Scala seine First-Class-Funktionen in anonyme Klassen, die mit den Java 8-Funktionsschnittstellen nahezu kompatibel sind. Über die Details können Sie in der Scala in der Sektion Tiefe lesen . 1.3.2 .

GlenPeterson
quelle
Ich schätze den Aufwand, die Organisation und die klare Kommunikation in dieser Antwort. aber ich muss ein kleines Problem mit einigen technischen Details haben. Einer der Schlüssel, der oben erwähnt wurde, ist die Zusammensetzung von Funktionen. Dies geht darauf zurück, warum die weitgehende Verkapselung von Funktionen in Objekten keinen Sinn ergibt: Befindet sich eine Funktion in einem Objekt, muss sie vorhanden sein, um auf dieses Objekt einwirken zu können. und wenn es auf dieses Objekt einwirkt, muss es seine Interna ändern. Jetzt werde ich vergeben, dass nicht jeder referenzielle Transparenz oder Unveränderlichkeit erfordert, aber wenn es das Objekt an der richtigen Stelle ändert, muss es nicht mehr zurückgegeben werden
Jimmy Hoffa
Und sobald eine Funktion keinen Wert zurückgibt, kann die Funktion plötzlich nicht mehr mit anderen zusammengesetzt werden, und Sie verlieren die gesamte Abstraktion der funktionalen Zusammensetzung. Sie könnten das Objekt durch die Funktion ändern und dann das Objekt zurückgeben lassen, aber wenn dies der Fall ist, warum sollte die Funktion dann nicht einfach das Objekt als Parameter verwenden und es von den Begrenzungen des übergeordneten Objekts befreien? Wenn es vom übergeordneten Objekt befreit ist, kann es auch mit anderen Typen arbeiten . Dies ist ein weiterer wichtiger Teil von FP, den Sie vermissen: Typabstraktion. Ihr forEachPasenger arbeitet nur gegen Passagiere ...
Jimmy Hoffa
1
Der Grund, warum Sie zuzuordnende und zu reduzierende Objekte abstrahieren und diese Funktionen nicht an das Enthalten von Objekten gebunden sind, besteht darin, dass sie durch parametrischen Polymorphismus für eine Vielzahl von Typen verwendet werden können. Es ist die Verschmelzung dieser verschiedenen Abstraktionen, die Sie in den OOP-Sprachen nicht finden, die FP wirklich definiert und wertschöpfend macht. Es ist nicht , dass Faulheit, referentielle Transparenz, Unveränderlichkeit, oder auch die HM - Typ - System notwendig FP zu schaffen, diese Dinge sind eher Nebenwirkungen der für die funktionale Zusammensetzung purposed Sprachen schaffen , in dem Funktionen abstrahieren kann über Typen im Allgemeinen
Jimmy Hoffa
@JimmyHoffa Du hast mein Beispiel sehr fair kritisiert. Die Java8-Consumer-Oberfläche hat mich in die Wandlungsfähigkeit verführt. Außerdem enthielt die Chouser / Fogus-Definition von FP keine Unveränderlichkeit, und ich fügte später die Odersky / Spoon / Venners-Definition hinzu. Ich habe das ursprüngliche Beispiel verlassen, aber unten unter "PS" eine neue, unveränderbare Version hinzugefügt. Es ist hässlich. Aber ich denke, es demonstriert Funktionen, die auf Objekte einwirken, um neue Objekte zu erzeugen, anstatt die Interna der Originale zu ändern. Großartiger Kommentar!
GlenPeterson
1
Diese Unterhaltung wird auf dem Whiteboard fortgesetzt: chat.stackexchange.com/transcript/message/11702383#11702383
GlenPeterson 16.10.13
12

Ich habe persönliche Erfahrung damit. Am Ende habe ich nichts gefunden, was rein funktional ist, aber ich habe etwas gefunden, mit dem ich zufrieden bin. So habe ich es gemacht:

  • Konvertiert alle externen Zustände in einen Parameter der Funktion. ZB: Wenn sich die Methode eines Objekts ändert x, stellen Sie dies so ein, dass die Methode übergeben wird, xanstatt aufzurufen this.x.
  • Entfernen Sie das Verhalten von Objekten.
    1. Machen Sie die Daten des Objekts öffentlich zugänglich
    2. Konvertieren Sie alle Methoden in Funktionen, die das Objekt aufruft.
    3. Lassen Sie den Client-Code, der das Objekt aufruft, die neue Funktion aufrufen, indem Sie die Objektdaten übergeben. EG: Konvertieren x.methodThatModifiesTheFooVar()infooFn(x.foo)
    4. Entfernen Sie die ursprüngliche Methode aus dem Objekt
  • Ersetzen Sie so viele Iterationsschleifen , wie Sie können mit Funktionen höherer Ordnung wie map, reduce, filterusw.

Ich konnte den veränderlichen Zustand nicht loswerden. In meiner Sprache (JavaScript) war es einfach zu nicht idiomatisch. Indem alle Zustände übergeben und / oder zurückgegeben werden, kann jede Funktion getestet werden. Dies unterscheidet sich von OOP, bei dem das Einrichten des Status zu lange dauern würde oder das Trennen von Abhängigkeiten häufig zuerst das Ändern des Produktionscodes erfordert.

Ich könnte mich auch bei der Definition irren, denke aber, dass meine Funktionen referenziell transparent sind: Meine Funktionen haben bei gleicher Eingabe den gleichen Effekt.

Bearbeiten

Wie Sie hier sehen können , ist es nicht möglich, ein wirklich unveränderliches Objekt in JavaScript zu erstellen. Wenn Sie fleißig sind und die Kontrolle darüber haben, wer Ihren Code aufruft, können Sie dies tun, indem Sie immer ein neues Objekt erstellen, anstatt das aktuelle zu ändern. Die Mühe hat sich für mich nicht gelohnt.

Wenn Sie Java verwenden , können Sie diese Techniken verwenden , um Ihre Klassen unveränderlich zu machen.

Daniel Kaplan
quelle
+1 Abhängig davon, was genau Sie versuchen, ist dies wahrscheinlich so weit, wie Sie es wirklich können, ohne Designänderungen vorzunehmen, die weit über das reine "Refactoring" hinausgehen würden.
Evicatos
@Evicatos: Ich weiß nicht, ob JavaScript den unveränderlichen Zustand besser unterstützt, ich denke, meine Lösung wäre so funktional wie in einer dynamischen funktionalen Sprache wie Clojure. Was ist ein Beispiel für etwas, das etwas erfordert, das über das reine Umgestalten hinausgeht?
Daniel Kaplan
Ich denke, den veränderlichen Zustand loszuwerden, würde sich qualifizieren. Ich denke nicht, dass es nur eine Frage der besseren Unterstützung in der Sprache ist, ich denke, dass der Übergang von veränderlich zu unveränderlich grundsätzlich immer grundlegende architektonische Änderungen erfordert, die im Wesentlichen eine Neufassung darstellen. Ymmv abhängig von Ihrer Definition des Refactorings.
Evicatos
@Evicatos siehe meine Bearbeitung
Daniel Kaplan
1
@tieTYT Ja, das ist traurig darüber, dass JS so wandelbar ist, aber zumindest kann Clojure JavaScript kompilieren: github.com/clojure/clojurescript
GlenPeterson
3

Ich denke nicht, dass es wirklich möglich ist, das Programm komplett umzugestalten - Sie müssten das richtige Paradigma umgestalten und neu implementieren.

Ich habe gesehen, dass Code-Refactoring als "disziplinierte Technik zur Umstrukturierung eines vorhandenen Code-Körpers definiert ist, die seine interne Struktur ändert, ohne sein externes Verhalten zu ändern".

Sie könnten bestimmte Dinge funktionaler machen, aber im Kern haben Sie immer noch ein objektorientiertes Programm. Sie können nicht einfach kleine Teile ändern, um sie an ein anderes Paradigma anzupassen.

Uri
quelle
Ich möchte hinzufügen, dass eine gute erste Note darin besteht, nach referentieller Transparenz zu streben. Sobald Sie dies haben, erhalten Sie ~ 50% der Vorteile der funktionalen Programmierung.
Daniel Gratzer
3

Ich denke, diese Artikelserie ist genau das, was Sie wollen:

Rein funktionale Retrogames

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

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

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

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

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

Die Zusammenfassung lautet:

Der Autor schlägt eine Hauptschleife mit Nebenwirkungen vor (Nebenwirkungen müssen irgendwo auftreten, oder?) Und die meisten Funktionen geben kleine unveränderliche Datensätze zurück, die genau beschreiben, wie sie den Status des Spiels verändert haben.

Natürlich werden Sie beim Schreiben eines realen Programms mehrere Programmierstile mischen und aufeinander abstimmen, wobei jeder dort verwendet wird, wo es am meisten hilft. Es ist jedoch eine gute Lernerfahrung, zu versuchen, ein Programm auf die funktionalste / unveränderlichste Weise zu schreiben und es auch auf die spaghettieste Weise zu schreiben, wobei nur globale Variablen verwendet werden :-) (Machen Sie es als Experiment, bitte nicht in Produktion)

marcus
quelle
2

Sie müssten wahrscheinlich Ihren gesamten Code auf den Kopf stellen, da OOP und FP beim Organisieren von Code zwei unterschiedliche Ansätze verfolgen.

OOP organisiert Code um Typen (Klassen): Verschiedene Klassen können dieselbe Operation implementieren (eine Methode mit derselben Signatur). Infolgedessen ist OOP besser geeignet, wenn sich die Menge der Vorgänge nicht wesentlich ändert, während sehr oft neue Typen hinzugefügt werden können. Betrachten wir zum Beispiel eine GUI - Bibliothek , in der jedes Widget einen festen Satz von Methoden aufweist ( hide(), show(), paint(), move(), und so weiter) , aber neue Widgets hinzugefügt werden könnte , wie die Bibliothek ausgefahren ist. In OOP ist es einfach, einen neuen Typ hinzuzufügen (für eine bestimmte Schnittstelle): Sie müssen nur eine neue Klasse hinzufügen und alle Methoden implementieren (lokale Codeänderung). Andererseits kann das Hinzufügen einer neuen Operation (Methode) zu einer Schnittstelle das Ändern aller Klassen erfordern, die diese Schnittstelle implementieren (obwohl die Vererbung den Arbeitsaufwand verringern kann).

FP organisiert Code um Operationen (Funktionen): Jede Funktion implementiert eine Operation, die unterschiedliche Typen auf unterschiedliche Weise behandeln kann. Dies wird normalerweise erreicht, indem der Typ über einen Mustervergleich oder einen anderen Mechanismus abgesetzt wird. Infolgedessen ist FP besser geeignet, wenn die Menge der Typen stabil ist und häufiger neue Operationen hinzugefügt werden. Nehmen Sie zum Beispiel einen festen Satz von Bildformaten (GIF, JPEG usw.) und einige Algorithmen, die Sie implementieren möchten. Jeder Algorithmus kann durch eine Funktion implementiert werden, die sich je nach Art des Bildes unterschiedlich verhält. Das Hinzufügen eines neuen Algorithmus ist einfach, da Sie nur eine neue Funktion implementieren müssen (lokale Codeänderung). Um ein neues Format (Typ) hinzuzufügen, müssen Sie alle bisher implementierten Funktionen ändern, um es zu unterstützen (nicht lokale Änderung).

Fazit: OOP und FP unterscheiden sich grundlegend in der Art und Weise, wie sie Code organisieren. Wenn Sie ein OOP-Design in ein FP-Design umwandeln, müssen Sie den gesamten Code entsprechend anpassen. Es kann jedoch eine interessante Übung sein. Siehe auch diese Vorlesungsunterlagen zu dem von mikemay zitierten SICP-Buch, insbesondere die Folien 13.1.5 bis 13.1.10.

Giorgio
quelle