Immutable-Implementierungen in Java - klassische und besondere Fallstricke
27.07.2015 Michael Mosmann
Die meisten Sprachen verfügen über Sprachmittel, um Werte als unveränderlich zu markieren, so dass entweder zur Laufzeit oder zur Compile-Zeit entsprechende Fehlermeldungen zu Tage treten, wenn man gegen diese Regeln verstößt. Die Intention des Programmierers ist dabei wie folgt: Durch die Markierung bringt man zum Ausdruck, dass man nicht erwartet, dass der Inhalt einer Variable mit einem neuen Wert gesetzt wird. Passiert das trotzdem, dann ist in jedem Fall nicht mehr davon auszugehen, dass ein Programm danach noch sinnvoll funktioniert.
Möchte man komplexere Objekte oder Daten unveränderlich gestalten, gibt es verschiedene Hürden, die je nach Sprache unterschiedlich hoch ausfallen. Java bietet vergleichsweise wenig Unterstützung für unveränderliche Datenstrukturen, so dass viele Entwickler auf den Einsatz von unveränderlichen Strukturen verzichten, weil der Aufwand zu hoch erscheint und die Vorteile nicht aufwiegt.
Die wesentlichen Vorteile sind meiner Meinung nach folgende:
- Vereinfachte Fehlersuche
- Problemfrei in nebenläufigen Umgebungen
- Fehlermeldungen nahe am Verursacher
Im folgenden zeige ich anhand verschiedener Beispiele aus der Praxis, wie man korrekt unveränderliche Typen implementiert und an welchen Stellen man durch Unachtsamkeit schnell schwer zu lokalisiernde Fehler provoziert.
Der Klassiker - Listen als Parameter
In sehr vielen Fällen ist der Ergebnistyp für eine Menge von Daten eine Liste mit Objekten. Oft ist die Liste dann ein Attribut eines übergeordneten Objektes. Und dieses Objekt wird dann durch die Anwendungsschichten transportiert. Die Rollen sind meist klar verteilt: es gibt eine Stelle, an der die Liste erzeugt wird und es gibt verschiedene Stellen, wo der Inhalt der Liste angezeigt wird. So einfach ist es meistens nicht, denn wenn die Rollen immer genau so verteilt wären, bräuchte man keine unveränderlichen Objekte. Erschwerend kommt hinzu, dass die Implementierungen aus den Java-Collections immer auch Funktionen bereit stellen, mit denen man Elemente löschen oder hinzufügen kann. Eine Variante mit nur lesendem Zufriff existiert nicht.
Eine klassiche Variante eines Datenobjektes mit einer Liste als Attribut sieht wie folgt aus:
public class DataWithList {
private final List<String> list;
public DataWithList(List<String> list) {
this.list = list;
}
public List<String> getList() {
return list;
}
}
Hier fallen dem geübten Auge mehrer Fallstricke auf. Aber der Reihe nach.
Veränderung der Daten nach der Weitergabe
Betrachten wir folgenden Test:
List<String> src = Lists.newArrayList("A","B","C");
List<String> fromObject = new DataWithList(src).getList();
assertEquals(3, fromObject.size());
src.remove(0);
assertEquals(2, fromObject.size());
Wenn jemand in der Liste, die wir uns in unserer Implementierung gemerkt haben, nachträglich einen Eintrag entfernt, verändert es auch die Liste, die an anderer Stelle weiterverarbeitet wird. Das ist beim betrachten unserer Implementierung auch nicht überraschend, denn wir reichen die Listeninstanz einfach weiter.
Lösung: wir fertigen uns von der Liste eine Kopie an:
public class DataWithListCopy {
private final List<String> list;
public DataWithListCopy(List<String> list) {
this.list = Lists.newArrayList(list);
}
public List<String> getList() {
return list;
}
}
Damit sollte im letzten Test das remove()
keine Wirkung entfalten.
Veränderung der Daten bei der Nutzung
Das erste Problem haben wir gelöst, aber es sind noch genügend Problemstellen vorhanden. Schauen wir uns daher folgenden Test an:
List<String> src = Lists.newArrayList("A","B","C");
DataWithListCopy container = new DataWithListCopy(src);
List<String> fromObject = container.getList();
assertEquals(3, fromObject.size());
fromObject.remove(0);
List<String> secondCall = container.getList();
assertEquals(2, secondCall.size());
Wenn wir jetzt aus der Liste, die wir aus unserer Implementierung bekommen, Einträge löschen, dann ändert das auch die Liste für jeden folgenden Aufrufer. Um die Liste, die wir uns in unserer Implementierung merken, gegen Veränderung zu schützen, gibt es folgende Möglichkeiten:
- Wir erstellen eine Kopie bei jedem Aufruf von
getList()
. - Wir nutzen die Möglichkeiten von
Collections.unmodifyableList()
.
Also so:
public class DataWithListCopies {
private final List<String> list;
public DataWithListCopies(List<String> list) {
this.list = Lists.newArrayList(list);
}
public List<String> getList() {
return Lists.newArrayList(list);
}
}
oder so:
public class DataWithListCopyAndUnmodifyable {
private final List<String> list;
public DataWithListCopyAndUnmodifyable(List<String> list) {
this.list = Lists.newArrayList(list);
}
public List<String> getList() {
return Collections.unmodifiableList(list);
}
}
Die Variante mit der Kopie der Liste hat den “Vorteil”, dass man für das nachträgliche Löschen von Elementen aus der Liste bereits diese Instanz verwenden kann. Aus den oben genannten Gründen würde ich aber immer davon abraten, wenn es nicht andere gewichtige Gründe gibt, die für so eine Variante sprechen.
eine bessere Variante
Wer genau hingeschaut hat, dem wird aufgefallen sein, dass die Implementierungen noch verbesserungswürdig sind. Es entstehen sehr schnell aufwendige Kopien und unnötige Instanzen. Wesentlich eleganter geht es mit den Immutable-Implementierungen von Guava:
public class DataWithListCopyAndImmutable {
private final ImmutableList<String> list;
public DataWithListCopyAndImmutable(List<String> list) {
this.list = ImmutableList.copyOf(list);
}
public ImmutableList<String> getList() {
return list;
}
}
In Projekten habe ich dabei gute Erfahrungen damit gemacht, dass der Rückgabewert von getList()
nicht
List<String>
ist, sondern ImmutableList<String>
ist. Auf diese Weise wird im Code zusätzlich sichtbar,
dass der Rückgabewert nicht verändert werden kann.
Naheliegend wäre noch der folgende zusätzliche Konstruktor:
public DataWithListCopyAndImmutable(ImmutableList<String> list) {
this.list = list;
}
Die Implemetierung von ImmutableList.copyOf()
sorgt selbst dafür, dass keine unnötigen Kopien erstellt
werden, wenn die übergebene Liste z.B. bereits eine Instanz von ImmutableList
ist, was diesen Konstruktor
überflüssig macht.
Unveränderliche Objekte
Vielleicht waren die Probleme im ersten Beispiel zu offensichtlich. Daher wenden wir uns einem einfacheren Szenario zu. Wir nutzen im folgenden Beispiel nur Typen als Parameter, die selbst bereits unveränderlich sind.
public class BaseNumber {
int value;
public BaseNumber(int value) {
this.value = value;
}
public int getValue() {
return value;
}
}
Eine offensichtliche Implementierung. Auf den ersten Blick kann man vielleicht kein Fehler erkennen. Man kann das Problem erahnen, wenn ich eine weitere Klassendefinition vornehme:
public class OtherNumber extends BaseNumber {
public OtherNumber(int value) {
super(value);
}
public void setValue(int value) {
this.value=value;
}
}
Eine Instanz einer abgeleiteten Klasse hat auf einmal ein Methode, mit der man den Wert von value
verändern
kann. Um das Problem zu verdeutlichen, hier etwas Testcode:
OtherNumber number = new OtherNumber(12);
BaseNumber numberInMethodCall = number;
assertEquals(12, numberInMethodCall.getValue());
number.setValue(13);
assertEquals(13, numberInMethodCall.getValue());
Für die eine Funktion sieht es so aus, als ob die Klasse immutable ist, aber an anderer Stelle gibt es die Möglichkeit, den Wert nachträglich zu verändern. Wir müssen also einen etwas höheren Aufwand treiben, um dafür zu sorgen, dass durch Unachtsamkeit das erwünschte Verhalten außer Kraft gesetzt wird.
Unveränderliche Objekte - Beispielimplementierung
Anhand des folgenden Beispiels möchte ich die wesentlichen Punkte, die es zu beachten gilt, zeigen und erklären:
public final class Person {
private final String firstName;
private final String lastName;
public Person(String firstName, String lastName) {
this.firstName = Preconditions.checkNotNull(firstName,"firstName is null");
this.lastName = Preconditions.checkNotNull(lastName,"lastName is null");
Preconditions.checkArgument(!firstName.isEmpty(),"firstName is empty");
Preconditions.checkArgument(!lastName.isEmpty(),"lastName is empty");
}
public String getFirstName() {
return firstName;
}
public String getLastName() {
return lastName;
}
}
Finale Klasse
Die Klasse ist als finale Klasse markiert, so dass man keine Ableitungen dieser Klasse erzeugen kann. Das verhindert, dass unerwünschtes Verhalten in einer abgeleiteten Klasse implementiert werden kann, auch wenn der Verwender die Instanz nur als Typ der Basisklasse sieht. Möchte man Vererbung einsetzen, weil das für den eigenen Anwendungsfall von Vorteil ist, sollte man die Klasse, von der man ableiten möchte abstrakt machen und alle Ableitungen als final markieren. Außerdem sollte man vermeiden, Funktionen anzubieten, die als Typ die abstrakte Basisklasse entgegen nimmt.
Finale Felder
Die Felder der Klasse müssen ebenfalls als final markiert sein. So kann der Compiler sicherstellen, dass dem Feld genau einmal ein Wert zugewiesen wird. In keinem Fall sollte man diese Anforderung aufweichen, wenn der Compiler anmerkt, dass es mehr als eine oder keine Zuweisung auf ein Feld gibt. Konstruktor Idealerweise gibt es genau einen Konstruktor. Auch wenn der Compiler dabei behilflich ist, die Fehler, die man sich mit mehreren Konstruktoren einfangen kann, auszumerzen, so erspart man sich doch einigen Ärger, wenn man darauf verzichtet. Es gibt bessere Alternativen (dazu später mehr).
Es empfielt sich außerdem, dass man die Werte, die im Konstruktor übergeben werden, soweit es geht auf
Gültigkeit prüft. Im einfachsten Fall ist das die Prüfung, ob der Parameter null
ist. In unserem Beispiel
prüfen wir beide Parameter auf null
. Außerdem prüfen wir, ob als Parameter eine leere Zeichenkette
übergeben wurde. Auf diese Weise sind Null-Prüfungen bei der Verwendung nicht mehr notwendig. Auch
wenn mit Java7 die Methode Objects.requireNonNull()
Einzug gehalten hat, empfehle ich doch eher die
Guava-Variante (wie sie im Code verwendet wird), weil dort auch andere häufig benutzte Prüfmöglichkeiten
implementiert wurden.
Die Prüfung im Konstruktor hat aber einen anderen wesentlichen Vorteil: wenn es doch passieren sollte, das
ein Parameter nicht den Erwartungen entspricht, dann deutet die Exception auf die Stelle im Code hin, wo
wir den fehlerhaften Wert erhalten haben. Sonst würde z.B. eine NullpointerException
an einer Stelle im
Code geworfen werden, aus der nicht mehr ersichtlich ist, wie es zu diesem fehlerhaften wert gekommen ist.
Attribute auslesen
In unserem Beispiel benutzen wir private Felder und lesende Methoden, die auf die Werte zugreifen. Alternativ
könnte man die Felder auch public
machen und auf diese Methoden verzichten. Dabei sollte man bedenken,
dass man kein Interface für Feldzugriffe definieren kann. Wenn das nicht notwendig ist, bleibt es eine
Geschmacksfrage.
Final? Aber sicher!
Kann man sich nach all den Ratschlägen nun darauf verlassen, dass die eigene Implementierung nun wirklich unveränderlich ist? Nicht ganz. Allerdings muss man etwas mehr Aufwand treiben, um in einer Implementierung, wie wir sie oben gesehen haben, nachträglich die Werte zu verändern. Das Zauberwort heißt Reflection. Und Zauberrei birgt die Gefahr, dass sie sich gegen den Zauberer richtet.
Schauen wir uns das an einem Beispiel an:
public final class ImmutableNumber {
private final int value;
public ImmutableNumber(int value) {
this.value = value;
}
public int getValue() {
return value;
}
}
In diesem Beispiel haben wir alle Empfehlungen umgesetzt. Der folgende Test zeigt, wie wir trotzdem den Wert in value verändern können:
ImmutableNumber instance = new ImmutableNumber(12);
assertEquals(12, instance.getValue());
Field valueField = instance.getClass().getDeclaredField("value");
valueField.setAccessible(true);
valueField.set(instance, 13);
assertEquals(13, instance.getValue());
Wir erzeugen einen Instanz mit dem Wert 12. Anschließend holen wir uns von der Instanz das Feld mit dem
Namen value. Der Aufruf von setAccessible(true)
sorgt dafür, dass wir auf das Feld zugreifen können,
obwohl es als privat markiert ist. Dann können wir einen neuen Wert mit set()
setzen. Um zu prüfen, ob
das von Erfolg gekrönt war, rufen wir noch einmal getValue()
auf. Auch wenn man den Zugriff auf private
Felder verhindern kann (mit entsprechender Konfiguration des SecurityManager
), ist die Markierung final
nur für den Compiler relevant. Zur Laufzeit ist die Markierung ohne jede Einschränkung.
Das Beispiel wirkt konstruiert. Wenn man allerdings bedenkt, welche in Projekten weit verbreiteten Frameworks die Möglichkeiten von Reflection benutzen, dann muss man wohl feststellen, dass man nicht mehr so einfach ausschließen kann, dass so etwas passiert.
Vorsicht vor Magie, man wird sie schwer wieder los.
Veränderliche Parameter
Manchmal liegen bestimmte Typen nicht als unveränderliche Implementierung vor. Dann sollte man nicht darauf verzichten, dafür zu sorgen, dass das gesamte Objekt unveränderlich ist.
public final class MutableParameterContainer {
private final Date date;
private final byte[] data;
public MutableParameterContainer(Date date, byte[] data) {
this.date = copyOf(date);
this.data = copyOf(data);
}
public byte[] getData() {
return copyOf(data);
}
public Date getDate() {
return copyOf(date);
}
private static byte[] copyOf(byte[] src) {
byte[] ret=new byte[src.length];
System.arraycopy(src, 0, ret, 0, src.length);
return ret;
}
private static Date copyOf(Date src) {
return new Date(src.getTime());
}
}
>
Zwei eher klassische Problemfälle sollen uns zeigen, welche Möglichkeiten man hat, damit umzugehen. Der
einfache Fall: java.util.Date
. Leider gibt es für Datumsbehandlung erst mit Java8 eine Implementierung,
die immutable ist. Daher müssen wir eine Kopie von dem Wert anlegen, der dem Konstruktor übergeben
wird und eine Kopie anfertigen, wenn wir den Wert wieder herausgeben. Das selbe gilt für Arrays, in diesem
Beispiel ein Array von byte
.
Allerdings ist das Kopieren von Daten eine relativ teure Aktion. Durch eine weitere Indirektion kann man die notwendige Maßnahme auf die Stellen im Code reduzieren, an denen die Daten tatsächlich benötigt werden und nicht nur durchgereicht werden.
Für ein Array von byte
erstellen wir eine eigene Implementierung, die man als Ersatz für byte[]
im Code
verwenden kann (z.B. als Parameter der vorherigen Implementierung):
public final class Bytes {
private final byte[] src;
public Bytes(byte[] src) {
this.src = copyOf(src);
}
public byte[] asByteArray() {
return copyOf(src);
}
private static byte[] copyOf(byte[] src) {
byte[] ret=new byte[src.length];
System.arraycopy(src, 0, ret, 0, src.length);
return ret;
}
}
Das Array wird immernoch kopiert, allerdings genau einmal, wenn die Instanz erzeugt wird, und ein weiteres mal, wenn der Code dann nicht auf die Instanz, sondern auf das eingebettete Array zugreifen muss.
Schnelle Lösungen
Nicht immer ist es einfach, diesen Aufwand zu rechtfertigen. Man kann sich trotzdem etwas behelfen, ohne das man größere Umbaumaßnahmen vornehmen muss. Dazu nehmen wir als Ausgang eine Implementierung, bei der die Attribute verändert werden können:
public class PoorMansImmutableImpl implements PoorMansImmutable {
String name;
int age;
@Override
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
Was auffällt: die Klasse implementiert ein Interface.
public interface PoorMansImmutable {
String getName();
int getAge();
}
In diesem Interface sind nur Methoden definiert, die lesend auf die Instanz zugreifen. Wenn man nun für den Rückgabewert als Typdeklaration das Interface benutzt, dann stellt der Compiler sicher, dass niemand schreibend auf die Instanz zugreifen kann. In einem Umfeld, in dem Reflection zum Einsatz kommt und man sicherstellen muss, dass keine Veränderungen an der Quellinstanz vorgenommen wird, kann per Reflection einen Proxy um das Objekt legen. Dabei läuft man natürlich Gefahr, dass man damit Fehler provoziert, für die man dann noch schwerer die Ursache finden kann.
Enums - immutable by default?
Mit der Einführung von Enums hat man sich daran gewöhnt, das Enums wie Konstanten angesehen werden können. Das folgende Beispiel soll zeigen, welche Fallstricke man hier leicht übersehen kann:
public enum MutableEnum {
One(1), Two(2), Five(3);
private int value;
private MutableEnum(int value) {
this.value = value;
}
public int asInt() {
return value;
}
public static MutableEnum buggyFindBy(int v) {
MutableEnum[] all=values();
int idx=0;
while (idx<all.length) {
MutableEnum cur=all[idx];
cur.value=v;
if (all[idx].value==v) {
return cur;
}
idx++;
}
return null;
}
}
In unserem Beispiel halten wir einen zusätzlichen Wert pro Konstante fest. Normalerweise erwartet man hier keine Methode, die den Wert von Value direkt verändert. Viel häufiger sind fehlerhafte Implementierungen, die versuchen eine Rückwärtsuche anhand eines Attributes zu realisieren. In unserem Beispiel haben wir einen Fehler versteckt. Ein Test bringt ihn ans Licht:
assertEquals(1, MutableEnum.One.asInt());
assertEquals(MutableEnum.One, MutableEnum.buggyFindBy(123));
assertEquals(123, MutableEnum.One.asInt());
Das unangenehme an diesem Fehler ist die Erwartungshaltung an den Code. Bei der Fehlersuche darauf zu kommen, dass die Ursache in diesem Stück Code zu suchen ist. Daher sollte man darauf achten, dass gerade auch bei Enums darauf geachtet wird, das alle Felder als final markiert sind.
Builder
Wenn es nicht zu vermeiden ist, dass man Objekte mit sehr sehr vielen Attributen erstellen muss, dann gibt es als Alternative zu einem Konstruktur mit allen Attributen die Möglichkeit, die Wertzuweisung über ein Builder zu realisieren. Man spart sich dabei nicht den Konstruktor und die Übergabe der Parameter, aber man macht den Code sehr viel lesbarer und muss trotzdem nicht auf eine unveränderliche Implementierung verzichten.
public final class SimpleBook {
private final String isbn;
private final String title;
private final int year;
private SimpleBook(String isbn, String title, int year) {
this.isbn = Preconditions.checkNotNull(isbn, "isbn is null");
this.title = Preconditions.checkNotNull(title, "title is null");
this.year = Preconditions.checkNotNull(year, "year is null");
}
public String getIsbn() {
return isbn;
}
public String getTitle() {
return title;
}
public int getYear() {
return year;
}
}
In diesem Beispiel hat der Konstruktor zwar nur drei Parameter, das Prinzip sollte trotzdem gut nachzuvol- lziehen sein. So kann eine Builder-Implementierung aussehen:
public class SimpleBookBuilder implements Builder<SimpleBook> {
private String isbn;
private String title;
private Integer year;
public SimpleBookBuilder isbn(String isbn) {
Preconditions.checkArgument(this.isbn == null, "isbn already set");
this.isbn = Preconditions.checkNotNull(isbn, "isbn is null");
return this;
}
public SimpleBookBuilder title(String title) {
Preconditions
.checkArgument(this.title == null, "title already set");
this.title = Preconditions.checkNotNull(title);
return this;
}
public SimpleBookBuilder year(int year) {
Preconditions.checkArgument(this.year == null, "year already set");
this.year = year;
return this;
}
@Override
public SimpleBook build() {
return new SimpleBook(this.isbn, this.title,
Preconditions.checkNotNull(this.year, "year not set"));
}
}
Was hier auffällt: Durch die Prüffunktionen soll ein mehrfacher Aufruf der selben Wertzuweisung verhindert werden. Dass alle notwendigen Parameter zugewiesen wurden, wird durch den Konstruktor sichergestellt.
Interessant ist vielleicht noch die Deklaration des Builder-Interface:
public interface Builder<T> {
T build();
}
Manchmal kommt auch bei unveränderlichen Objekten Vererbung zum Einsatz. Wenn man Code-Kopien vermeiden möchte, muss man ein paar Vorkehrungen treffen. Die Basisklasse sollte abstrakt sein, die dazu passende Builder-Implementierung auch.
public class AbstractCat {
private String color;
public AbstractCat(String color) {
this.color = color;
}
public String getColor() {
return color;
}
public static abstract class AbstractCatBuilder<B extends AbstractCatBuilder<B>> {
String color;
public B color(String color) {
this.color = color;
return (B) this;
}
}
}
Die Ableitungen implementieren jeweils auch ein abgeleiteten Builder:
public final class MyCat extends AbstractCat {
private final String nickname;
public MyCat(String color, String nickname) {
super(color);
this.nickname = nickname;
}
public String getNickname() {
return nickname;
}
public static MyCatBuilder builder() {
return new MyCatBuilder();
}
public static class MyCatBuilder extends AbstractCatBuilder<MyCatBuilder> implements Builder<MyCat> {
private String nickname;
public MyCatBuilder nickname(String nickname) {
this.nickname = nickname;
return this;
}
@Override
public MyCat build() {
return new MyCat(this.color, this.nickname);
}
}
}
Jetzt wird vielleicht auch die merkwürdige Signatur in der AbstractCatBuilder
-Klasse klar. Die Klasse
muss mit einem Typ parameterisiert werden, der eine Ableitung der Klasse darstellt. Das ist notwendig,
damit beim Aufruf der Funktionen der Basisklasse der korrekte Typ der Instanz zurückgegeben wird. Sonst
würde der erste Aufruf einer Methode der Basisklasse nicht eine Instanz von Typ MyCatBuilder, sondern von
AbstractCatBuilder zurückgeben. Der Test zeigt, dass das Ergebnis den Erwartungen entspricht:
MyCat cat = MyCat.builder()
.color("yellow")
.nickname("submarine")
.build();
assertEquals("yellow", cat.getColor());
assertEquals("submarine", cat.getNickname());
Attributanpassungen durch Instanzkopien
Um das Angebot abzurunden hier noch eine Variante, die dann ihre Stärken ausspielt, wenn man ausgehend von einem Satz von Daten Teile davon verändern möchte, ohne das man dazu alle anderen Werte von Hand aus den Quelldaten übernehmen möchte:
public final class FilterSettings {
private final Optional<Integer> minAge;
private final Optional<Integer> maxAge;
private final Optional<String> firstName;
private FilterSettings(Optional<Integer> minAge, Optional<Integer> maxAge,
Optional<String> firstName) {
this.minAge = minAge;
this.maxAge = maxAge;
this.firstName = firstName;
}
public FilterSettings() {
this(Optional.absent(), Optional.absent(), Optional.absent());
}
public Optional<String> getFirstName() {
return firstName;
}
public Optional<Integer> getMinAge() {
return minAge;
}
public Optional<Integer> getMaxAge() {
return maxAge;
}
public FilterSettings withFirstName(String firstName) {
return new FilterSettings(this.minAge, this.maxAge, Optional.of(firstName));
}
public FilterSettings withAgeBetween(int min, int max) {
return new FilterSettings(Optional.of(min), Optional.of(max), this.firstName);
}
public FilterSettings withoutFirstName() {
return new FilterSettings(this.minAge, this.maxAge, Optional.absent());
}
}
Die Wirkungsweise wird mit einem Beispiel klarer:
FilterSettings settings = new FilterSettings()
.withFirstName("Klaus")
.withAgeBetween(12, 43);
assertEquals(Integer.valueOf(12),settings.getMinAge().get());
assertEquals(Integer.valueOf(43),settings.getMaxAge().get());
assertEquals("Klaus",settings.getFirstName().get());
Es sieht so aus wie ein klassische Builder, es fehlt allerdings die build()
-Methode. Im folgenden Test zeigen
wir, wo der Unterschied zu suchen ist:
FilterSettings first = new FilterSettings();
FilterSettings second = first.withFirstName("Klaus");
assertFalse(first==second);
assertFalse(first.getFirstName().isPresent());
assertTrue(second.getFirstName().isPresent());
Jeder Aufruf einer Methode, deren Ziel es ist, ein Attribut zu verändern, liefert einen neue Instanz mit allen alten Werten und der Veränderung zurück. Wie man am Beispiel sehen kann, wird der Code mit zunehmender Anzahl von Attributen sehr schnell sehr aufwendig. Trotzdem sollte man sich diese Variante merken.
Fazit
Leider mangelt es Java an einer sprachlichen Unterstützung für unveränderliche Objekte, es fehlen Immutable- Implementierungen für die Collections, trotzdem lohnt sich der Aufwand, auf unveränderliche Datentypen zu setzen. Ich sehe gerade in der vereinfachten Fehlersuche den größten Vorteil von unveränderlichen Datentypen.
Da man diesen Code nur einmal schreibt, läuft man auch nicht Gefahr, dass die Aufwände aus dem Ruder laufen. In Kombination mit Vorbedingungen kann das sogar positive Effekte auf die Code-Qualität haben, da auf diese Weise immer ein kleiner Unit-Test mitläuft.