Heute wollen wir das Thema Unveränderlichkeit ansprechen und herausfinden, ob dieses Problem ernsthaftere Überlegungen verdient.
Unveränderliche Objekte sind ein unermesslich starkes Phänomen in der Programmierung. Durch die Unveränderlichkeit können Sie alle Arten von Parallelitätsproblemen und eine Reihe von Fehlern vermeiden. Das Verständnis unveränderlicher Konstrukte kann jedoch schwierig sein. Werfen wir einen Blick darauf, was sie sind und wie sie verwendet werden.
Schauen Sie sich zunächst ein einfaches Objekt an:
class Person {
public String name;
public Person(
String name
) {
this.name = name;
}
}
Wie Sie sehen können, nimmt das Objekt
Personeinen Parameter in seinen Konstruktor und fügt ihn dann in eine öffentliche Variable ein name. Dementsprechend können wir solche Dinge tun:
Person p = new Person("John");
p.name = "Jane";
Einfach, oder? Lesen oder ändern Sie die Daten jederzeit nach Belieben. Bei dieser Methode gibt es jedoch einige Probleme. Die erste und wichtigste davon ist, dass wir eine Variable in unserer Klasse verwenden
nameund somit den internen Speicher der Klasse unwiderruflich in die öffentliche API einführen. Mit anderen Worten, wir können die Art und Weise, wie der Name in der Klasse gespeichert wird, nur ändern, wenn wir einen wesentlichen Teil unserer Anwendung neu schreiben.
Einige Sprachen (z. B. C #) bieten die Möglichkeit, eine Getter-Funktion einzufügen, um dieses Problem zu umgehen. In den meisten objektorientierten Sprachen müssen Sie jedoch explizit handeln:
class Person {
private String name;
public Person(
String name
) {
this.name = name;
}
public String getName() {
return name;
}
}
So weit, ist es gut. Wenn Sie jetzt den internen Speicher des Namens beispielsweise in Vor- und Nachnamen ändern möchten, können Sie dies tun:
class Person {
private String firstName;
private String lastName;
public Person(
String firstName,
String lastName
) {
this.firstName = firstName;
this.lastName = lastName;
}
public String getName() {
return firstName + " " + lastName;
}
}
Wenn Sie sich nicht mit den schwerwiegenden Problemen befassen, die mit einer solchen Darstellung von Namen verbunden sind , ist es offensichtlich, dass sich die API
getName()nicht extern geändert hat.
Was ist mit dem Setzen von Namen? Was müssen Sie hinzufügen, um den Namen nicht nur zu erhalten, sondern auch so einzustellen?
class Person {
private String name;
//...
public void setName(String name) {
this.name = name;
}
//...
}
Auf den ersten Blick sieht es gut aus, denn jetzt können wir den Namen wieder ändern. Diese Art der Datenänderung weist jedoch einen grundlegenden Fehler auf. Es hat zwei Seiten: philosophisch und praktisch.
Beginnen wir mit einem philosophischen Problem. Das Objekt
Personsoll eine Person darstellen. In der Tat kann sich der Nachname einer Person ändern, aber es wäre besser, eine Funktion für diesen Zweck zu benennen changeName, da ein solcher Name impliziert, dass wir den Nachnamen derselben Person ändern. Es sollte auch Geschäftslogik enthalten, um den Nachnamen einer Person zu ändern und sich nicht nur wie ein Setter zu verhalten. Der Name setNameführt zu einer völlig logischen Schlussfolgerung, dass wir den im Personenobjekt gespeicherten Namen freiwillig und zwangsweise ändern können und nichts dafür bekommen.
Der zweite Grund hat mit der Praxis zu tun: Der veränderbare Zustand (gespeicherte Daten, die sich ändern können) ist anfällig für Fehler. Nehmen wir dieses Objekt
Personund definieren eine Schnittstelle PersonStorage:
interface PersonStorage {
public void store(Person person);
public Person getByName(String name);
}
Hinweis: Dies
PersonStoragegibt nicht genau an, wo das Objekt gespeichert ist: im Speicher, auf der Festplatte oder in einer Datenbank. Die Schnittstelle erfordert auch keine Implementierung, um eine Kopie des darin gespeicherten Objekts zu erstellen. Daher kann ein interessanter Fehler auftreten:
Person p = new Person("John");
myPersonStorage.store(p);
p.setName("Jane");
myPersonStorage.store(p);
Wie viele Personen befinden sich derzeit im Personengeschäft? Eins oder zwei? Außerdem, wenn Sie die Methode jetzt anwenden
getByName, welche Person wird sie zurückgeben?
Wie Sie sehen können, sind hier zwei Optionen möglich: Entweder
PersonStoragewird das Objekt kopiert Person. In diesem Fall werden zwei Datensätze gespeichert Person, oder es wird dies nicht getan, und es wird nur der Verweis auf das übergebene Objekt gespeichert . Im zweiten Fall wird nur ein Objekt mit einem Namen gespeichert “Jane”. Die Implementierung der zweiten Option könnte folgendermaßen aussehen:
class InMemoryPersonStorage implements PersonStorage {
private Set<Person> persons = new HashSet<>();
public void store(Person person) {
this.persons.add(person);
}
}
Schlimmer noch, die gespeicherten Daten können geändert werden, ohne die Funktion aufzurufen
store. Da das Repository nur einen Verweis auf das ursprüngliche Objekt enthält, ändert das Ändern des Namens auch die gespeicherte Version:
Person p = new Person("John");
myPersonStorage.store(p);
p.setName("Jane");
Im Wesentlichen schleichen sich also Fehler in unser Programm ein, gerade weil es sich um einen veränderlichen Zustand handelt. Es besteht kein Zweifel, dass dieses Problem umgangen werden kann, indem die Arbeit zum Erstellen einer Kopie im Speicher explizit aufgeschrieben wird, aber es gibt auch einen viel einfacheren Weg: Arbeiten mit unveränderlichen Objekten. Betrachten wir ein Beispiel:
class Person {
private String name;
public Person(
String name
) {
this.name = name;
}
public String getName() {
return name;
}
public Person withName(String name) {
return new Person(name);
}
}
Wie Sie sehen, wird
setNamejetzt anstelle einer Methode eine Methode verwendet withName, die eine neue Kopie des Objekts erstellt Person. Wenn wir jedes Mal eine neue Kopie erstellen, verzichten wir auf einen veränderlichen Zustand und auf die entsprechenden Probleme. Dies ist natürlich mit einem gewissen Overhead verbunden, aber moderne Compiler können damit umgehen. Wenn Sie auf Leistungsprobleme stoßen, können Sie diese später beheben.
Merken:
Vorzeitige Optimierung ist die Wurzel allen Übels (Donald Knuth)
Es könnte argumentiert werden, dass die Persistenzstufe, die auf das lebende Objekt verweist, eine unterbrochene Persistenzstufe ist, aber ein solches Szenario ist realistisch. Es gibt schlechten Code, und Unveränderlichkeit ist ein wertvolles Werkzeug, um diese Art von Fehler zu verhindern.
In komplexeren Szenarien, in denen Objekte durch mehrere Ebenen der Anwendung geleitet werden, überfluten Fehler den Code leicht und die Unveränderlichkeit verhindert das Auftreten von Statusfehlern. Beispiele dieser Art sind beispielsweise In-Memory-Caching oder Funktionsaufrufe außerhalb der Reihenfolge.
Wie Unveränderlichkeit bei der Parallelverarbeitung hilft
Ein weiterer wichtiger Bereich, in dem Unveränderlichkeit nützlich ist, ist die Parallelverarbeitung. Genauer gesagt, Multithreading. In Multithread-Anwendungen werden mehrere Codezeilen parallel ausgeführt, die gleichzeitig auf denselben Speicherbereich zugreifen. Betrachten Sie eine sehr einfache Auflistung:
if (p.getName().equals("John")) {
p.setName(p.getName() + "Doe");
}
Dieser Code ist an sich nicht fehlerhaft, aber wenn er parallel ausgeführt wird, beginnt er zu verhindern und kann unordentlich werden. Überprüfen Sie mit einem Kommentar, wie das obige Code-Snippet aussieht:
if (p.getName().equals("John")) {
// , John
p.setName(p.getName() + "Doe");
}
Dies ist eine Rennbedingung. Der erste Thread prüft, ob der Name gleich ist
“John”, aber der zweite Thread ändert den Namen. Gleichzeitig funktioniert der erste Thread weiter, vorausgesetzt, der Name ist gleich John.
Natürlich könnte man das Sperren verwenden, um sicherzustellen, dass zu einem bestimmten Zeitpunkt nur ein Thread in den kritischen Teil des Codes eingeht. Es kann jedoch zu einem Engpass kommen. Wenn die Objekte jedoch unveränderlich sind, kann sich ein solches Szenario nicht entwickeln, da immer dasselbe Objekt in p gespeichert ist. Wenn ein anderer Thread die Änderung beeinflussen möchte, wird eine neue Kopie erstellt, die nicht im ersten Thread enthalten ist.
Ergebnis
Grundsätzlich würde ich empfehlen, immer darauf zu achten, dass der veränderbare Zustand in Ihrer Anwendung minimiert wird. Wenn Sie es verwenden, beschränken Sie es eng mit gut gestalteten APIs und lassen Sie es nicht in andere Bereiche der Anwendung gelangen. Je weniger Code-Teile Status enthalten, desto unwahrscheinlicher ist es, dass Sie Statusfehler abfangen.
Natürlich sind die meisten Programmierprobleme nicht lösbar, wenn Sie überhaupt nicht auf State zurückgreifen. Wenn wir jedoch alle Datenstrukturen standardmäßig als unveränderlich betrachten, gibt es viel weniger zufällige Fehler im Code. Wenn Sie wirklich gezwungen sind, Veränderlichkeit in Ihren Code einzuführen, müssen Sie dies sorgfältig tun und über die Konsequenzen nachdenken, und nicht den gesamten Code damit beginnen.