Wie geht die funktionale Programmierung mit der Situation um, in der dasselbe Objekt von mehreren Stellen aus referenziert wird?


10

Ich lese und höre, dass die Leute (auch auf dieser Seite) routinemäßig das Paradigma der funktionalen Programmierung loben und betonen, wie gut es ist, alles unveränderlich zu haben. Insbesondere schlagen die Leute diesen Ansatz sogar in traditionell zwingenden OO-Sprachen wie C #, Java oder C ++ vor, nicht nur in rein funktionalen Sprachen wie Haskell, die dies dem Programmierer aufzwingen.

Ich finde es schwer zu verstehen, weil ich Veränderlichkeit und Nebenwirkungen ... praktisch finde. Angesichts der Art und Weise, wie Menschen derzeit Nebenwirkungen verurteilen und es für eine gute Praxis halten, sie nach Möglichkeit loszuwerden, glaube ich jedoch, dass ich, wenn ich ein kompetenter Programmierer sein möchte, meine Karriere beginnen muss, um das Paradigma besser zu verstehen ... Daher mein Q.

Ein Ort, an dem ich Probleme mit dem Funktionsparadigma finde, ist, wenn ein Objekt natürlich von mehreren Stellen aus referenziert wird. Lassen Sie es mich an zwei Beispielen beschreiben.

Das erste Beispiel ist mein C # -Spiel, das ich in meiner Freizeit machen möchte . Es ist ein rundenbasiertes Web-Spiel, bei dem beide Spieler Teams mit 4 Monstern haben und ein Monster aus ihrem Team ins Spiel schicken können, wo es dem Monster des gegnerischen Spielers gegenübersteht. Spieler können auch Monster vom Schlachtfeld zurückrufen und durch ein anderes Monster aus ihrem Team ersetzen (ähnlich wie bei Pokemon).

In dieser Einstellung kann ein einzelnes Monster natürlich von mindestens zwei Stellen aus referenziert werden: dem Team eines Spielers und dem Schlachtfeld, auf das zwei "aktive" Monster verweisen.

Betrachten wir nun die Situation, in der ein Monster getroffen wird und 20 Gesundheitspunkte verliert. In den Klammern des imperativen Paradigmas modifiziere ich das healthFeld dieses Monsters , um diesen Wandel widerzuspiegeln - und das mache ich jetzt. Dies macht die MonsterKlasse jedoch veränderlich und die damit verbundenen Funktionen (Methoden) unrein, was meiner Meinung nach derzeit als schlechte Praxis angesehen wird.

Obwohl ich mir die Erlaubnis gegeben habe, den Code dieses Spiels in einem nicht idealen Zustand zu haben, um die Hoffnung zu haben, ihn irgendwann in der Zukunft tatsächlich zu beenden, würde ich gerne wissen und verstehen, wie er sein sollte richtig geschrieben. Deshalb: Wenn dies ein Konstruktionsfehler ist, wie kann er behoben werden?

In dem funktionalen Stil, wie ich ihn verstehe, würde ich stattdessen eine Kopie dieses MonsterObjekts erstellen und es bis auf dieses eine Feld mit dem alten identisch halten. und die Methode suffer_hitwürde dieses neue Objekt zurückgeben, anstatt das alte zu ändern. Dann würde ich das BattlefieldObjekt ebenfalls kopieren und alle Felder bis auf dieses Monster gleich halten.

Dies bringt mindestens 2 Schwierigkeiten mit sich:

  1. Die Hierarchie kann leicht viel tiefer sein als dieses vereinfachte Beispiel von nur Battlefield-> Monster. Ich müsste alle Felder außer einem so kopieren und ein neues Objekt bis zum Ende dieser Hierarchie zurückgeben. Dies wäre Boilerplate-Code, den ich besonders ärgerlich finde, da die funktionale Programmierung Boilerplate reduzieren soll.
  2. Ein viel schwerwiegenderes Problem ist jedoch, dass dies dazu führen würde, dass die Daten nicht mehr synchron sind . Das aktive Monster des Feldes würde seine Gesundheit verringern; Das gleiche Monster, auf das von seinem kontrollierenden Spieler verwiesen wird Team, würde dies jedoch nicht tun. Wenn ich stattdessen den imperativen Stil annehmen würde, wäre jede Änderung von Daten sofort von allen anderen Stellen des Codes aus sichtbar, und in solchen Fällen wie diesem finde ich es wirklich praktisch - aber die Art und Weise, wie ich Dinge erhalte, ist genau das, was die Leute sagen falsch mit dem imperativen Stil!
    • Jetzt wäre es möglich, sich um dieses Problem zu kümmern, indem Sie Teamnach jedem Angriff eine Reise zum unternehmen . Das ist zusätzliche Arbeit. Was aber, wenn ein Monster später plötzlich von noch mehr Orten aus referenziert werden kann? Was ist, wenn ich mit einer Fähigkeit komme, mit der sich ein Monster beispielsweise auf ein anderes Monster konzentrieren kann, das nicht unbedingt auf dem Spielfeld ist (ich denke tatsächlich über eine solche Fähigkeit nach)? Werde ich mich sicher daran erinnern, sofort nach jedem Angriff auch eine Reise zu fokussierten Monstern zu unternehmen? Dies scheint eine Zeitbombe zu sein, die explodieren wird, wenn der Code komplexer wird. Ich denke, es ist keine Lösung.

Eine Idee für eine bessere Lösung stammt aus meinem zweiten Beispiel, als ich auf dasselbe Problem stieß. In der Wissenschaft wurde uns gesagt, wir sollten in Haskell einen Dolmetscher für eine Sprache unseres eigenen Designs schreiben. (Auf diese Weise musste ich auch verstehen, was FP ist). Das Problem trat auf, als ich Schließungen implementierte. Erneut kann derselbe Bereich jetzt von mehreren Stellen aus referenziert werden: Über die Variable, die diesen Bereich enthält, und als übergeordneter Bereich aller verschachtelten Bereiche! Wenn eine Änderung an diesem Bereich über eine der darauf verweisenden Referenzen vorgenommen wird, muss diese Änderung natürlich auch durch alle anderen Referenzen sichtbar sein.

Die Lösung bestand darin, jedem Bereich eine ID zuzuweisen und ein zentrales Wörterbuch aller Bereiche in der StateMonade zu führen. Jetzt würden Variablen nur die ID des Bereichs enthalten, an den sie gebunden waren, und nicht den Bereich selbst, und verschachtelte Bereiche würden auch die ID ihres übergeordneten Bereichs enthalten.

Ich denke, der gleiche Ansatz könnte in meinem Monster-Kampfspiel versucht werden ... Felder und Teams beziehen sich nicht auf Monster; Sie enthalten stattdessen IDs von Monstern, die in einem zentralen Monsterwörterbuch gespeichert sind.

Ich sehe jedoch wieder ein Problem mit diesem Ansatz, das mich daran hindert, es ohne zu zögern als Lösung für das Problem zu akzeptieren:

Es ist wieder eine Quelle für Boilerplate-Code. Einzeiler müssen zwangsläufig dreizeilig sein: Was früher eine einzeilige direkte Änderung eines einzelnen Felds war, erfordert jetzt (a) Abrufen des Objekts aus dem zentralen Wörterbuch (b) Vornehmen der Änderung (c) Speichern des neuen Objekts zum zentralen Wörterbuch. Das Speichern von IDs von Objekten und zentralen Wörterbüchern anstelle von Referenzen erhöht die Komplexität. Da FP beworben wird, um die Komplexität und den Boilerplate-Code zu reduzieren , deutet dies darauf hin, dass ich es falsch mache.

Ich wollte auch über ein zweites Problem schreiben, das viel schwerwiegender zu sein scheint: Dieser Ansatz führt zu Speicherlecks . Objekte, die nicht erreichbar sind, werden normalerweise durch Müll gesammelt. Objekte, die in einem zentralen Wörterbuch gespeichert sind, können jedoch nicht mit Müll gesammelt werden, selbst wenn kein erreichbares Objekt auf diese bestimmte ID verweist. Und während theoretisch sorgfältige Programmierung Speicherlecks vermeiden kann (wir könnten darauf achten, jedes Objekt manuell aus dem zentralen Wörterbuch zu entfernen, sobald es nicht mehr benötigt wird), ist dies fehleranfällig und FP wird angekündigt, um die Korrektheit von Programmen zu erhöhen nicht der richtige Weg sein.

Ich fand jedoch rechtzeitig heraus, dass es eher ein gelöstes Problem zu sein scheint. Java bietet WeakHashMap, mit denen dieses Problem behoben werden kann. C # bietet eine ähnliche Funktion - ConditionalWeakTable- obwohl es laut den Dokumenten von Compilern verwendet werden soll. Und in Haskell haben wir System.Mem.Weak .

Ist das Speichern solcher Wörterbücher die richtige funktionale Lösung für dieses Problem oder gibt es eine einfachere, die ich nicht sehe? Ich stelle mir vor, dass die Anzahl solcher Wörterbücher leicht und schlecht wachsen kann; Wenn diese Wörterbücher auch unveränderlich sein sollen, kann dies eine Menge Parameterübergabe oder in Sprachen, die dies unterstützen, monadische Berechnungen bedeuten , da die Wörterbücher in Monaden gehalten würden (aber ich lese das noch einmal in rein funktionaler Form Sprachen, in denen so wenig Code wie möglich vorhanden ist, sollten monadisch sein, während diese Wörterbuchlösung fast den gesamten Code in die StateMonade einfügt. Dies lässt mich erneut bezweifeln, ob dies die richtige Lösung ist.)

Nach einiger Überlegung möchte ich noch eine Frage hinzufügen: Was gewinnen wir durch die Erstellung solcher Wörterbücher? Was bei der imperativen Programmierung falsch ist, ist nach Ansicht vieler Experten, dass Änderungen an einigen Objekten auf andere Codeteile übertragen werden. Um dieses Problem zu lösen, sollten Objekte unveränderlich sein - genau aus diesem Grund sollten, wenn ich richtig verstehe, an ihnen vorgenommene Änderungen an keiner anderen Stelle sichtbar sein. Aber jetzt mache ich mir Sorgen um andere Codeteile, die mit veralteten Daten arbeiten, und erfinde daher zentrale Wörterbücher, damit ... wieder Änderungen in einigen Codeteilen auf andere Codeteile übertragen werden! Kehren wir also nicht zum imperativen Stil mit all seinen vermeintlichen Nachteilen zurück, sondern mit zusätzlicher Komplexität?


6
Um dies zu verdeutlichen, sind funktionale unveränderliche Programme hauptsächlich für Datenverarbeitungssituationen mit Parallelität gedacht . Mit anderen Worten, Programme, die Eingabedaten durch einen Satz von Gleichungen verarbeiten oder Prozesse, die ein Ausgabeergebnis erzeugen. Unveränderlichkeit hilft in diesem Szenario aus mehreren Gründen: Werte, die von mehreren Threads gelesen werden, ändern sich garantiert nicht während ihrer Lebensdauer, was die Möglichkeit, die Daten sperrfrei zu verarbeiten, und die Funktionsweise des Algorithmus erheblich vereinfacht.
Robert Harvey

8
Das schmutzige kleine Geheimnis über funktionale Unveränderlichkeit und Spielprogrammierung ist, dass diese beiden Dinge irgendwie nicht miteinander kompatibel sind. Sie versuchen im Wesentlichen, ein dynamisches, sich ständig änderndes System mithilfe einer statischen, unbeweglichen Datenstruktur zu modellieren.
Robert Harvey

2
Nehmen Sie Veränderlichkeit und Unveränderlichkeit nicht als religiöses Dogma. Es gibt Situationen, in denen jeder besser ist als der andere, Unveränderlichkeit nicht immer besser ist, z. B. das Schreiben eines GUI-Toolkits mit unveränderlichen Datentypen ist ein absoluter Albtraum.
Whatsisname

1
Diese C # -spezifische Frage und ihre Antworten befassen sich mit dem Thema Boilerplate, was hauptsächlich auf die Notwendigkeit zurückzuführen ist, leicht modifizierte (aktualisierte) Klone eines vorhandenen unveränderlichen Objekts zu erstellen.
Rwong

2
Eine wichtige Erkenntnis ist, dass ein Monster in diesem Spiel als eine Einheit betrachtet wird. Außerdem wird das Ergebnis jeder Schlacht (bestehend aus der Kampfsequenznummer, den Entitäts-IDs der Monster, den Zuständen der Monster vor und nach der Schlacht) zu einem bestimmten Zeitpunkt (oder Zeitschritt) als Zustand betrachtet. Auf diese Weise können Spieler ( Team) das Ergebnis des Kampfes und damit die Zustände der Monster durch ein Tupel (Kampfnummer, ID der Monsterentität) abrufen.
Rwong

Antworten:


19

Wie behandelt die funktionale Programmierung ein Objekt, auf das von mehreren Stellen aus verwiesen wird? Es lädt Sie ein, Ihr Modell erneut zu besuchen!

Zur Erklärung ... schauen wir uns an, wie vernetzte Spiele manchmal geschrieben werden - mit einer zentralen "Golden Source" -Kopie des Spielstatus und einer Reihe eingehender Client-Ereignisse, die diesen Status aktualisieren und dann wieder an die anderen Clients gesendet werden .

Sie können über den Spaß lesen , den das Factorio-Team daran hatte, dass sich dies in bestimmten Situationen gut verhält. Hier ist eine kurze Übersicht über ihr Modell:

Die grundlegende Funktionsweise unseres Multiplayers besteht darin, dass alle Clients den Spielstatus simulieren und nur die Spielereingaben (sogenannte Eingabeaktionen) empfangen und senden. Die Hauptverantwortung des Servers besteht darin, Eingabeaktionen zu übertragen und sicherzustellen, dass alle Clients dieselben Aktionen im selben Tick ausführen.

Da der Server entscheiden muss, wann Aktionen ausgeführt werden, bewegt sich eine Spieleraktion wie folgt: Spieleraktion -> Spielclient -> Netzwerk -> Server -> Netzwerk-> Spielclient. Dies bedeutet, dass jede Spieleraktion nur ausgeführt wird, wenn eine Rundreise durch das Netzwerk erfolgt. Dies würde das Spiel sehr verzögert erscheinen lassen, deshalb war das Ausblenden der Latenz ein Mechanismus, der dem Spiel fast seit der Einführung des Mehrspielermodus hinzugefügt wurde. Das Ausblenden der Latenz simuliert die Spielereingabe, ohne die Aktionen anderer Spieler zu berücksichtigen und ohne die Arbitrage des Servers zu berücksichtigen.

In Factorio haben wir den Spielstatus, dies ist der vollständige Status der Karte, Spieler, Berechtigungen, alles. Es wird deterministisch auf allen Clients basierend auf den vom Server empfangenen Aktionen simuliert. Dies ist heilig und wenn es sich jemals vom Server oder einem anderen Client unterscheidet, tritt eine Desynchronisierung auf.

Zusätzlich zum Spielstatus haben wir den Latenzstatus. Dies enthält eine kleine Teilmenge des Hauptzustands. Der Latenzstatus ist nicht heilig und zeigt nur, wie der Spielstatus unserer Meinung nach in Zukunft aussehen wird, basierend auf den Eingabeaktionen, die der Spieler ausgeführt hat.

Der Schlüssel ist, dass der Status jedes Objekts an dem bestimmten Häkchen in der Zeitleiste unveränderlich ist . Alles im globalen Multiplayer-Zustand muss letztendlich zu einer deterministischen Realität konvergieren.

Und - das könnte der Schlüssel zu Ihrer Frage sein. Der Status jeder Entität ist für einen bestimmten Tick unveränderlich, und Sie verfolgen die Übergangsereignisse, die im Laufe der Zeit neue Instanzen erzeugen.

Wenn Sie darüber nachdenken, muss die vom Server eingehende Ereigniswarteschlange Zugriff auf ein zentrales Verzeichnis von Entitäten haben, damit sie ihre Ereignisse anwenden kann.

Letztendlich sind Ihre einfachen einzeiligen Mutatormethoden, die Sie nicht komplizieren möchten, nur einfach, weil Sie die Zeit nicht wirklich genau modellieren. Wenn Immerhin Gesundheit in der Mitte der Bearbeitungsschleife ändern kann, dann früher Entitäten in dieser Zecke werden einen alten Wert sehen, und spätere ein verändert man sehen. Dies sorgfältig zu handhaben bedeutet, zumindest den aktuellen (unveränderlichen) und den nächsten (im Bau befindlichen) Zustand zu unterscheiden, die wirklich nur zwei Zecken in der großen Zeitlinie der Zecken sind!

Als allgemeine Richtlinie sollten Sie also in Betracht ziehen, den Zustand eines Monsters in eine Reihe kleiner Objekte aufzuteilen, die sich beispielsweise auf Ort / Geschwindigkeit / Physik, Gesundheit / Schaden und Vermögenswerte beziehen. Erstellen Sie ein Ereignis, um jede möglicherweise auftretende Mutation zu beschreiben, und führen Sie Ihre Hauptschleife wie folgt aus:

  1. Eingaben verarbeiten und entsprechende Ereignisse generieren
  2. interne Ereignisse erzeugen (zB aufgrund von Objektkollisionen usw.)
  3. Wende Ereignisse auf aktuelle unveränderliche Monster an, um neue Monster für den nächsten Tick zu generieren. Meistens wird der alte unveränderte Status kopiert, wenn möglich, aber bei Bedarf werden neue Statusobjekte erstellt.
  4. rendern und für das nächste Häkchen wiederholen.

Oder sowas ähnliches. Ich denke: "Wie würde ich das verteilen?" ist im Allgemeinen eine ziemlich gute mentale Übung, um mein Verständnis zu verfeinern, wenn ich verwirrt bin, wo Dinge leben und wie sie sich entwickeln sollten.

Dank eines Hinweises von @ AaronM.Eshbach wird hervorgehoben, dass dies eine ähnliche Problemdomäne ist wie Event Sourcing und das CQRS- Muster, bei dem Sie Statusänderungen in einem verteilten System als eine Reihe unveränderlicher Ereignisse im Laufe der Zeit modellieren . In diesem Fall versuchen wir höchstwahrscheinlich, eine komplexe Datenbank-App zu bereinigen, indem wir (wie der Name schon sagt!) Die Behandlung des Mutator-Befehls vom Abfrage- / Ansichtssystem trennen. Natürlich komplexer, aber flexibler.


2
Weitere Informationen finden Sie unter Event Sourcing und CQRS . Dies ist eine ähnliche Problemdomäne: Modellierung von Statusänderungen in einem verteilten System als eine Reihe unveränderlicher Ereignisse im Laufe der Zeit.
Aaron M. Eshbach

@ AaronM.Eshbach das ist der eine! Stört es Sie, wenn ich Ihre Kommentare / Zitate in die Antwort einbeziehe? Es klingt maßgeblicher. Vielen Dank!
SusanW

Natürlich nicht, bitte.
Aaron M. Eshbach

3

Du bist immer noch halb im Imperativlager. Anstatt jeweils an ein einzelnes Objekt zu denken, denken Sie an Ihr Spiel in Bezug auf eine Geschichte von Spielen oder Ereignissen

p1 - send m1 to battlefield
p2 - send m2 to battlefield
m1 - attacks m2 (2 dam)
m2 - attacks m1 (10 dam)
p1 - retreats m1

etc

Sie können den Status des Spiels zu einem bestimmten Zeitpunkt berechnen, indem Sie die Aktionen miteinander verketten, um ein unveränderliches Statusobjekt zu erstellen. Jedes Spiel ist eine Funktion, die ein Statusobjekt nimmt und ein neues Statusobjekt zurückgibt

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.