zurück zum Artikel

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

Nicolai Parlog
Typinferenz für lokale Variablen in Java 10, Teil 2

(Bild: Pixabay)

Für 'var' leitet der Compiler den Typ ab. Der kennt aber ein reichhaltigeres Typsystem, als die Sprache erlaubt, wodurch 'var' einige Tricks ermöglicht.

Typinferenz mit var [1] öffnet die Tür zu ein paar interessanten Tricks, bei denen Entwickler Variablen deklarieren können, obwohl sie keinen Typ haben, den man explizit ausdrücken könnte. Das heißt, obwohl Compiler und JVM einen konkreten Typ prinzipiell korrekt verarbeiten können, erlaubt die Syntax von Java nicht, ihn in einer Deklaration zu verwenden – mit var kann der Compiler ihn aber ableiten und korrekt in den Bytecode schreiben. Das können Entwickler zu verschiedenen Zwecken verwenden:

Wenn man einen Konstruktor aufruft, um eine neue Instanz zu erstellen, hat man die Chance, ein paar Felder oder Methoden hinzuzufügen, indem man zwischen Parametern und Semikolon geschweifte Klammern und ein wenig Code einfügt:

User user = // ...
Map<User, SimpleAddress> addresses = // ...
Object userWithAddress = new Object() {
User _user = user;
Optional<SimpleAddress> _address =
Optional.ofNullable(addresses.get(user));
};
// does not compile
System.out.println(userWithAddress._user);

Dabei erstellt der Compiler ad hoc eine anonyme Unterklasse (hier von Object) und instanziiert sie. Allerdings kann der Quellcode diese Klasse nicht referenzieren – sie ist ja namenlos – und man kann deswegen keine Variable mit diesem Typ deklarieren. Die Folge ist, dass man userWithAddress als Object deklarieren muss und userWithAddress._user nicht kompiliert – Object hat kein Feld _user.

Anders sieht die Sache mit var aus:

User user = // ...
Map<User, SimpleAddress> addresses = // ...
var userWithAddress = new Object() {
User _user = user;
Optional<SimpleAddress> _address =
Optional.ofNullable(addresses.get(user));
};
// compiles
System.out.println(userWithAddress._user);

Auf der rechten Seite der Zuweisung passiert beim Kompilieren dasselbe wie zuvor. Links allerdings kann der Compiler jetzt den Typ eintragen, den er soeben erstellt hat. Da dieser ein Feld namens _user hat, kompiliert auch der Zugriff userWithAddress._user.

Das obige Beispiel wirkt etwas gewollt – warum sollten Entwickler userWithAddress so deklarieren wollen? Ein möglicher Anwendungsfall findet sich in längeren Aufrufketten, zum Beispiel Stream Pipelines. Da ist die Situation häufig, neben dem eigentlichen Element vorübergehend ein anderes Datum zu benötigen. Aber mangels Tupeln ist das nicht einfach auszudrücken. Da können Ad-hoc-Felder ganz gelegen kommen:

List<User> users = // ...
Map<User, SimpleAddress> addresses = // ...
Optional<User> user = users.stream()
.map(user -> new Object() {
User _user = user;
Optional<SimpleAddress> _address =
Optional.ofNullable(addresses.get(user));
})
.filter(o -> o._address.isPresent())
.filter(o -> isValid(o._address.get()))
.map(o -> o._user)
.findAny();

Spannenderweise benötigt das Beispiel nicht mal var, weil der anonyme Typ nur innerhalb der Stream Pipeline existiert. Folgerichtig kompiliert und läuft das Beispiel auch auf Java 8. Wo var hilft, ist bei der Deklaration von Zwischenvariablen, zum Beispiel wenn man die Adresse gerne behalten möchte:

List<User> users = // ...
Map<User, SimpleAddress> addresses = // ...
var user = users.stream()
.map(user -> new Object() {
User _user = user;
Optional<SimpleAddress> _address =
Optional.ofNullable(addresses.get(user));
})
.filter(o -> o._address.isPresent())
.filter(o -> isValid(o._address.get()))
.findAny();

Ohne das zweite map ist user ein Optional<anonymous class with two fields>, was sich ohne var nicht ausdrücken ließe.

Immer, wenn man neue Tricks entdeckt, sollte man sich allerdings fragen, ob man sie auch einsetzen sollte. In diesem Fall rät der Autor davon ab. Es gibt bestimmt Situationen, in denen es keine bessere Lösung gibt, aber man sollte sie nicht als normales Werkzeug am Entwicklergürtel betrachten.

Zum einen ist das Erstellen einer anonymen Klasse relativ umfangreich, da ihre Felder an Ort und Stelle zu deklarieren sind. Zum anderen ist die Mischung von anonymen Klassen und Typinferenz, beides nicht gerade Einsteigerthemen, nicht trivial und reduziert die Verständlichkeit des Codes.

Am meisten stört aber, dass das Konstrukt einigen einfachen Refactorings nicht standhält. Wenn im vorherigen Beispiel user in einem halben Dutzend Zeilen weiterverarbeitet wird, könnten aufmerksame Kolleginnen auf die Idee kommen, die Stream Pipeline und die Weiterverarbeitung in zwei Methoden zu trennen, um den Code lesbarer zu machen. Prinzipiell die richtige Idee, aber der var-Trick verhindert das sonst mechanische Refactoring, weil das Ergebnis der Pipeline keinen Typ hat, den man für den Rückgabewert der neuen Methode gebrauchen könnte:

List<User> users = // ...
Map<User, SimpleAddress> addresses = // ...
// 'determineUser' must be declared to return a concrete
// type, so no amount of 'var' magic is gonna help here
/*...*/ user = determineUser(users, addresses);
processUser(user);

Um das Refactoring durchzuziehen, wäre also eine Menge Code zu ändern, was die Wahrscheinlichkeit senkt, dass es jemand tatsächlich durchführt. Der Erfahrung nach sollten Aufräumarbeiten nicht schwerer als unbedingt nötig sein. Vor dem Hintergrund ist dieser Ansatz also zu vermeiden.

Alternativ können Entwickler für Paare Map.Entry verwenden, den man seit Java 9 mit der lesbaren, statischen Methode Map::entry erstellen kann. Wem das nicht genügt, sollte sich nach einer Bibliotheken umschauen, die Tupel mitbringt oder auf eine zukünftige Java-Version warten, in der es Record und/oder Value Types gibt.

Ähnlich wie Felder lassen sich auch Methoden hinzufügen:

var corp = new Megacorp(/* ... */) {
final BigDecimal SUCCESS_BOUNDARY = new BigDecimal("500000000");

boolean isSuccessful() {
return earnings().compareTo(SUCCESS_BOUNDARY) > 0;
}

boolean isEvil() {
return true;
}
};

Wäre corp als Megacorp deklariert, stünden die neuen Methoden isSuccessful und isEvil nicht zur Verfügung. Mit var schon:

var corp = // like before
System.out.printf(
"Corporation %s is %s and %s.\n",
corp.name(),
corp.isSuccessful() ? "successful" : "a failure",
corp.isEvil() ? "evil" : "a failure"
);

Das Prinzip ist identisch mit den Ad-hoc-Feldern. Die Kritik ebenfalls: Lesbarkeit und Veränderbarkeit des Codes leiden ohne nennenswerten Gegenwert. In diesem Fall könnten Methoden wie isSuccessful oder isEvil einfach direkt Teil der Klasse oder einer Unterklasse sein oder, wenn das nicht möglich oder wünschenswert ist, als statische Helfermethoden implementiert werden. Das würde es auch ermöglichen, sie an anderer Stelle einfach wiederzuverwenden.

Gelegentlich ist man in der Situation, dass ein Methodenparameter zwei Interfaces implementieren soll:

static <E> Optional<E> firstMatch(
/* something which is Iterable<E> and Closeable */ elements,
Predicate<? super E> condition)
throws IOException {
try (elements) {
return stream(elements)
.filter(condition)
.findAny();
}
}

Hier soll über elements iteriert werden (deswegen Iterable) und die Quelle nachher über den Try-with-resources-Block geschlossen werden (deswegen Closeable). Der Parameter elements soll also sowohl den Typ Iterable als auch Closeable haben oder, in anderen Worten, der Typ von elements ist genau die Überschneidung der Typen Iterable und Closeable – daher der Ausdruck Intersection Type.

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

Venn-Diagramm zum Konzept Intersection Types

Wie kann man das in Java ausdrücken? Der übliche Ansatz ist, ein neues Interface einzuführen, dass die benötigten erweitert:

public interface CloseableIterator<E> extends Closeable, Iterator<E> { }

static <E> Optional<E> firstMatch(
CloseableIterator<E> elements,
Predicate<? super E> condition)
throws IOException {
// ...
}

Das Problem dabei ist, dass existierende Klassen aus Bibliotheken, Frameworks oder dem JDK nichts von diesem Interface wissen und man sie deswegen nicht mit der neuen Methode verwenden kann – selbst wenn sie die eigentlich benötigten Interfaces implementieren:

// 'Scanner' implements 'Iterator<String>' and 'Closeable'
Scanner scanner = new new Scanner(System.in);
Optional<String> dollarLine =
// compile error because 'scanner' is no 'CloseableIterator'
firstMatch(iterator, s -> s.startsWith("$"));

Das ist ärgerlich und es gibt in Java keine naheliegende Lösung dafür. In manch anderen Sprachen kann man Intersection Types direkt deklarieren. In Java könnte das vielleicht so aussehen, dass man Iterable<E> & Closeable elements anstelle von CloseableIterator<E> schreibt. Ganz so einfach ist es nicht, aber Generics bilden die Brücke dahin:

private static <E, T extends Iterator<E> & Closeable> Optional<E>
firstMatch(T elements, Predicate<? super E> condition)
throws IOException {
// ...
}

Wie gesagt, nicht naheliegend, aber es funktioniert. Hier kommen sogenannte Bounded-Type-Parameter zum Einsatz, um auszudrücken, dass elements vom Typ T sein soll, der wiederum sowohl Iterator<E> als auch Closeable implementieren muss. T ist also der Schnitt, die Intersection, aus Iterator<E> und Closeable.

So weit, so gut – das geht alles schon lange vor Java 10. Wo bleibt var? Wie zuvor auch kommt var ins Spiel, wenn eine Variable zu deklarieren ist. Analog zu firstMatch kann man folgende Factory-Methode schreiben:

static <T extends Iterator<String> & Closeable> T openCloseableIterator() {
return (T) new Scanner(System.in);
}

Das Problem dabei ist, dass die naheliegende Instruktionskette "rufe openCloseableIterator auf, weise das Ergebnis einer Variable zu und reiche die an firstMatch weiter" so nicht trivial aufzuschreiben ist. Deklariert man das Ergebnis von openCloseableIterator entweder als Iterator<String> oder als Closeable, beschwert sich der Compiler beim Aufruf von firstMatch, dass das jeweils andere Interface nicht implementiert ist. Wie bei Parametern ist die direkte Deklaration von Iterator<String> & Closeable in Java nicht erlaubt. Allerdings funktioniert der gleiche Trick mit Bounded-Type-Parametern:

static <T extends Iterator<String> & Closeable> void readAndPrint()
throws IOException {
T iterator = openCloseableIterator();
Optional<String> dollarLine =
firstMatch(iterator, s -> s.startsWith("$"));
System.out.println(dollarLine);
}

Man muss allerdings sagen, dass der Spaß mit Generics nun langsam aufhört. In openCloseableIterator und firstMatch ergibt die öffentlich sichtbare, generische Deklaration von T Sinn, denn sie schränkt einen Rückgabe- beziehungsweise Parametertyp ein. Aber bei readAndPrint taucht T in der Signatur gar nicht mehr auf. Das ist verwirrend für den Aufrufer. Ganz zu schweigen davon, was man machen muss, wenn es eine zweite solche Variable braucht.

Und hier kommt endlich var ins Spiel: Wie schon zuvor kann man damit leicht Variablen deklarieren, die einen Typ haben, der in der Java-Syntax nicht ausdrückbar ist:

static <T extends Iterator<String> & Closeable> void readAndPrint()
throws IOException {
var iterator = openCloseableIterator();
Optional<String> dollarLine =
firstMatch(iterator, s -> s.startsWith("$"));
System.out.println(dollarLine);
}

Die gesamte Konstruktion von Intersection Types ist ähnlich wie Ad-hoc-Felder und -Methoden nicht trivial und lebt vom Zusammenspiel komplexer Java-Features, hier Bounded-Type-Parameter und Typinferenz. Im Gegensatz zu den anderen beiden Anwendungen gibt es hier jedoch manchmal einfach keine Alternative. Darüber hinaus sind Intersection Types ein Konzept, das in vielen Programmiersprachen von Bedeutung ist, sodass sich damit auseinanderzusetzen ein echter Mehrwert für jeden Entwickler ist.

Wer also zu gelegentlich zu Intersection Types greifen muss, wird sich freuen, dass var die Verwendung an manchen Stellen deutlich vereinfachen kann. In solchen Situationen kann man es dann auch ohne schlechtes Gewissen einsetzen.

Zu guter Letzt kommen wir zu einem weiteren Feature einiger Programmiersprachen, das sich in Java nun dank var (umständlich) ausdrücken lässt: Mixins. Dabei geht es darum, ad hoc eine Variable zu erstellen, die die Funktionen verschiedener Typen vereint. Konzeptionell könnte das so aussehen:

type Megacorp {
String name();
BigDecimal earnings();
}

type IsSuccessful {
boolean isSuccessful() {
return earnings().compareTo(SUCCESS_BOUNDARY) > 0;
}
}

type IsEvil {
boolean isEvil() { return true; }
}

Megacorp & IsSuccessful & IsEvil corp =
new (Megacorp & IsSuccessful & IsEvil)(/*...*/);
System.out.printf(
"Corporation %s is %s and %s.\n",
// relying on 'corp' as 'Megacorp'
corp.name(),
// relying on 'corp' as 'IsSuccessful'
corp.isSuccessful() ? "successful" : "a failure",
// relying on 'corp' as 'IsEvil'
corp.isEvil() ? "evil" : "a failure"
);

Das ist zunächst recht weit von Java weg, aber mit ein paar Generics-basierten Tricks kann man dahin kommen. Das Grundprinzip ist, einen Lambda-Ausdruck zu verwenden, der auf den gewünschten Intersection Type gecastet und einer var-Variable zugewiesen wird:

// compiler infers desired intersection type for 'corp'
var corp = (MegacorpDelegate & IsSuccessful & IsEvil) () -> megacorp;

Was genau geht da vor? Zunächst einmal muss der zentrale Typ, in diesem Fall Megacorp, ein Interface sein. Um eine Instanz davon mit einem Lambda-Ausdruck erstellen zu können, braucht man außerdem ein delegierendes Interface, das alle Aufrufe an eine gegebene Instanz weiterleitet:

interface Megacorp {
String name();
BigDecimal earnings();
}

@FunctionalInterface
interface MegacorpDelegate extends Megacorp {
Megacorp delegate();
default String name() { return delegate().name(); }
default BigDecimal earnings() { return delegate().earnings(); }
}

Dabei ist wichtig, dass delegate() die einzige abstrakte Methode ist, sodass man für eine gegebene Megacorp-Instanz megacorp mit () -> megacorp eine Instanz von MegacorpDelegate erstellen kann. Damit funktioniert schon mal der erste Teil der obigen Zuweisung:

Megacorp megacorp = // ...
// compiler infers 'MegacorpDelegate' for 'corp'
var corp = (MegacorpDelegate) () -> megacorp;

Jetzt kann man auf Megacorp aufbauend beliebige Interfaces erstellen, die allerlei erdenkliche Zusatzfunktionalität haben – solange sie sich ausschließlich mit Default-Methoden, das heißt auf Basis der Megacorp-Funktion, implementieren lassen.

interface IsSuccessful extends Megacorp {
final BigDecimal SUCCESS_BOUNDARY = new BigDecimal("500000000");

default boolean isSuccessful() {
return earnings().compareTo(SUCCESS_BOUNDARY) > 0;
}

}

interface IsEvil extends Megacorp {

default boolean isEvil() {
return true;
}

}

IsSuccessful und IsEvil kann man nicht mit einem Lambda-Ausdruck erstellen, da sie keine abstrakten Methoden haben. Auf der anderen Seite muss man deswegen aber auch keine Methode implementieren, um ihren Vertrag zu erfüllen, und deswegen kann man sie mit einem durch einen Lambda-Ausdruck erstellten Typ schneiden:

// compiles and runs
Megacorp megacorp = // ...
var corp = (MegacorpDelegate & IsSuccessful & IsEvil) () -> megacorp;
System.out.printf(
"Corporation %s is %s and %s.\n",
// relying on 'Megacorp'
corp.name(),
// relying on 'IsSuccessful'
corp.isSuccessful() ? "successful" : "a failure",
// relying on 'IsEvil'
corp.isEvil() ? "evil" : "a failure"
);

Ist eine Megacorp-Instanz gegeben, kann man an Ort und Stelle entscheiden, welche anderen Features man zu einer neuen Instanz zusammenmischen möchte. Und zwar ohne, dass der Ersteller von Megacorp oder MegacorpDelegate davon wissen müsste. Das erlaubt es, unabhängig von existierenden Typen Zusatzfunktionen für sie zu entwickeln, die man im passenden Kontext einbringen und anschließend auf natürliche Art und Weise mit Methodenaufrufen auf der Instanz ansteuern kann.

Nachdem das "Wie" geklärt ist, stellt sich wieder die Frage nach dem "Ob". Lohnt sich das wirklich? Zunächst ist der Aufwand nicht unerheblich, denn passend zum existierenden Interface, hier Megacorp, ist ein ...Delegate-Interface zu erstellen, das alle Methoden weiterleitet.

Der Todesstoß für diesen Ansatz ist aber, dass Default-Methoden keine Methoden aus Object implementieren können. Dadurch kann zum Beispiel ein Aufruf von equals auf einer delegierenden Instanz nicht zum darunterliegenden Objekt weitergeleitet werden. Gemischte Instanzen sind immer auf equals, hashCode, toString etc. von Object festgenagelt, ohne dass das "von außen" ersichtlich ist.

Dass dann auch noch die Mischung nichttrivialer Sprachfeatures und die Einschränkung, dass man die Mixins nur mit den beschränkten Möglichkeiten von Interfaces erstellen kann, dagegen sprechen, fällt da schon fast nicht mehr ins Gewicht. Deswegen ein Ratschlag: So schön Mixins in anderen Sprachen auch sein mögen und so viel Spaß Experimente machen, in Code, der in Produktion läuft, hat dieser Trick nichts zu suchen.

Alternativ kann man auch hier, wie bei den Ad-hoc-Methoden, die gewünschte Funktionen in Helferklassen oder einer designierten Unterklasse sammeln.

Die meisten Tricks, die var erlaubt, kommen bei näherer Betrachtung nicht gut weg, da sie potenziell komplizierte Sprachfeatures mischen und dabei keinen deutlichen Vorteil liefern. Einzig Intersection Types können regelmäßig die beste zur Verfügung stehende Lösung sein.

Nicolai Parlog [2]
ist selbständiger Softwareentwicker, Autor und Trainer. Er lebt in Karlsruhe und bloggt auf codefx.org.
(bbo [3])


URL dieses Artikels:
https://www.heise.de/-4118051

Links in diesem Artikel:
[1] https://www.heise.de/hintergrund/Typinferenz-fuer-lokale-Variablen-in-Java-10-Teil-1-4095599.html
[2] https://twitter.com/nipafx
[3] mailto:bbo@ix.de