Unveränderliche Strukturen und tiefe Kompositionshierarchie

9

Ich entwickle eine GUI-Anwendung, die stark mit Grafiken arbeitet - Sie können sich das Beispiel als Vektoreditor vorstellen. Es ist sehr verlockend, alle Datenstrukturen unveränderlich zu machen - so konnte ich fast ohne Aufwand rückgängig machen / wiederholen, kopieren / einfügen und viele andere Dinge.

Der Einfachheit halber werde ich das folgende Beispiel verwenden: Die Anwendung wird zum Bearbeiten von polygonalen Formen verwendet, daher habe ich das Objekt "Polygon", bei dem es sich lediglich um eine Liste unveränderlicher Punkte handelt:

Scene -> Polygon -> Point

Daher habe ich nur eine veränderbare Variable in meinem Programm - die, die das aktuelle Szenenobjekt enthält. Das Problem, das ich habe, beginnt, wenn ich versuche, das Ziehen von Punkten zu implementieren. In einer veränderlichen Version greife ich einfach nach einem PointObjekt und beginne, seine Koordinaten zu ändern. In unveränderlicher Version - ich stecke fest. Ich hätte Indizes von Polygonaktuell Scene, Index von gezogenem Punkt in speichern Polygonund jedes Mal ersetzen können. Dieser Ansatz lässt sich jedoch nicht skalieren. Wenn die Zusammensetzung auf 5 und höher steigt, wird die Kesselplatte unerträglich.

Ich bin sicher, dass dieses Problem gelöst werden kann - schließlich gibt es Haskell mit völlig unveränderlichen Strukturen und IO-Monade. Aber ich kann einfach nicht finden wie.

Kannst du mir einen Hinweis geben?

Rogach
quelle
@Job - so funktioniert es jetzt und es gibt mir viele Schmerzen. Also suche ich nach alternativen Ansätzen - und Unveränderlichkeit scheint perfekt für diese Anwendungsstruktur zu sein, zumindest bevor wir Benutzerinteraktion hinzufügen :)
Rogach
@ Rogach: Können Sie mehr über Ihren Boilerplate-Code erklären?
Rwong

Antworten:

9

Ich hätte die Indizes von Polygon in der aktuellen Szene und den Index des gezogenen Punkts in Polygon speichern und jedes Mal ersetzen können. Dieser Ansatz lässt sich jedoch nicht skalieren. Wenn die Zusammensetzung auf 5 und höher steigt, wird die Kesselplatte unerträglich.

Sie haben absolut Recht, dieser Ansatz lässt sich nicht skalieren, wenn Sie die Kesselplatte nicht umgehen können . Insbesondere wurde die Boilerplate zum Erstellen einer ganz neuen Szene mit einem winzigen Teil geändert. Viele funktionale Sprachen bieten jedoch ein Konstrukt für den Umgang mit dieser Art der Manipulation verschachtelter Strukturen: Linsen.

Ein Objektiv ist im Grunde ein Getter und Setter für unveränderliche Daten. Ein Objektiv konzentriert sich auf einen kleinen Teil einer größeren Struktur. Bei einer Linse gibt es zwei Dinge , die man damit machen kann - man kann sieht den kleinen Teil eines Wertes der größeren Struktur, oder Sie können festlegen , den kleinen Teil eines Wertes einer größeren Struktur auf einen neuen Wert. Angenommen, Sie haben ein Objektiv, das sich auf das dritte Element in einer Liste konzentriert:

thirdItemLens :: Lens [a] a

Dieser Typ bedeutet, dass die größere Struktur eine Liste von Dingen ist und der kleine Unterabschnitt eines dieser Dinge ist. Mit diesem Objektiv können Sie das dritte Element in der Liste anzeigen und festlegen:

> view thirdItemLens [1, 2, 3, 4, 5]
3
> set thirdItemLens 100 [1, 2, 3, 4, 5]
[1, 2, 100, 4, 5]

Der Grund, warum Objektive nützlich sind, liegt darin, dass sie Werte darstellen, die Getter und Setter darstellen, und dass Sie sie genauso abstrahieren können wie andere Werte. Sie können Funktionen erstellen, die Objektive zurückgeben, z. B. eine listItemLensFunktion, die eine Zahl nakzeptiert und ein Objektiv zurückgibt, das das ndritte Element in einer Liste anzeigt. Zusätzlich können Linsen zusammengesetzt werden :

> firstLens = listItemLens 0
> thirdLens = listItemLens 2
> firstOfThirdLens = lensCompose firstLens thirdLens
> view firstOfThirdLens [[1, 2], [3, 4], [5, 6], [7, 8]]
5
> set firstOfThirdLens 100 [[1, 2], [3, 4], [5, 6], [7, 8]]
[[1, 2], [3, 4], [100, 6], [7, 8]]

Jedes Objektiv kapselt das Verhalten zum Durchlaufen einer Ebene der Datenstruktur. Indem Sie sie kombinieren, können Sie die Kesselplatte für das Durchqueren mehrerer Ebenen komplexer Strukturen entfernen. Angenommen, Sie haben eine scenePolygonLens i, die das idritte Polygon in einer Szene anzeigt, und eine polygonPointLens n, die den nthPunkt in einem Polygon anzeigt, können Sie einen Linsenkonstruktor erstellen, um sich auf genau den Punkt zu konzentrieren, den Sie in einer gesamten Szene interessieren:

scenePointLens i n = lensCompose (polygonPointLens n) (scenePolygonLens i)

Angenommen, ein Benutzer klickt auf Punkt 3 des Polygons 14 und verschiebt ihn um 10 Pixel nach rechts. Sie können Ihre Szene folgendermaßen aktualisieren:

lens = scenePointLens 14 3
point = view lens currentScene
newPoint = movePoint 10 0 point
newScene = set lens newPoint currentScene

Dies enthält die gesamte Boilerplate zum Durchlaufen und Aktualisieren einer Szene im Inneren lens. Alles, was Sie beachten müssen, ist, auf was Sie den Punkt ändern möchten. Sie können dies mit einer lensTransformFunktion weiter abstrahieren , die ein Objektiv, ein Ziel und eine Funktion zum Aktualisieren der Ansicht des Ziels durch das Objektiv akzeptiert :

lensTransform lens transformFunc target =
  current = view lens target
  new = transformFunc current
  set lens new target

Dies nimmt eine Funktion und verwandelt sie in einen "Updater" für eine komplizierte Datenstruktur, wobei die Funktion nur auf die Ansicht angewendet und zum Erstellen einer neuen Ansicht verwendet wird. Kehren wir also zu dem Szenario zurück, in dem der 3. Punkt des 14. Polygons um 10 Pixel nach rechts verschoben wird. Dies kann folgendermaßen ausgedrückt lensTransformwerden:

lens = scenePointLens 14 3
moveRightTen point = movePoint 10 0 point
newScene = lensTransform lens moveRightTen currentScene

Und das ist alles, was Sie brauchen, um die gesamte Szene zu aktualisieren. Dies ist eine sehr leistungsstarke Idee und funktioniert sehr gut, wenn Sie einige nützliche Funktionen zum Erstellen von Objektiven haben, mit denen Sie die Daten anzeigen können, die Ihnen wichtig sind.

Allerdings ist dies derzeit alles ziemlich weit draußen, selbst in der funktionalen Programmier-Community. Es ist schwierig, eine gute Bibliotheksunterstützung für die Arbeit mit Objektiven zu finden, und noch schwieriger zu erklären, wie sie funktionieren und welche Vorteile sie für Ihre Mitarbeiter haben. Nehmen Sie diesen Ansatz mit einem Körnchen Salz.

Jack
quelle
Hervorragende Erklärung! Jetzt verstehe ich, was Objektive sind!
Vincent Lecrubier
13

Ich habe an genau dem gleichen Problem gearbeitet (aber nur mit 3 Kompositionsstufen). Die Grundidee ist, zu klonen und dann zu ändern . Im unveränderlichen Programmierstil müssen das Klonen und Ändern zusammen erfolgen, was zum Befehlsobjekt wird .

Beachten Sie, dass im veränderlichen Programmierstil das Klonen ohnehin notwendig gewesen wäre:

  • Rückgängig / Wiederherstellen zulassen
  • Das Anzeigesystem muss möglicherweise gleichzeitig das Modell "vor der Bearbeitung" und "während der Bearbeitung" überlappen (als Geisterlinien) anzeigen, damit der Benutzer die Änderungen sehen kann.

Im veränderlichen Programmierstil

  • Die vorhandene Struktur ist tief geklont
  • Die Änderungen werden in der geklonten Kopie vorgenommen
  • Die Anzeige-Engine wird angewiesen, die alte Struktur in Geisterlinien und die geklonte / modifizierte Struktur in Farbe zu rendern.

Im unveränderlichen Programmierstil,

  • Jede Benutzeraktion, die zu einer Datenänderung führt, wird einer Folge von "Befehlen" zugeordnet.
  • Ein Befehlsobjekt kapselt genau, welche Änderung angewendet werden soll, und verweist auf die ursprüngliche Struktur.
    • In meinem Fall merkt sich mein Befehlsobjekt nur den Punktindex, der geändert werden muss, und die neuen Koordinaten. (dh sehr leicht, da ich nicht unveränderlich dem unveränderlichen Stil folge.)
  • Wenn ein Befehlsobjekt ausgeführt wird, erstellt es eine geänderte Tiefenkopie der Struktur, wodurch die Änderung in der neuen Kopie dauerhaft wird.
  • Wenn der Benutzer mehr Änderungen vornimmt, werden mehr Befehlsobjekte erstellt.
rwong
quelle
1
Warum eine tiefe Kopie einer unveränderlichen Datenstruktur erstellen? Sie müssen nur den "Rücken" der Referenzen vom geänderten Objekt in den Stamm kopieren und die Referenzen auf die verbleibenden Teile der ursprünglichen Struktur beibehalten.
Stellen Sie Monica
3

Tief unveränderliche Objekte haben den Vorteil, dass das tiefe Klonen von etwas lediglich das Kopieren einer Referenz erfordert. Sie haben den Nachteil, dass für eine kleine Änderung an einem tief verschachtelten Objekt eine neue Instanz jedes Objekts erstellt werden muss, in dem es verschachtelt ist. Veränderbare Objekte haben den Vorteil, dass das Ändern eines Objekts einfach ist - tun Sie es einfach -, aber das tiefe Klonen eines Objekts erfordert das Erstellen eines neuen Objekts, das einen tiefen Klon jedes verschachtelten Objekts enthält. Schlimmer noch, wenn man will , ein Objekt klonen und eine Änderung vornehmen, Klon , dass das Objekt, eine weitere Änderung vornehmen, usw. dann , egal wie groß oder klein die Veränderungen sind ein bis hat zu halten für jede gespeicherte Version der eine Kopie der gesamten Hierarchie Objektzustand. Böse.

Ein Ansatz, der in Betracht gezogen werden könnte, wäre die Definition eines abstrakten "vielleicht veränderlichen" Typs mit veränderlichen und tief unveränderlichen abgeleiteten Typen. Alle diese Typen würden eine AsImmutableMethode aufweisen; Wenn Sie diese Methode für eine tief unveränderliche Instanz eines Objekts aufrufen, wird diese Instanz einfach zurückgegeben. Das Aufrufen einer veränderlichen Instanz würde eine tief unveränderliche Instanz zurückgeben, deren Eigenschaften tief unveränderliche Schnappschüsse ihrer Entsprechungen im Original waren. Unveränderliche Typen mit veränderlichen Äquivalenten würden eine AsMutableMethode verwenden, die eine veränderbare Instanz konstruieren würde, deren Eigenschaften mit denen des Originals übereinstimmen.

Das Ändern eines verschachtelten Objekts in einem tief unveränderlichen Objekt würde erfordern, dass zuerst das äußere unveränderliche Objekt durch ein veränderliches Objekt ersetzt wird, dann die Eigenschaft, die das zu ändernde Objekt enthält, durch ein veränderliches Objekt usw. ersetzt wird, aber wiederholt Änderungen an demselben Aspekt des Objekts vorgenommen werden Für das Gesamtobjekt müssten keine zusätzlichen Objekte erstellt werden, bis versucht wird, AsImmutableein veränderliches Objekt aufzurufen (wodurch die veränderlichen Objekte veränderlich bleiben, aber unveränderliche Objekte mit denselben Daten zurückgegeben werden).

Als einfache, aber signifikante Optimierungen könnte jedes veränderbare Objekt einen zwischengespeicherten Verweis auf ein Objekt seines zugeordneten unveränderlichen Typs enthalten, und jeder unveränderliche Typ sollte seinen GetHashCodeWert zwischenspeichern. AsImmutableÜberprüfen Sie beim Aufrufen eines veränderlichen Objekts, bevor Sie ein neues unveränderliches Objekt zurückgeben, dass es mit der zwischengespeicherten Referenz übereinstimmt. Wenn ja, geben Sie die zwischengespeicherte Referenz zurück (wobei Sie das neue unveränderliche Objekt verlassen). Aktualisieren Sie andernfalls die zwischengespeicherte Referenz, um das neue Objekt aufzunehmen, und geben Sie es zurück. In diesem Fall wiederholte Anrufe anAsImmutableohne dazwischenliegende Mutationen ergeben sich die gleichen Objektreferenzen. Selbst wenn man die Kosten für die Erstellung der neuen Instanzen nicht spart, vermeidet man die Speicherkosten für deren Aufbewahrung. Ferner können Gleichheitsvergleiche zwischen den unveränderlichen Objekten stark beschleunigt werden, wenn in den meisten Fällen die verglichenen Elemente referenzgleich sind oder unterschiedliche Hash-Codes aufweisen.

Superkatze
quelle