Es gibt hier einige schöne Beispiele, aber ich wollte mit einigen persönlichen einsteigen, bei denen Unveränderlichkeit eine Menge half. In meinem Fall begann ich mit dem Entwurf einer unveränderlichen gleichzeitigen Datenstruktur, hauptsächlich in der Hoffnung, dass ich in der Lage sein sollte, Code sicher parallel zu überlappenden Lese- und Schreibvorgängen auszuführen, ohne mir Gedanken über die Rennbedingungen machen zu müssen. Es gab einen Vortrag, den John Carmack mich dazu inspirierte, wo er über eine solche Idee sprach. Es ist eine ziemlich grundlegende Struktur und ziemlich trivial zu implementieren:
Natürlich können Sie mit ein paar einfachen Schritten Elemente in konstanter Zeit entfernen und wiedergewinnbare Löcher zurücklassen, und die Blöcke werden derefisiert, wenn sie für eine bestimmte unveränderliche Instanz leer und möglicherweise befreit werden. Um die Struktur zu ändern, müssen Sie jedoch eine "vorübergehende" Version ändern und die an ihr vorgenommenen Änderungen atomar festschreiben, um eine neue unveränderliche Kopie zu erhalten, die die alte nicht berührt. In der neuen Version werden nur neue Kopien der Blöcke erstellt, die diese Änderungen enthalten müssen eindeutig gemacht werden, während die anderen kopiert und gezählt werden.
Aber ich fand es nicht , dassnützlich für Multithreading-Zwecke. Schließlich gibt es immer noch das konzeptionelle Problem, bei dem beispielsweise ein Physiksystem die Physik gleichzeitig anwendet, während ein Spieler versucht, Elemente in einer Welt zu bewegen. Mit welcher unveränderlichen Kopie der transformierten Daten gehen Sie, der vom Spieler transformierten oder der vom physikalischen System transformierten? Daher habe ich keine wirklich gute und einfache Lösung für dieses grundlegende konzeptionelle Problem gefunden, es sei denn, es gibt veränderbare Datenstrukturen, die sich nur auf intelligentere Weise sperren und überlappende Lese- und Schreibvorgänge in denselben Abschnitten des Puffers verhindern, um ein Abwürgen von Threads zu vermeiden. Das scheint John Carmack in seinen Spielen herausgefunden zu haben. Zumindest redet er darüber, als könne er fast eine Lösung sehen, ohne ein Auto von Würmern zu öffnen. Ich bin in dieser Hinsicht nicht so weit gekommen wie er. Alles, was ich sehen kann, sind endlose Designfragen, wenn ich versuche, alles um unveränderliche Objekte herum zu parallelisieren. Ich wünschte, ich könnte einen Tag damit verbringen, an seinem Gehirn herumzusuchen, da die meisten meiner Bemühungen mit den Ideen begannen, die er ausstieß.
Trotzdem fand ich in anderen Bereichen einen enormen Wert dieser unveränderlichen Datenstruktur. Ich verwende es jetzt sogar, um Bilder zu speichern, was wirklich seltsam ist und macht, dass der Direktzugriff einige weitere Anweisungen erfordert (Rechtsverschiebung und bitweise and
zusammen mit einer Schicht von Zeiger-Indirektion), aber ich werde die folgenden Vorteile behandeln.
System rückgängig machen
Einer der unmittelbarsten Orte, an denen ich davon profitierte, war das Undo-System. Das Rückgängigmachen von Systemcode war eines der fehleranfälligsten Dinge in meinem Bereich (Visual FX-Branche), und zwar nicht nur in den Produkten, an denen ich gearbeitet habe, sondern auch in konkurrierenden Produkten (deren Rückgängigmachungssysteme waren auch schuppig), da es so viele verschiedene gab Datentypen, die Sie für das ordnungsgemäße Rückgängigmachen und Wiederherstellen benötigen (Eigenschaftensystem, Änderungen der Netzdaten, Änderungen des Shaders, die nicht auf Eigenschaften basieren, z. B. das Vertauschen von Daten untereinander, Änderungen der Szenenhierarchie, z. usw. usw. usw.).
Die Menge an erforderlichem Rückgängig-Code war also enorm und konkurrierte oft mit der Menge an Code, der das System implementiert, für das das Rückgängig-System Zustandsänderungen aufzeichnen musste. Indem ich mich auf diese Datenstruktur stützte, konnte ich das System zum Rückgängigmachen auf genau dies zurückführen:
on user operation:
copy entire application state to undo entry
perform operation
on undo/redo:
swap application state with undo entry
Normalerweise wäre der obige Code enorm ineffizient, wenn Ihre Szenendaten Gigabyte umfassen und vollständig kopiert werden. Diese Datenstruktur kopiert jedoch nur oberflächlich Dinge, die nicht geändert wurden, und macht es tatsächlich billig genug, eine unveränderliche Kopie des gesamten Anwendungsstatus zu speichern. So kann ich nun Undo-Systeme so einfach wie den obigen Code implementieren und mich darauf konzentrieren, diese unveränderliche Datenstruktur zu verwenden, um das Kopieren unveränderter Teile des Anwendungsstatus immer billiger und billiger zu machen. Seit ich diese Datenstruktur verwende, haben alle meine persönlichen Projekte Systeme rückgängig gemacht, die nur dieses einfache Muster verwenden.
Jetzt gibt es hier noch etwas Overhead. Das letzte Mal, als ich es gemessen habe, waren es ungefähr 10 Kilobyte, nur um den gesamten Anwendungsstatus flach zu kopieren, ohne Änderungen daran vorzunehmen (dies ist unabhängig von der Komplexität der Szene, da die Szene in einer Hierarchie angeordnet ist. Wenn sich also nichts unter dem Stamm ändert, ändert sich nur der Stamm kopiert wird, ohne in die Kinder hinabsteigen zu müssen). Das ist weit von 0 Byte entfernt, wie es für ein Rückgängig-System erforderlich wäre, das nur Deltas speichert. Bei einem Overhead von 10 Kilobyte Rückgängigmachen pro Vorgang sind dies jedoch nur ein Megabyte pro 100 Benutzervorgänge. Außerdem könnte ich das möglicherweise in Zukunft noch weiter reduzieren, wenn nötig.
Ausnahmesicherheit
Ausnahmesicherheit bei einer komplexen Anwendung ist keine Kleinigkeit. Wenn Ihr Anwendungsstatus jedoch unveränderlich ist und Sie nur transiente Objekte verwenden, um atomare Änderungstransaktionen festzuschreiben, ist dies von Natur aus ausnahmesicher, da der Transient verworfen wird, wenn ein Teil des Codes ausgelöst wird, bevor eine neue unveränderliche Kopie erstellt wird . Damit wird eines der schwierigsten Dinge, die ich in einer komplexen C ++ - Codebasis immer richtig gefunden habe, banalisiert.
Zu viele Leute verwenden in C ++ nur RAII-konforme Ressourcen und denken, dass dies ausnahmesicher ist. Dies ist häufig nicht der Fall, da eine Funktion in der Regel Nebenwirkungen hervorrufen kann, die über den lokalen Bereich hinausgehen. In diesen Fällen müssen Sie sich im Allgemeinen mit Scope Guards und ausgefeilter Rollback-Logik befassen. Diese Datenstruktur hat es so gemacht, dass ich mich oft nicht darum kümmern muss, da die Funktionen keine Nebenwirkungen verursachen. Sie geben transformierte unveränderliche Kopien des Anwendungsstatus zurück, anstatt den Anwendungsstatus zu transformieren.
Zerstörungsfreie Bearbeitung
Die zerstörungsfreie Bearbeitung besteht im Wesentlichen aus dem Zusammenfügen / Stapeln / Verbinden von Vorgängen, ohne die Daten des ursprünglichen Benutzers zu berühren (nur Eingabe- und Ausgabedaten, ohne Eingabe zu berühren). Die Implementierung mit einer einfachen Bildanwendung wie Photoshop ist in der Regel trivial und profitiert möglicherweise nicht so stark von dieser Datenstruktur, da bei vielen Vorgängen möglicherweise nur alle Pixel des gesamten Bilds transformiert werden sollen.
Bei der zerstörungsfreien Netzbearbeitung beispielsweise möchten viele Vorgänge jedoch häufig nur einen Teil des Netzes transformieren. Eine Operation möchte hier möglicherweise nur einige Scheitelpunkte verschieben. Ein anderer möchte dort möglicherweise nur einige Polygone unterteilen. Hier hilft die unveränderliche Datenstruktur einer Tonne dabei, die Notwendigkeit zu vermeiden, eine vollständige Kopie des gesamten Netzes anzufertigen, nur um eine neue Version des Netzes mit einem kleinen Teil davon, der geändert wurde, zurückzugeben.
Minimierung von Nebenwirkungen
Mit diesen Strukturen ist es auch einfach, Funktionen zu schreiben, mit denen Nebenwirkungen minimiert werden, ohne dass eine enorme Leistungseinbuße entsteht. Ich habe immer mehr Funktionen geschrieben, die heutzutage nur ganze unveränderliche Datenstrukturen wertmäßig zurückgeben, ohne dass Nebenwirkungen auftreten, auch wenn dies etwas verschwenderisch erscheint.
Beispielsweise könnte die Versuchung, eine Reihe von Positionen zu transformieren, typischerweise darin bestehen, eine Matrix und eine Liste von Objekten zu akzeptieren und diese auf veränderbare Weise zu transformieren. In diesen Tagen stelle ich fest, dass ich gerade eine neue Liste von Objekten zurückgebe.
Wenn Ihr System über mehr Funktionen wie diese verfügt, die keine Nebenwirkungen verursachen, ist es auf jeden Fall einfacher, über die Richtigkeit des Systems nachzudenken und seine Richtigkeit zu testen.
Die Vorteile billiger Kopien
Dies sind die Bereiche, in denen ich unveränderliche Datenstrukturen (oder persistente Datenstrukturen) am meisten genutzt habe. Anfangs war ich auch etwas übereifrig und erstellte einen unveränderlichen Baum, eine unveränderliche verknüpfte Liste und eine unveränderliche Hash-Tabelle, aber im Laufe der Zeit fand ich selten so viel Verwendung für diese. In der obigen Abbildung habe ich hauptsächlich den klobigen, unveränderlichen Array-ähnlichen Container verwendet.
Ich habe auch immer noch eine Menge Code, der mit veränderlichen Dateien arbeitet (finde es eine praktische Notwendigkeit, zumindest für Code auf niedriger Ebene), aber der Hauptanwendungsstatus ist eine unveränderliche Hierarchie, die von einer unveränderlichen Szene zu unveränderlichen Komponenten in ihr hinunterschlägt. Einige der billigeren Komponenten werden immer noch vollständig kopiert, aber die teuersten, wie Maschen und Bilder, verwenden die unveränderliche Struktur, um nur die teilweise billigen Kopien der Teile zu ermöglichen, die transformiert werden mussten.
ConcurrentModificationException
der normalerweise dadurch verursacht wird, dass derselbe Thread die Sammlung im selben Thread mutiert, im Körper einerforeach
Schleife über der selben Sammlung.