Mit welchen Techniken kann ich objektorientierten Code in Funktionscode umgestalten?

9

Ich habe ungefähr 20-40 Stunden damit verbracht, einen Teil eines Spiels mit JavaScript und HTML5 Canvas zu entwickeln. Als ich anfing, hatte ich keine Ahnung, was ich tat. Es begann also als Proof of Concept und kommt jetzt gut voran, aber es gibt keine automatisierten Tests. Das Spiel wird langsam so komplex, dass es von einigen automatisierten Tests profitieren könnte, aber es scheint schwierig zu sein, da der Code von der Mutation des globalen Zustands abhängt.

Ich möchte das Ganze mit Underscore.js , einer funktionalen Programmierbibliothek für JavaScript, überarbeiten. Ein Teil von mir meint, ich sollte einfach bei Null anfangen und einen funktionalen Programmierstil verwenden und testen. Ich denke jedoch, dass die Umgestaltung des imperativen Codes in deklarativen Code eine bessere Lernerfahrung und eine sicherere Möglichkeit sein könnte, zu meinem aktuellen Funktionsstatus zu gelangen.

Das Problem ist, ich weiß, wie mein Code am Ende aussehen soll, aber ich weiß nicht, wie ich meinen aktuellen Code in ihn umwandeln soll. Ich hoffe, einige Leute hier könnten mir einige Tipps zum Refactoring-Buch und zum effektiven Arbeiten mit Legacy-Code geben.


Zum Beispiel denke ich als ersten Schritt darüber nach, den globalen Staat zu "verbieten". Nehmen Sie jede Funktion, die eine globale Variable verwendet, und übergeben Sie sie stattdessen als Parameter.

Der nächste Schritt kann darin bestehen, die Mutation zu "verbieten" und immer ein neues Objekt zurückzugeben.

Jeder Rat wäre dankbar. Ich habe noch nie OO-Code genommen und ihn in Funktionscode umgestaltet.

Daniel Kaplan
quelle
1
"Der nächste Schritt könnte darin bestehen, die Mutation zu" verbieten "und immer ein neues Objekt zurückzugeben.": IMO müssen Sie die Mutation nicht vollständig verbieten: Es kommt auf die referenzielle Transparenz an. Sie können destruktive Updates als Optimierungstechnik verwenden, sofern Sie die referenzielle Transparenz beibehalten.
Giorgio
@ Giorgio gut zu wissen. Ich würde es jedoch vorziehen, die Mutation zuerst zu "verbieten" und dann zu optimieren.
Daniel Kaplan
1
Ich denke, deine ersten beiden Schritte sind eine großartige Idee! Es wäre mir eine Ehre, wenn Sie sich eine funktionale JS-Bibliothek ansehen würden, die ich geschrieben habe (winzig), um monadisch zusammengesetzte Funktionsketten zu erstellen. es leitet den Zustand durch die Funktionskette, mutiert aber nie; Jede Funktion, die Sie damit verketten, stellt sicher, dass Werte geklont werden, um einen neuen Wert anstelle des ihm übergebenen Werts zurückzugeben. github.com/JimmyHoffa/Machinad Die Bibliothek selbst ist ziemlich komplex, wenn Sie nicht mit Monaden vertraut sind. Schauen Sie sich jedoch das Beispiel an und Sie sollten sehen, dass es sehr einfach zu verwenden ist. Die Implementierung war einfach komplex.
Jimmy Hoffa
1
Wenn Sie Dinge in einem funktionalen Stil tun möchten, würde ich sicherstellen, dass Sie Ihren Code auswaschen, nämlich die Vererbung, wenn sie überhaupt vorhanden ist. zwingen Sie sich, Funktionen zu erstellen und das zu verwenden, was Javascript hervorragend kann: lexikalischer Bereich, um Einrichtungen dort verfügbar zu machen, wo Sie sie benötigen, anstatt Hierarchiebäume. Definieren Sie Ihre Datenstrukturen als solche, und fügen Sie ihnen nur Funktionen hinzu, um einen fließenden Stil zu unterstützen, bei dem die Funktion genauso gut wäre, wenn Sie die Datenstruktur als ersten Parameter verwenden. In OO wäre dies ein anämisches Modell; Aber wir sprechen von FP. Komposition ist der Schlüssel, um diese Nachteile zu vermeiden.
Jimmy Hoffa
@tieTYT: Natürlich stimme ich Ihnen zu, wir alle wissen, dass vorzeitige Optimierung schlecht ist.
Giorgio

Antworten:

9

Nachdem ich mehrere Projekte in funktionalem JavaScript verfasst habe, kann ich meine Erfahrungen teilen. Ich werde mich auf allgemeine Überlegungen konzentrieren, nicht auf spezifische Implementierungsprobleme. Denken Sie daran, es gibt keine Silberkugeln. Tun Sie, was für Ihre spezielle Situation am besten funktioniert.

Pure Functional vs Functional Style

Überlegen Sie, was Sie von diesem funktionalen Refactoring profitieren möchten. Möchten Sie wirklich eine reine funktionale Implementierung oder nur einige der Vorteile, die ein funktionaler Programmierstil bieten kann, wie z. B. referenzielle Transparenz?

Ich habe den letzteren Ansatz gefunden, den ich als funktionalen Stil bezeichne, der sich gut in JavaScript einfügt und normalerweise das ist, was ich will. Es ermöglicht auch die sorgfältige Verwendung selektiver nicht funktionaler Konzepte in Code, wo sie sinnvoll sind.

Das Schreiben von Code in einem rein funktionalen Stil ist auch in JavaScript möglich, jedoch nicht immer die am besten geeignete Lösung. Und Javascript kann niemals rein funktional sein.

Funktionsstil-Schnittstellen

Im funktionalen Stil von Javascript ist die Grenze zwischen funktionalem und imperativem Code die wichtigste Überlegung. Es ist wichtig, diese Grenze klar zu verstehen und zu definieren.

Ich organisiere meinen Code um funktionale Schnittstellen. Hierbei handelt es sich um Sammlungen von Logik, die sich in einem funktionalen Stil verhalten und von einzelnen Funktionen bis zu ganzen Modulen reichen. Die konzeptionelle Trennung von Schnittstelle und Implementierung ist wichtig. Eine Schnittstelle im funktionalen Stil kann zwingend implementiert werden, solange die zwingende Implementierung nicht über die Grenze hinaus leckt.

Leider liegt die Last für die Durchsetzung von Schnittstellen für funktionale Stile in Javascript vollständig beim Entwickler. Auch Sprachmerkmale wie Ausnahmen machen eine wirklich saubere Trennung unmöglich.

Da Sie eine vorhandene Codebasis umgestalten, identifizieren Sie zunächst vorhandene logische Schnittstellen in Ihrem Code, die sauber in einen funktionalen Stil verschoben werden können. Eine überraschende Menge an Code kann bereits funktional sein. Gute Ausgangspunkte sind kleine Schnittstellen mit wenigen zwingenden Abhängigkeiten. Jedes Mal, wenn Sie über Code wechseln, werden vorhandene zwingende Abhängigkeiten beseitigt und es kann mehr Code verschoben werden.

Iterieren Sie weiter und arbeiten Sie schrittweise nach außen, um große Teile Ihres Projekts auf die funktionale Seite der Grenze zu verschieben, bis Sie mit den Ergebnissen zufrieden sind. Dieser inkrementelle Ansatz ermöglicht das Testen einzelner Änderungen und erleichtert das Identifizieren und Durchsetzen der Grenze.

Algorithmen, Datenstrukturen und Design überdenken

Imperative Lösungen und Designmuster sind im funktionalen Stil oft nicht sinnvoll. Insbesondere für Datenstrukturen können direkte Ports von imperativem Code zum Funktionsstil hässlich und langsam sein. Ein einfaches Beispiel: Möchten Sie lieber jedes Element einer Liste für ein Voranstellen kopieren oder das Element in die vorhandene Liste aufnehmen?

Erforschung und Bewertung bestehender funktionaler Problemansätze. Funktionale Programmierung ist mehr als nur eine Programmiertechnik, sie ist eine andere Art, über Probleme nachzudenken und sie zu lösen. Projekte, die in den traditionelleren funktionalen Programmiersprachen wie lisp oder haskell geschrieben wurden, sind in dieser Hinsicht eine große Ressource.

Verstehe die Sprache und die Builtins

Dies ist ein wichtiger Teil des Verständnisses der funktionalen Imperativgrenze und wirkt sich auf das Schnittstellendesign aus. Verstehen Sie, welche Funktionen im funktionalen Stilcode sicher verwendet werden können und welche nicht. Alles, von dem Builtins Ausnahmen auslösen können und die [].pushObjekte mutieren, zu Objekten, die als Referenz übergeben werden, und wie Prototypenketten funktionieren. Ein ähnliches Verständnis aller anderen Bibliotheken, die Sie in Ihrem Projekt verwenden, ist ebenfalls erforderlich.

Dieses Wissen ermöglicht fundierte Entwurfsentscheidungen, z. B. wie mit fehlerhaften Eingaben und Ausnahmen umgegangen werden soll, oder was passiert, wenn eine Rückruffunktion etwas Unerwartetes bewirkt? Einige davon können im Code erzwungen werden, andere erfordern lediglich eine Dokumentation zur ordnungsgemäßen Verwendung.

Ein weiterer Punkt ist, wie streng Ihre Implementierung sein sollte. Möchten Sie wirklich versuchen, das korrekte Verhalten bei maximalen Call-Stack-Fehlern zu erzwingen, oder wenn der Benutzer eine integrierte Funktion neu definiert?

Verwenden von nicht funktionalen Konzepten, wo dies angemessen ist

Funktionale Ansätze sind nicht immer angemessen, insbesondere in einer Sprache wie JavaScript. Die rekursive Lösung kann elegant sein, bis Sie maximale Call-Stack-Ausnahmen für größere Eingaben erhalten. Daher wäre möglicherweise ein iterativer Ansatz besser. Ebenso verwende ich die Vererbung für die Codeorganisation, die Zuweisung in Konstruktoren und unveränderliche Objekte, wenn sie sinnvoll sind.

Solange die funktionalen Schnittstellen nicht verletzt werden, tun Sie, was Sinn macht und was am einfachsten zu testen und zu warten ist.


Beispiel

Hier ist ein Beispiel für die Konvertierung eines sehr einfachen realen Codes in einen funktionalen Stil. Ich habe kein sehr gutes großes Beispiel für diesen Prozess, aber dieses kleine berührt einige interessante Punkte.

Dieser Code erstellt einen Versuch aus einer Zeichenfolge. Ich beginne mit einem Code, der auf dem oberen Abschnitt von John Resigs Trie-Js Build-Trie- Modul basiert . Viele Vereinfachungen / Formatierung Änderungen wurden vorgenommen , und meine Absicht ist nicht zu kommentieren die Qualität des Original - Code (Es gibt viel klarer und saubere Weise ein Trie zu bauen, also warum diese c Artcode kommt zuerst in Google interessant ist. Hier ein ist schnelle Implementierung, die reduzieren verwendet ).

Ich mag dieses Beispiel, weil ich es nicht bis zu einer echten funktionalen Implementierung führen muss, sondern nur zu funktionalen Schnittstellen. Hier ist der Ausgangspunkt:

var txt = SOME_USER_STRING,
    words = txt.replace(/\n/g, "").split(" "),
    trie = {};

for (var i = 0, l = words.length; i < l; i++) {
    var word = words[i], letters = word.split(""), cur = trie;
    for (var j = 0; j < letters.length; j++) {
        var letter = letters[j];
        if (!cur.hasOwnProperty(letter))
            cur[letter] = {};
        cur = cur[ letter ];
    }
    cur['$'] = true;
}

// Output for text = 'a ab f abc abf'
trie = {
    "a": {
        "$":true,
        "b":{
            "$":true,
            "c":{"$":true}
        "f":{"$":true}}},
    "f":{"$":true}};

Stellen wir uns vor, dies sei das gesamte Programm. Dieses Programm führt zwei Dinge aus: Konvertieren einer Zeichenfolge in eine Liste von Wörtern und Erstellen eines Versuchs aus der Liste von Wörtern. Dies sind die vorhandenen Schnittstellen im Programm. Hier ist unser Programm mit den Schnittstellen deutlicher ausgedrückt:

var _get_words = function(txt) {
    return txt.replace(/\n/g, "").split(" ");
};

var _build_trie_from_list = function(words, trie) {
    for (var i = 0, l = words.length; i < l; i++) {
        var word = words[i], letters = word.split(""), cur = trie;
        for (var j = 0; j < letters.length; j++) {
            var letter = letters[j];
            if (!cur.hasOwnProperty(letter))
                cur[letter] = {};
            cur = cur[ letter ];
        }
        cur['$'] = true;
    }
};

// The 'public' interface
var build_trie = function(txt) { 
    var words = _get_words(txt), trie = {};
    _build_trie_from_list(words, trie);
    return trie;
};

Nur durch die Definition unserer Schnittstellen können wir _get_wordszur funktionalen Stilseite der Grenze wechseln . Weder noch replaceoder splitändert die ursprüngliche Zeichenfolge. Und unsere öffentliche Oberfläche build_trieist auch funktional, obwohl sie mit einem sehr wichtigen Code interagiert. Tatsächlich ist dies in den meisten Fällen ein guter Haltepunkt. Mehr Code würde überwältigend werden, also lassen Sie mich ein paar andere Änderungen überblicken.

Stellen Sie zunächst alle Schnittstellen in einen funktionalen Stil. Dies ist in diesem Fall trivial. _build_trie_from_listGeben Sie einfach ein Objekt zurück, anstatt eines zu mutieren.

Umgang mit fehlerhaften Eingaben

Überlegen Sie, was passiert, wenn wir build_triemit einer Reihe von Zeichen aufrufen build_trie(['a', ' ', 'a', 'b', 'c', ' ', 'f']). Der Aufrufer ging davon aus, dass sich dies in einem funktionalen Stil verhalten würde, löst jedoch eine Ausnahme aus, wenn .replaceer für das Array aufgerufen wird. Dies kann das beabsichtigte Verhalten sein. Oder wir könnten explizite Typprüfungen durchführen und sicherstellen, dass die Eingaben wie erwartet sind. Aber ich schreibe lieber Enten.

Strings sind nur Arrays von Zeichen und Arrays sind nur Objekte mit einer lengthEigenschaft und nicht negativen Ganzzahlen als Schlüssel. Also , wenn wir Refactoring und schrieb neue replaceund splitMethoden , die auf Array-ähnliche Objekte von Strings funktioniert, sie haben nicht einmal Strings sein müssen, könnte unser Code tun genau das Richtige. ( String.prototype.*funktioniert hier nicht, es konvertiert die Ausgabe in einen String). Das Tippen von Enten ist völlig unabhängig von der funktionalen Programmierung, aber der größere Punkt ist, dass schlechte Eingaben immer berücksichtigt werden sollten.

Grundlegende Neugestaltung

Es gibt auch grundlegendere Überlegungen. Angenommen, wir möchten den Trie auch im funktionalen Stil bauen. Im imperativen Code bestand der Ansatz darin, den Versuch wortweise zu konstruieren. Ein direkter Port würde uns jedes Mal den gesamten Versuch kopieren lassen, wenn wir eine Einfügung vornehmen müssen, um zu vermeiden, dass Objekte mutieren. Das wird natürlich nicht funktionieren. Stattdessen könnte der Versuch Knoten für Knoten von unten nach oben konstruiert werden, so dass ein Knoten, sobald er fertiggestellt ist, nie wieder berührt werden muss. Oder ein anderer Ansatz ist möglicherweise besser. Eine schnelle Suche zeigt viele vorhandene funktionale Implementierungen von Versuchen.

Hoffe, das Beispiel hat die Dinge ein wenig geklärt.


Insgesamt finde ich, dass das Schreiben von funktionalem Stilcode in Javascript ein lohnendes und unterhaltsames Unterfangen ist. Javascript ist eine überraschend leistungsfähige funktionale Sprache, wenn auch im Standardzustand zu ausführlich.

Viel Glück bei Ihrem Projekt.

Einige persönliche Projekte im funktionalen Stil Javascript:

  • parse.js - Parser-Kombinatoren
  • Nu - Lazy Streams
  • Atum - Javascript-Interpreter, geschrieben im funktionalen Stil Javascript.
  • Khepri - Prototyp-Programmiersprache, die ich für die funktionale Javascript-Entwicklung verwende. Im funktionalen Stil Javascript implementiert.
Matt Bierner
quelle
Vielen Dank, dass Sie sich die Zeit genommen haben, dies aufzuschreiben. Einige Beispiele wären sehr dankbar (und persönliche Projekte helfen dabei nicht, weil sie das "Nachher" zeigen, sie zeigen nicht, wie man von "Vorher" dorthin kommt). Dies ist am nützlichsten in Ihrem Abschnitt mit dem Titel "Functional Style Interfaces".
Daniel Kaplan
Ich habe ein einfaches Beispiel hinzugefügt. Es wird schwierig sein, ein größeres Beispiel zu finden, das den Konvertierungsprozess demonstriert. Ich empfehle daher dringend, so schnell wie möglich mit der Arbeit mit realem Code zu beginnen. Es ist wirklich ein sehr projektspezifischer Prozess und funktioniert tatsächlich, obwohl der Code Ihnen viel beibringen wird. Vielleicht ist der erste Durchgang nicht großartig, aber iterieren und lernen Sie weiter, bis Sie mit dem Ergebnis zufrieden sind. Und es gibt viele hervorragende Beispiele für funktionales Design online für andere Sprachen (ich persönlich finde Schema und Clojure die besten Ausgangspunkte für viele Themen).
Matt Bierner
Ich wünschte, diese Antwort wäre in mehrere geteilt, damit ich jeden Teil verbessern könnte
Paul