Beim Entwerfen von Klassen, die Ihr Datenmodell enthalten sollen, kann es hilfreich sein, unveränderliche Objekte zu erstellen. Ab wann wird die Belastung durch Konstruktorparameterlisten und tiefe Kopien jedoch zu groß, und Sie müssen die unveränderliche Einschränkung aufgeben?
Zum Beispiel ist hier eine unveränderliche Klasse, um eine benannte Sache darzustellen (ich verwende C # -Syntax, aber das Prinzip gilt für alle OO-Sprachen)
class NamedThing
{
private string _name;
public NamedThing(string name)
{
_name = name;
}
public NamedThing(NamedThing other)
{
this._name = other._name;
}
public string Name
{
get { return _name; }
}
}
Benannte Objekte können erstellt, abgefragt und in neue benannte Objekte kopiert werden, der Name kann jedoch nicht geändert werden.
Das ist alles in Ordnung, aber was passiert, wenn ich ein weiteres Attribut hinzufügen möchte? Ich muss dem Konstruktor einen Parameter hinzufügen und den Kopierkonstruktor aktualisieren. Das ist nicht zu viel Arbeit, aber die Probleme beginnen, soweit ich sehen kann, wenn ich ein komplexes Objekt unveränderlich machen möchte .
Wenn die Klasse möglicherweise Attribute und Sammlungen enthält, die andere komplexe Klassen enthalten, scheint mir die Konstruktorparameterliste ein Albtraum zu werden.
Ab wann wird eine Klasse zu komplex , um unveränderlich zu sein?
Antworten:
Wann werden sie zur Last? Sehr schnell (insbesondere, wenn Ihre Sprache keine ausreichende syntaktische Unterstützung für die Unveränderlichkeit bietet.)
Unveränderlichkeit wird als Königsweg für das Multicore-Dilemma und all das verkauft. Die Unveränderlichkeit in den meisten OO-Sprachen zwingt Sie jedoch dazu, künstliche Artefakte und Methoden in Ihr Modell und Ihren Prozess einzufügen. Für jede komplexe unveränderliche Klasse müssen Sie einen (zumindest intern) gleich komplexen Builder haben. Egal wie Sie es entwerfen, es führt immer noch eine starke Kopplung ein (daher haben wir einen guten Grund, sie einzuführen.)
Es ist nicht unbedingt möglich, alles in kleinen, nicht komplexen Klassen zu modellieren. Also für große Klassen und Strukturen, können wir sie künstlich aufzuteilen - nicht , weil das Gefühl in unserem Domain - Modell macht, sondern weil wir mit ihrer komplexen Instanziierung und Bauherr in Code zu tun haben.
Es ist noch schlimmer, wenn Menschen die Idee der Unveränderlichkeit in einer Allzwecksprache wie Java oder C # zu weit führen und alles unveränderlich machen. Infolgedessen werden Konstrukte mit s-Ausdrücken in Sprachen erzwungen, die solche Dinge nicht mühelos unterstützen.
Engineering ist der Akt der Modellierung durch Kompromisse und Kompromisse. Alles durch Erlass unveränderlich zu machen, weil jemand liest, dass alles in der funktionalen Sprache X oder Y (einem völlig anderen Programmiermodell) unveränderlich ist, ist nicht akzeptabel. Das ist keine gute Technik.
Kleine, möglicherweise einheitliche Dinge können unveränderlich gemacht werden. Komplexere Dinge können unveränderlich gemacht werden, wenn es Sinn macht . Aber Unveränderlichkeit ist keine Wunderwaffe. Die Fähigkeit, Fehler zu reduzieren, die Skalierbarkeit und Leistung zu verbessern, ist nicht die einzige Funktion der Unveränderlichkeit. Es ist eine Funktion der richtigen Ingenieurpraxis . Schließlich haben die Leute gute, skalierbare Software ohne Unveränderlichkeit geschrieben.
Unveränderlichkeit wird sehr schnell zu einer Last (sie erhöht die zufällige Komplexität), wenn sie ohne Grund ausgeführt wird, wenn sie außerhalb dessen erfolgt, was im Kontext eines Domänenmodells sinnvoll ist.
Zum einen versuche ich es zu vermeiden (es sei denn, ich arbeite in einer Programmiersprache mit guter syntaktischer Unterstützung).
quelle
Ich habe eine Phase durchlaufen, in der darauf bestanden wurde, dass der Unterricht, wo immer möglich, unveränderlich ist. Hatte Builder für so ziemlich alles, unveränderliche Arrays usw. Ich fand die Antwort auf Ihre Frage einfach: Ab wann werden unveränderliche Klassen zur Last? Sehr schnell. Sobald Sie etwas serialisieren möchten, müssen Sie in der Lage sein, es zu deserialisieren, was bedeutet, dass es veränderlich sein muss. Sobald Sie ein ORM verwenden möchten, bestehen die meisten von ihnen darauf, dass Eigenschaften veränderbar sind. Und so weiter.
Ich habe diese Richtlinie schließlich durch unveränderliche Schnittstellen zu veränderlichen Objekten ersetzt.
Jetzt ist das Objekt flexibel, aber Sie können dem aufrufenden Code immer noch mitteilen, dass diese Eigenschaften nicht bearbeitet werden sollen.
quelle
IComparable<T>
garantiert, dass wennX.CompareTo(Y)>0
undY.CompareTo(Z)>0
dannX.CompareTo(Z)>0
. Schnittstellen haben Verträge . Wenn im Vertrag fürIImmutableList<T>
festgelegt ist, dass die Werte aller Elemente und Eigenschaften "in Stein gemeißelt" werden müssen, bevor eine Instanz der Außenwelt ausgesetzt wird, werden alle legitimen Implementierungen dies tun. Nichts hindert eineIComparable
Implementierung daran, die Transitivität zu verletzen, aber Implementierungen, die dies tun, sind unzulässig. Wenn eineSortedDictionary
Fehlfunktion bei einer unehelichenIComparable
, ...IReadOnlyList<T>
unveränderlich sein wird, da (1) keine solche Anforderung in der Interface - Dokumentation angegeben wird, und (2) die häufigste ImplementierungList<T>
, ist nicht einmal schreibgeschützt ? Mir ist nicht ganz klar, was an meinen Begriffen nicht eindeutig ist: Eine Sammlung ist lesbar, wenn die darin enthaltenen Daten gelesen werden können. Es ist schreibgeschützt, wenn versprochen werden kann, dass die enthaltenen Daten nur geändert werden können, wenn ein externer Verweis im Code enthalten ist, der dies ändern würde. Es ist unveränderlich, wenn es garantieren kann, dass es nicht geändert werden kann, Punkt.Ich glaube nicht, dass es eine allgemeine Antwort darauf gibt. Je komplexer eine Klasse ist, desto schwieriger ist es, über ihre Statusänderungen nachzudenken, und desto teurer ist es, neue Kopien davon zu erstellen. Ab einer gewissen (persönlichen) Komplexität wird es zu schmerzhaft, eine Klasse unveränderlich zu machen / zu halten.
Beachten Sie, dass eine zu komplexe Klasse oder eine lange Liste von Methodenparametern Designgerüche an sich sind, unabhängig von der Unveränderlichkeit.
Daher besteht die bevorzugte Lösung normalerweise darin, eine solche Klasse in mehrere unterschiedliche Klassen zu unterteilen, von denen jede für sich veränderlich oder unveränderlich gemacht werden kann. Wenn dies nicht möglich ist, kann es wandelbar gemacht werden.
quelle
Sie können das Kopierproblem vermeiden, wenn Sie alle Ihre unveränderlichen Felder in einem inneren speichern
struct
. Dies ist im Grunde eine Variation des Erinnerungsmusters. Wenn Sie dann eine Kopie erstellen möchten, kopieren Sie einfach das Erinnerungsstück:quelle
Hier arbeiten ein paar Dinge. Unveränderliche Datensätze eignen sich hervorragend für die Multithread-Skalierbarkeit. Grundsätzlich können Sie Ihren Speicher erheblich optimieren, sodass ein Parametersatz eine Instanz der Klasse ist - überall. Da sich die Objekte nie ändern, müssen Sie sich nicht um die Synchronisierung kümmern, wenn Sie auf ihre Mitglieder zugreifen. Das ist gut. Wie Sie jedoch betonen, benötigen Sie eine gewisse Veränderlichkeit, je komplexer das Objekt ist. Ich würde mit folgenden Überlegungen beginnen:
In Sprachen, die nur unveränderliche Objekte unterstützen (z. B. Erlang), ist das Endergebnis eine neue Kopie des Objekts mit dem aktualisierten Wert, wenn eine Operation den Status eines unveränderlichen Objekts zu ändern scheint. Wenn Sie beispielsweise einen Artikel zu einem Vektor / einer Liste hinzufügen, gehen Sie wie folgt vor:
Das kann eine vernünftige Art sein, mit komplizierteren Objekten zu arbeiten. Wenn Sie beispielsweise einen Baumknoten hinzufügen, ist das Ergebnis ein neuer Baum mit dem hinzugefügten Knoten. Die Methode im obigen Beispiel gibt eine neue Liste zurück. In dem Beispiel in diesem Absatz wird
tree.add(newNode)
ein neuer Baum mit dem hinzugefügten Knoten zurückgegeben. Für die Benutzer wird es einfach, damit zu arbeiten. Für die Bibliotheksschreiber wird es langweilig, wenn die Sprache das implizite Kopieren nicht unterstützt. Diese Schwelle liegt in Ihrer eigenen Geduld. Für die Benutzer Ihrer Bibliothek liegt das vernünftigste Limit, das ich gefunden habe, bei drei bis vier Parameterobergrenzen.quelle
Wenn Sie mehrere Endklassenmitglieder haben und nicht möchten, dass sie allen Objekten ausgesetzt werden, die sie erstellen müssen, können Sie das Builder-Muster verwenden:
Der Vorteil ist, dass Sie auf einfache Weise ein neues Objekt mit nur einem anderen Wert eines anderen Namens erstellen können.
quelle
newObject
.NamedThing
in diesem Fall)Builder
Wiederverwendung von a das von mir erwähnte Risiko besteht, dass etwas passiert. Jemand erstellt möglicherweise viele Objekte und entscheidet, dass die meisten Eigenschaften identisch sind, um sie einfach wiederzuverwenden.Builder
Machen wir es also zu einem globalen Singleton, dem Abhängigkeiten hinzugefügt werden! Hoppla. Major Bugs eingeführt. Ich denke also, dass dieses Muster von gemischt instanziierten vs. statischen Mustern schlecht ist.Meiner Meinung nach lohnt es sich nicht, kleine Klassen in Sprachen wie der von Ihnen gezeigten unveränderlich zu machen . Ich benutze hier kleine und nicht komplexe , denn selbst wenn Sie dieser Klasse zehn Felder hinzufügen und damit wirklich ausgefallene Operationen ausführen, bezweifle ich, dass es Kilobyte oder Megabyte oder Gigabyte dauern wird class kann einfach eine billige Kopie des gesamten Objekts erstellen, um das Original nicht zu verändern, wenn es keine externen Nebenwirkungen verursachen soll.
Persistente Datenstrukturen
Ich persönlich verwende die Unveränderlichkeit für große, zentrale Datenstrukturen, die eine Reihe von wichtigen Daten wie Instanzen der Klasse, die Sie anzeigen, wie eine, die eine Million speichert, zusammenfassen
NamedThings
. Durch die Zugehörigkeit zu einer persistenten Datenstruktur, die unveränderlich ist und sich hinter einer Schnittstelle befindet, die nur Lesezugriff erlaubt, werden die zum Container gehörenden Elemente unveränderlich, ohne dass die Elementklasse (NamedThing
) damit umgehen muss.Günstige Kopien
Die persistente Datenstruktur ermöglicht es, Bereiche davon zu transformieren und eindeutig zu machen, wodurch Änderungen am Original vermieden werden, ohne dass die Datenstruktur in ihrer Gesamtheit kopiert werden muss. Das ist das Schöne daran. Wenn Sie naiv Funktionen schreiben möchten, die Nebenwirkungen vermeiden, die eine Datenstruktur eingeben, die Gigabyte Speicherplatz beansprucht und nur einen Megabyte-Speicherplatz verändert, müssen Sie das ganze Freaking-Ding kopieren, um zu vermeiden, die Eingabe zu berühren und ein neues zurückzugeben Ausgabe. Entweder kopieren Sie Gigabyte, um Nebenwirkungen zu vermeiden, oder verursachen Nebenwirkungen in diesem Szenario, sodass Sie zwischen zwei unangenehmen Optionen wählen müssen.
Mit einer dauerhaften Datenstruktur können Sie eine solche Funktion schreiben und vermeiden, dass eine Kopie der gesamten Datenstruktur erstellt wird. Die Ausgabe erfordert nur etwa ein Megabyte zusätzlichen Speicher, wenn Ihre Funktion nur den Speicher eines Megabytes transformiert.
Belastung
Was die Bürde angeht, so gibt es zumindest in meinem Fall eine unmittelbare. Ich brauche diese Builder, von denen die Leute sprechen oder "Transienten", wie ich sie nenne, um Transformationen in diese massive Datenstruktur effektiv ausdrücken zu können, ohne sie zu berühren. Code wie folgt:
... muss dann so geschrieben werden:
Im Gegenzug zu diesen beiden zusätzlichen Codezeilen kann die Funktion jetzt sicher über Threads mit derselben ursprünglichen Liste aufgerufen werden, verursacht keine Nebenwirkungen usw. Außerdem ist es sehr einfach, diesen Vorgang zu einer nicht rückgängig zu machenden Benutzeraktion zu machen, da die Undo kann nur eine billige, flache Kopie der alten Liste speichern.
Ausnahmesicherheit oder Fehlerbehebung
Nicht jeder profitiert in solchen Kontexten so sehr von persistenten Datenstrukturen wie ich (ich habe sie in Undo-Systemen und bei der zerstörungsfreien Bearbeitung, die zentrale Begriffe in meiner VFX-Domäne sind, so häufig verwendet), aber eine Sache, die für ungefähr gilt Jeder, der berücksichtigt werden muss, ist Ausnahmesicherheit oder Fehlerbehebung .
Wenn Sie die ursprüngliche Mutationsfunktion ausnahmesicher machen möchten, ist eine Rollback-Logik erforderlich, für die die einfachste Implementierung das Kopieren der gesamten Liste erfordert :
Zu diesem Zeitpunkt ist die ausnahmesichere veränderbare Version noch rechenintensiver und wahrscheinlich noch schwieriger zu schreiben als die unveränderliche Version, die einen "Builder" verwendet. Und viele C ++ - Entwickler vernachlässigen die Ausnahmesicherheit einfach und das ist vielleicht in Ordnung für ihre Domain, aber in meinem Fall möchte ich sicherstellen, dass mein Code auch im Falle einer Ausnahme korrekt funktioniert (selbst wenn ich Tests schreibe, die absichtlich Ausnahmen auslösen, um Ausnahmen zu testen Sicherheit), und das macht es so, dass ich in der Lage sein muss, alle Nebenwirkungen, die eine Funktion hervorruft, rückgängig zu machen, wenn etwas auslöst.
Wenn Sie ausnahmesicher sein und Fehler ordnungsgemäß beheben möchten, ohne dass Ihre Anwendung abstürzt und brennt, müssen Sie alle Nebenwirkungen, die eine Funktion im Falle eines Fehlers / einer Ausnahme verursachen kann, rückgängig machen. Und da kann der Builder tatsächlich mehr Programmiererzeit sparen, als es kostet, zusammen mit Rechenzeit, weil:
Also zurück zur Grundfrage:
Sie sind immer eine Belastung für Sprachen, bei denen es mehr um Veränderbarkeit als um Unveränderlichkeit geht. Deshalb sollten Sie sie meiner Meinung nach dort einsetzen, wo der Nutzen die Kosten deutlich überwiegt. Aber auf einer Ebene, die breit genug für ausreichend große Datenstrukturen ist, gibt es meines Erachtens viele Fälle, in denen dies einen angemessenen Kompromiss darstellt.
Auch in meinem Fall habe ich nur wenige unveränderliche Datentypen und alle sind riesige Datenstrukturen, die eine große Anzahl von Elementen speichern sollen (Pixel eines Bildes / einer Textur, Entitäten und Komponenten eines ECS und Eckpunkte / Kanten / Polygone von ein Netz).
quelle