Typinferenz für lokale Variablen in Java 10, Teil 1

Seite 2: Wo kann man 'var' einsetzen?

Inhaltsverzeichnis

Die Verwendung von var ist denkbar einfach: Man setzt es einfach anstelle des Typnamen ein. Da var nur in dieser Position seine Bedeutung entfaltet, ist es übrigens kein Schlüsselwort, sondern ein reservierter Typname. Das hat den Vorteil, dass Variablen und Methoden weiterhin var heißen dürfen – nur als Klassenname fällt es aus.

// compiles, but not recommended
var var = var();
// no longer compiles in Java 10
class var { }

Abgesehen von seiner eigentlichen Funktion, der Typinferenz, ist eine grundlegende Eigenschaft von var, dass es nur lokal agiert. Zwei Folgen davon kamen bereits zur Sprache:

  • Nur Deklarationen, die die Variable noch im gleichen Statement initialisieren, können var verwenden.Selbst wenn es eine eindeutig erste Zuweisung gibt, zum Beispiel direkt in der nächsten Zeile, bleibt diese unbeachtet.
  • Nur der Typ des einen Werts wird verwendet, um den Typ der Variable abzuleiten. Weitere Zuweisungen anderer Werte bleiben unbeachtet.

Zusammengefasst kann man sagen, der Compiler berücksichtigt nur das eine Statement. Warum? In vielen anderen Situationen, insbesondere wenn Generics involviert sind, ist die Typinferenz wesentlich komplizierter. Ginge das nicht auch hier? Ja, und technisch spricht zum Beispiel nichts dagegen, alle Zuweisungen in Betracht zu ziehen, um den Typ einer Variable zu bestimmen.

Der Grund, warum das nicht geschieht, ist der Wunsch, sogenannte "Action at a Distance"-Fehler zu vermeiden. Wenn nicht nur lokale Informationen zur Bestimmung des Typs verwendet werden, können Änderungen an einer Zeile im Quellcode Kompilierfehler an weit entfernten und schwer absehbaren Stellen hervorrufen. Zum Beispiel:

// inferred as 'int'
var id = 123;
if (id < 100) {
// very long branch; unfortunately not its own method call
} else {
// oh boy, much more code...
}

// now somebody adds this line - what happens?
id = "124";

Da var nur die erste Zuweisung berücksichtigt, gibt es einen Kompilierfehler in der hinzugefügten Zeile id = "124". Das ist gut: Der Fehler tritt bei der Änderung auf.

Würde der Compiler alle Zuweisungen berücksichtigen, müsste er nach dem Hinzufügen der neuen Zeile einen neuen Typ für id ableiten, vermutlich etwas wie Comparable oder Serializable (String und Integer implementieren beides). Dann würde aber der Vergleich id < 100 nicht mehr kompilieren, und plötzlich gäbe es einen Fehler weit weg von der Änderung, die vorher nicht wirklich absehbar war – unangenehm. Deswegen beschränkt sich der Compiler auf die Deklaration, um den Typ zu bestimmen.

Es ist generell hilfreich, wenn der Geltungsbereich von var nicht allzu groß ist. Das ist auch der Grund für einige weitere zentrale Einschränkungen bei der Verwendung von var. Der Name des dazugehörigen Java Enhancement Proposal JEP 286 gibt es bereits her: "Local-variable type inference". Dementsprechend ist var nur in folgenden Situationen erlaubt:

  • lokale Variablen, also solche, die innerhalb einer Methode oder eines Konstruktors definiert sind,
  • for-Schleifen,
  • try-with-resources-Blöcke (obwohl das seit Java 9 weniger wichtig ist, da eine bereits existierende Ressource nicht mehr zwangsläufig neu zugewiesen werden muss) und
  • Variablen in Lambda-Ausdrücken (ab Java 11).

In einem Beispiel zusammengefasst:

// var for local variables
var numbers = List.of("a", "b", "c");

// var in for loops
for (var nr : numbers)
System.out.print(nr + " ");
for (var i = 0; i < numbers.size(); i++)
System.out.print(numbers.get(i) + " ");

// var in try-with-resources
try (var file = new FileInputStream(new File("no-such-file"))) {
new BufferedReader(new InputStreamReader(file))
.lines()
.forEach(System.out::println);
} catch (IOException ex) {
// at least, we tried
System.out.println("There's actually no 'no-such-file'. :)");
}

// var in lambda expressions; only in Java 11+
UnaryOperator<String> appendSpace = (var a) -> a + " ";

Diese Anwendungsfälle haben gemein, dass die mit var deklarierten Variablen typischerweise nur sehr lokal verwendet werden – zumindest solange Methoden nicht zu lang werden. Andere Situationen, in denen ein Typ über eine Methode hinaus sichtbar ist, erlauben deswegen keine Typinferenz: Weder Felder noch Methodenparameter oder Rückgaben können mit var deklariert werden.

Ein anderer Grund, var bei Feldern und Methoden nicht zuzulassen, ist, dass Änderungen am Code sonst Laufzeitfehler auslösen können, die schwer absehbar sind. Wird zum Beispiel der Typ eines Methodenparameters geändert, etwa von List zu Collection, muss gegen die alte Variante kompilierter Code neu kompiliert werden. Ansonsten referenziert der Bytecode eine Methode mit einer Signatur, nämlich mit List als Parameter, die nicht existiert. Die Folge ist ein NoSuchMethodError.

Ändert man den Typ explizit, ist die Wahrscheinlichkeit groß, dass man das Problem im Blick hat, bevor es zur Laufzeit auftritt. Wäre var bei Methodenparametern erlaubt, könnte eine solche Typänderung aber implizit geschehen, wodurch sie viel leichter übersehen werden könnte.

Für die meisten Java-Entwickler sind Typen eine wichtige Informationsquelle, wenn es darum geht, Code zu analysieren und zu verstehen. Wird var verwendet, ist der Typ aber nicht mehr explizit im Quellcode festgehalten. Macht das den Code dann nicht unlesbarer? Und da Quelltext wesentlich öfter gelesen als geschrieben wird, bedeutet das nicht, dass var am falschen Ende spart?

Das hängt davon ab, wie man es benutzt. Prinzipiell sollte var nicht dazu dienen, um beim Schreiben ein paar Anschläge zu sparen – der Fokus sollte immer darauf liegen, die Lesbarkeit zu verbessern. Dabei hilft Typinferenz, indem es redundante oder wenig hilfreiche Informationen durch ein einfaches und kurzes Wort ersetzt. Das folgende Beispiel deklariert eine Reihe von Variablen:

URL codefx = new URL("http://codefx.org")
URLConnection connection = codefx.openConnection();
Reader reader = new BufferedReader(
new InputStreamReader(connection.getInputStream()));
readFirstLines(reader);

Die konkreten Typen sind allerdings nicht allzu relevant, denn dass codefx eine URL ist, macht der Konstruktoraufruf offensichtlich, dass connection eine URLConnection ist, kann man sich leicht denken, und bei reader steht der Typ wieder auf der rechten Seite. Die Typinformationen sind weitgehend redundant und damit beinahe nutzlos. Demgegenüber steht, dass sie den Blick auf das Wesentliche versperren: die Variablennamen. Wegen der unterschiedlich langen Typnamen "springen" diese von einer Zeile zur nächsten und sind damit auf den ersten Blick wesentlich schwieriger zu identifizieren. Mit var ist das lesbarer:

var codefx = new URL("http://codefx.org");
var connection = codefx.openConnection();
var reader = new BufferedReader(
new InputStreamReader(connection.getInputStream()));
readFirstLines(reader);

Obwohl alle drei Typnamen relativ kurz sind, ist der positive Effekt schon spürbar. Bei langen Domänennamen, Generics und Wildcards wird der Mehrwert noch deutlicher, und wenn Entwickler URL nicht durch var ersetzen wollen, sieht das bei InternationalCustomerOrderProcessor<? extends AnonymousCustomer, SimpleOrder<? extends Book>> vielleicht anders aus.

// with explicit types
No no = new No();
AmountIncrease<BigDecimal> more = new BigDecimalAmountIncrease();
HorizontalConnection<LinePosition, LinePosition> jumping =
new HorizontalLinePositionConnection();
Variable variable = new Constant(5);
List<String> names = List.of("Max", "Maria");

// with inferred types
var no = new No();
var more = new BigDecimalAmountIncrease();
var jumping = new HorizontalLinePositionConnection();
var variable = new Constant(5);
var names = List.of("Max", "Maria");

Mit var intensiviert sich der Fokus auf gute Variablennamen. Klassennamen sind wichtig, aber Klassen sind immer generelle Konzepte in einem weiten Kontext, sei es dem JDK, einem Framework oder einer Anwendung. Lokale Variablen hingegen haben einen spezifischen Kontext, der es erlaubt, wesentlich präzisere und ausdrucksstärkere Namen zu geben. Das sollte man auch machen, wenn Typnamen anwesend sind, aber ohne sie ist die Dringlichkeit und damit auch die Motivation dazu höher.

Die Lesbarkeit wird weiterhin durch die Tatsache gefördert, dass IDEs und andere Werkzeuge var als Schlüsselwort markieren, was es dem geschulten Auge einfacher macht, es zu ignorieren. Weil var sowohl beim Schreiben als auch beim Lesen Variablennamen stärker ins Zentrum rückt und es die explizite Angabe langer, unlesbarer Typnamen unnötig macht, ist ein sekundärer Effekt zu erwarten: mehr Zwischenvariablen.

Wer hat nicht schon eine längere oder geschachtelten Kette von Methodenaufrufen auseinandergenommen, um sie lesbarer zu machen, nur um dann festzustellen, dass die vielen neuen Variablen und deren teilweise unhandliche Typen die Situation nicht wirklich verbessert haben? Zum Beispiel bei Stream Pipelines passiert das häufiger. Mit var kann man solche Refactorings noch einmal neu angehen und mit ein, zwei geschickt gewählten Zwischenvariablen wird der Code lesbarer.

Wer jetzt noch skeptisch ist, ob es jemals einen guten Grund gibt, Typinformationen wegzulassen, sollte sich seine letzten Lambda-Ausdrücke anschauen. Ist da einer dabei, der die Bestimmung der Parametertypen dem Compiler überlässt?

// what type does 'message' have?
messages.removeIf(message -> message.startsWith("TRACE"));

Bei allen guten Argumenten und vielleicht auch allem Enthusiasmus für var sollte man aber im Auge behalten, dass übertriebener Einsatz der Lesbarkeit schadet. Wo genau die Grenze zwischen angemessener und übertriebener Verwendung verläuft, ist aber für jedes Team unterschiedlich.

Eine wichtige Rolle spielt dabei, mit welchen Programmen (außer IDEs) mit Code gearbeitet wird – zum Beispiel Code-Review-Werkzeuge. Im Gegensatz zu Entwicklungsumgebungen sind sie typischerweise nicht in der Lage, den Typ einer Variable abzuleiten und auf Wunsch anzuzeigen. Je wichtiger solche Werkzeuge im Entwicklungsprozess sind, desto offensichtlicher muss der Typ sein, den var ersetzt. Darüber hinaus spielen persönliche Vorlieben eine Rolle.

All das macht es sinnvoll, sich im Team abzustimmen, wie man var verwenden möchte. Idealerweise erarbeitet man mit einigen Für- und Gegenbeispielen aus der eigenen Codebasis ein paar Grundregeln für den Einsatz. So kann man verhindern, dass sich unterschiedliche Stile entwickeln und man am Ende an der var-Dichte erkennen kann, wer eine Methode geschrieben hat.