Später Einsteiger in dieses Q & A mit bereits guten Antworten, aber ich wollte mich als Ausländer einmischen, der es gewohnt ist, die Dinge vom untergeordneten Standpunkt der Bits und Bytes im Speicher aus zu betrachten.
Ich bin sehr begeistert von unveränderlichen Designs, selbst aus der C-Perspektive, und von der Perspektive, neue Wege zu finden, um diese scheußliche Hardware, die wir heutzutage haben, effektiv zu programmieren.
Langsamer / schneller
Auf die Frage, ob es die Dinge langsamer macht, wäre eine roboterhafte Antwort yes
. Auf dieser Art von sehr technischer konzeptioneller Ebene könnte Unveränderlichkeit die Dinge nur langsamer machen. Hardware ist am besten geeignet, wenn sie den Speicher nicht sporadisch zuweist und stattdessen nur den vorhandenen Speicher ändern kann (weshalb wir Konzepte wie zeitliche Lokalität verwenden).
Eine praktische Antwort ist jedoch maybe
. Die Leistung ist nach wie vor weitgehend eine Produktivitätsmetrik in einer nicht trivialen Codebasis. Wir finden es normalerweise nicht schrecklich, Codebasen zu warten, die über die Rennbedingungen stolpern, und das am effizientesten, selbst wenn wir die Fehler ignorieren. Effizienz ist oft eine Funktion von Eleganz und Einfachheit. Die Spitze der Mikrooptimierungen kann in gewisser Weise widersprüchlich sein, diese sind jedoch normalerweise den kleinsten und kritischsten Codeabschnitten vorbehalten.
Unveränderliche Bits und Bytes transformieren
Wenn wir Röntgenkonzepte wie objects
und strings
so weiter betrachten, dann sind dies aus der Sicht der unteren Ebene nur Bits und Bytes in verschiedenen Speicherformen mit unterschiedlichen Geschwindigkeits- / Größenmerkmalen (Geschwindigkeit und Größe der Speicherhardware sind typischerweise) sich gegenseitig ausschließen).
Die Speicherhierarchie des Computers mag es, wenn wir wiederholt auf denselben Speicherblock zugreifen, wie im obigen Diagramm, da dieser häufig verwendete Speicherblock in der schnellsten Form des Speichers (L1-Cache, z. B. welcher) gespeichert wird ist fast so schnell wie ein Register). Möglicherweise greifen wir wiederholt auf denselben Speicher zu (verwenden ihn mehrmals) oder greifen wiederholt auf verschiedene Abschnitte des Chunks zu (z. B .: Durchlaufen der Elemente in einem zusammenhängenden Chunk, der wiederholt auf verschiedene Abschnitte dieses Chunks zugreift).
Wir werfen in diesem Prozess einen Schraubenschlüssel, wenn das Ändern dieses Speichers dazu führt, dass ein ganz neuer Speicherblock an der Seite erstellt werden soll, wie folgt:
... in diesem Fall kann der Zugriff auf den neuen Speicherblock obligatorische Seitenfehler und Cache-Fehler erfordern, um ihn wieder in die schnellsten Speicherformen (bis hin zu einem Register) zu verschieben. Das kann ein echter Leistungskiller sein.
Es gibt jedoch Möglichkeiten, dies zu mildern, indem ein Reservepool vorab zugewiesenen Speichers verwendet wird, der bereits angesprochen wurde.
Große Aggregate
Ein weiteres konzeptionelles Problem, das sich aus einer etwas übergeordneten Sicht ergibt, besteht darin, unnötige Kopien von wirklich großen Aggregaten in großen Mengen zu erstellen.
Um ein übermäßig komplexes Diagramm zu vermeiden, stellen wir uns vor, dieser einfache Speicherblock wäre irgendwie teuer (möglicherweise UTF-32-Zeichen auf einer unglaublich begrenzten Hardware).
Wenn wir in diesem Fall "HELP" durch "KILL" ersetzen wollten und dieser Speicherblock unveränderlich war, müssten wir einen vollständigen neuen Block erstellen, um ein eindeutiges neues Objekt zu erstellen, obwohl sich nur Teile davon geändert haben :
Diese Art tiefer Kopie von allem anderen, nur um ein kleines Teil einzigartig zu machen, kann sehr teuer sein (in realen Fällen wäre dieser Speicherblock viel, viel größer, um ein Problem darzustellen).
Trotz eines solchen Aufwands ist diese Art der Konstruktion in der Regel weitaus weniger anfällig für menschliches Versagen. Jeder, der in einer funktionalen Sprache mit reinen Funktionen gearbeitet hat, kann dies wahrscheinlich zu schätzen wissen, insbesondere in Multithread-Fällen, in denen wir solchen Code ohne Rücksicht auf die Welt multithreaden können. Im Allgemeinen neigen menschliche Programmierer dazu, Zustandsänderungen auszulösen, insbesondere solche, die externe Nebenwirkungen auf Zustände außerhalb des Bereichs einer aktuellen Funktion verursachen. Selbst die Wiederherstellung nach einem externen Fehler (Ausnahme) in einem solchen Fall kann mit veränderlichen externen Zustandsänderungen in der Mischung unglaublich schwierig sein.
Eine Möglichkeit, diese redundante Kopierarbeit zu verringern, besteht darin, diese Speicherblöcke in eine Sammlung von Zeigern (oder Verweisen) auf Zeichen zu verwandeln.
Entschuldigung, mir ist nicht klar geworden, dass wir L
beim Erstellen des Diagramms keine eindeutigen Angaben machen müssen .
Blau zeigt flache kopierte Daten an.
... leider würde es unglaublich teuer werden, einen Zeiger / eine Referenz pro Zeichen zu bezahlen. Darüber hinaus können wir den Inhalt der Zeichen über den gesamten Adressraum verteilen und dafür in Form einer Schiffsladung von Seitenfehlern und Cache-Fehlern bezahlen, was diese Lösung noch schlimmer macht, als das gesamte Objekt zu kopieren.
Auch wenn wir darauf geachtet haben, diese Zeichen zusammenhängend zuzuweisen, kann der Computer beispielsweise 8 Zeichen und 8 Zeiger auf ein Zeichen in eine Cache-Zeile laden. Wir laden den Speicher wie folgt, um den neuen String zu durchlaufen:
In diesem Fall müssen zum Durchlaufen dieser Zeichenfolge am Ende 7 verschiedene Cachezeilen mit zusammenhängendem Speicher geladen werden, wenn im Idealfall nur 3 erforderlich sind.
Daten aufteilen
Um das obige Problem zu lösen, können wir dieselbe grundlegende Strategie anwenden, jedoch auf einer gröberen Ebene von 8 Zeichen, z
Das Ergebnis erfordert, dass 4 Datenzeilen (1 für die 3 Zeiger und 3 für die Zeichen) geladen werden, um diese Zeichenfolge zu durchlaufen, die nur 1 kurz vor dem theoretischen Optimum liegt.
Das ist also überhaupt nicht schlecht. Es gibt einige Speicherverschwendung, aber Speicher ist reichlich vorhanden, und die Verwendung von mehr verlangsamt die Dinge nicht, wenn auf den zusätzlichen Speicher nur kalte Daten zugegriffen wird, auf die nicht häufig zugegriffen wird. Nur für die heißen, zusammenhängenden Daten, bei denen Speicherbedarf und -geschwindigkeit oft Hand in Hand gehen und wir mehr Speicher in eine einzelne Seite oder Cache-Zeile einbauen und vor der Räumung darauf zugreifen möchten. Diese Darstellung ist ziemlich Cache-freundlich.
Geschwindigkeit
Die Verwendung einer Darstellung wie der oben genannten kann also zu einem angemessenen Leistungsgleichgewicht führen. Die wahrscheinlich leistungskritischste Verwendung von unveränderlichen Datenstrukturen besteht darin, klobige Datenstücke zu modifizieren und sie in diesem Prozess einzigartig zu machen, während nicht modifizierte Teile flach kopiert werden. Es bedeutet auch einen Mehraufwand für Atomoperationen, die flachen kopierten Teile sicher in einem Multithread-Kontext zu referenzieren (möglicherweise mit fortlaufender Atomreferenzzählung).
Solange diese klobigen Daten jedoch grob genug dargestellt werden, verringert sich ein Großteil dieses Overheads und ist möglicherweise sogar trivialisiert, während wir dennoch die Sicherheit und Leichtigkeit haben, mehr Funktionen in reiner Form ohne externe Seite zu codieren und zu multithreaden Auswirkungen.
Neue und alte Daten aufbewahren
Unveränderlichkeit ist aus meiner Sicht (in praktischer Hinsicht) am hilfreichsten, wenn wir versucht sind, vollständige Kopien großer Datenmengen anzufertigen, um sie in einem veränderlichen Kontext einzigartig zu machen, in dem das Ziel besteht, etwas Neues hervorzubringen Etwas, das es bereits gibt und in dem wir sowohl Neues als auch Altes bewahren wollen, wenn wir nur kleine Stücke davon mit einem sorgfältigen unveränderlichen Design einzigartig machen könnten.
Beispiel: System rückgängig machen
Ein Beispiel hierfür ist ein Undo-System. Möglicherweise ändern wir einen kleinen Teil einer Datenstruktur und möchten sowohl das ursprüngliche Formular, das rückgängig gemacht werden kann, als auch das neue Formular beibehalten. Mit dieser Art von unveränderlichem Design, das nur kleine, geänderte Abschnitte der Datenstruktur eindeutig macht, können wir einfach eine Kopie der alten Daten in einem Rückgängig-Eintrag speichern, während wir nur die Speicherkosten der hinzugefügten eindeutigen Portionsdaten bezahlen. Dies bietet ein sehr effektives Gleichgewicht zwischen Produktivität (wodurch die Implementierung eines Undo-Systems zum Kinderspiel wird) und Leistung.
Hochrangige Schnittstellen
Dennoch ergibt sich mit dem obigen Fall etwas Unangenehmes. In einem lokalen Funktionskontext lassen sich veränderbare Daten häufig am einfachsten und unkompliziertesten ändern. Der einfachste Weg, ein Array zu ändern, besteht darin, es zu durchlaufen und jeweils ein Element zu ändern. Wir könnten den intellektuellen Aufwand erhöhen, wenn wir eine große Anzahl von High-Level-Algorithmen zur Auswahl hätten, um ein Array zu transformieren, und die geeignete auswählen müssten, um sicherzustellen, dass all diese klobigen, flachen Kopien erstellt werden, während die Teile, die geändert werden, unverändert bleiben einzigartig gemacht.
In solchen Fällen ist es wahrscheinlich am einfachsten, veränderbare Puffer lokal im Kontext einer Funktion zu verwenden (in der sie uns normalerweise nicht auslösen), die atomar Änderungen an der Datenstruktur festlegt, um eine neue unveränderliche Kopie zu erhalten (ich glaube, dass einige Sprachen dies aufrufen) diese "Transienten") ...
... oder wir modellieren einfach Transformationsfunktionen höherer und höherer Ebenen über den Daten, um den Prozess des Änderns eines veränderlichen Puffers und des Festschreibens für die Struktur ohne Veränderungslogik zu verbergen. Auf jeden Fall ist dies noch kein weit verbreitetes Gebiet, und wir müssen uns auf unveränderliche Entwürfe beschränken, um aussagekräftige Schnittstellen für die Transformation dieser Datenstrukturen zu finden.
Datenstrukturen
Eine andere Sache, die hier auftritt, ist, dass die Unveränderlichkeit, die in einem leistungskritischen Kontext verwendet wird, wahrscheinlich dazu führen wird, dass Datenstrukturen in Blockdaten zerfallen, bei denen die Blöcke nicht zu klein, aber auch nicht zu groß sind.
Verknüpfte Listen möchten möglicherweise einiges ändern, um dies zu berücksichtigen und sich in entrollte Listen zu verwandeln. Große, zusammenhängende Arrays können sich in ein Array von Zeigern verwandeln, die in zusammenhängende Chunks mit Modulo-Indizierung für den wahlfreien Zugriff umgewandelt werden.
Dies kann möglicherweise die Art und Weise verändern, wie wir Datenstrukturen auf interessante Weise betrachten, und gleichzeitig die Änderungsfunktionen dieser Datenstrukturen so anpassen, dass sie voluminöser wirken, um die zusätzliche Komplexität beim flachen Kopieren einiger Bits zu verbergen und andere Bits dort eindeutig zu machen.
Performance
Wie auch immer, dies ist meine kleine untergeordnete Ansicht zum Thema. Theoretisch kann Unveränderlichkeit Kosten verursachen, die von sehr großen bis zu kleinen Kosten reichen. Ein sehr theoretischer Ansatz führt jedoch nicht immer zu einer schnellen Bewerbung. Es macht sie möglicherweise skalierbar, aber für die Geschwindigkeit in der realen Welt ist es oft erforderlich, die praktischere Denkweise zu berücksichtigen.
Aus praktischer Sicht verschwimmen Eigenschaften wie Leistung, Wartbarkeit und Sicherheit vor allem bei einer sehr großen Codebasis. Während die Leistung in gewissem Sinne mit der Unveränderlichkeit abnimmt, ist es schwierig, die Vorteile für Produktivität und Sicherheit (einschließlich Thread-Sicherheit) zu diskutieren. Eine Erhöhung dieser Werte kann häufig zu einer Steigerung der praktischen Leistung führen, schon allein deshalb, weil die Entwickler mehr Zeit haben, ihren Code zu optimieren, ohne von Fehlern überschwemmt zu werden.
Deshalb denke ich , aus diesem praktischen Sinn, unveränderlichen Datenstrukturen tatsächlich könnte helfen , die Leistung in vielen Fällen, so seltsam , wie es sich anhört. Eine ideale Welt sucht möglicherweise nach einer Mischung aus diesen beiden: unveränderlichen und veränderlichen Datenstrukturen, wobei die veränderlichen Datenstrukturen in der Regel in einem sehr lokalen Bereich (z. B. lokal für eine Funktion) sehr sicher sind, während die unveränderlichen eine externe Seite vermeiden können bewirkt sofort und macht aus allen Änderungen an einer Datenstruktur eine atomare Operation, die eine neue Version ohne das Risiko von Rennbedingungen erzeugt.