C++ vs. Java: Lambda ist nicht gleich Lambda

von Hubert Schmid vom 2014-05-04

C++ unterstützt Lambda-Ausdrücke seit C++11 und Java seit der vor einigen Wochen freigegebenen Version 8. Doch trotz gleicher Termini unterscheidet sich die Semantik der beiden Umsetzungen deutlich. Dieser Artikel widmet sich dem wesentlichen Unterschied, den Entwickler kennen sollten, die in beiden Sprachen unterwegs sind.

Beispiel

Die folgenden beiden Listings zeigen für C++ und Java jeweils einen Lambda-Ausdruck, der die vordere Hälfte einer Zeichenkette zurückgibt. Das C++-Beispiel verzichtet der besseren Vergleichbarkeit wegen auf die Qualifizierung mit std::. Auffallend ist einerseits die Ähnlichkeit der beiden Ausdrücke und andererseits die Kompaktheit von Java. Dabei ist der Unterschied rein syntaktisch. Vom Informationsgehalt her sind beide Versionen identisch.

// C++ function<string(string)> halve = [](auto&& s) { return s.substr(0, s.length() / 2); };// Java Function<String, String> halve = s -> s.substring(0, s.length() / 2);

Von der oberflächlichen Ähnlichkeit sollte man sich jedoch nicht täuschen lassen. Denn unter der Haube könnte sich die Semantik kaum stärker unterscheiden. Zunächst betrachte ich die C++-Version genauer, bevor ich mich anschließend der Java-Version widme.

C++

Das wichtige Leitmotiv in C++ lautet syntaktischer Zucker. Eigentlich handelt es sich bei obigem Lambda-Ausdruck lediglich um eine verkürzte Schreibweise der folgenden Klassendefinition, die anschließend instantiiert wird.

struct unnamed_class { template <typename T> auto operator()(T&& s) const { return s.substr(0, s.length() / 2); } }; function<string(string)> halve = unnamed_class{};

Die restliche Funktionalität verbirgt sich im Konstrutor von function. Das Funktionstemplate wird erst dort für string instantiiert, und der unbenannte Typ verschwindet durch sogenannte Type-Erasure. Insbesondere im Vergleich zu Java lässt sich diese Eigenschaft mit folgendem Listing verdeutlichen. Dabei wird der Lambda-Ausdruck zunächst mittels Typinferenz einer lokalen Variable zugewiesen. Zu diesem Zeitpunkt ist der Typ string noch unbekannt. Das Funktionsobjekt lässt sich dennoch bereits mit temp("foobar"s) aufrufen. Erst durch die zweite Anweisung wird der generische Lambda-Ausdruck auf string beschränkt.

auto&& temp = [](auto&& s) { return s.substr(0, s.length() / 2); }; function<string(string)> halve = temp;

Die Einführung der Lambda-Ausdrücke erfolgte in C++ praktisch minimal invasiv. Lediglich ein Detail musste am Typsystem angepasst werden, um auch Funktions-lokale Klassendefinitionen als Typparameter zuzulassen. Das erklärt auch, wie C++ trotz der gefühlten Schwerfälligkeit vergleichsweise schnell und unproblematisch Lambda-Ausdrücke einführen konnte.

Java

Java 8 geht bei der Unterstützung der Lambda-Ausdrücke einen ganz anderen Weg. Das folgende Listing verdeutlicht den Unterschied, indem es zeigt, was in Java nicht geht: Ersetzt man im obigen Beispiel den Typ auf der linken Seite der Variablen-Definition durch Object, so liefert der Compiler eine Fehlermeldung.

// ERROR: incompatible types: Object is not a functional interface Object halve = s -> s.substring(0, s.length() / 2);

Die Fehlermeldung ist irreführend, denn sie suggeriert, dass die Typen auf der linken und rechten Seite des Zuweisungsoperators nicht zusammen passen. Genauer müsste es lauten, dass der Zieltyp des Lambda-Ausdrucks – in diesem Fall Object – kein funktionales Interface ist, und somit der funktionale Typ des Lambda-Ausdrucks nicht bestimmbar ist. Daran ändert sich auch nichts, wenn man den Parametertyp explizit angibt, wie im folgenden Listing zu sehen.

// ERROR: incompatible types: Object is not a functional interface Object halve = (String s) -> s.substring(0, s.length() / 2);

Im Gegensatz zu C++ hängt der Typ eines Lambda-Ausdrucks in Java also vom Kontext ab, in dem er verwendet wird. Das sagt noch nichts über die Bedeutung dieses Verhaltens aus. Die Auswirkungen verdeutlicht jedoch ein genauerer Blick in die Sprachspezifikation. Die Bestimmung des Zieltyps beschränkt sich nämlich nicht auf so einfache Fälle wie oben, sondern unterstützt auch verschachtelte Ausdrücke sowie überladene und generische Methoden. Das folgende Listing zeigt ein Beispiel für überladene Methoden.

void foo(UnaryOperator<String> unary) { } void foo(BinaryOperator<Integer> binary) { } void test() { // OK: calls method with UnaryOperator foo(s -> s.substring(0, s.length() / 2)); }

Beide foo-Methoden verwenden als Parameter ein funktionales Interface. Doch der Compiler erkennt, dass für den Aufruf nur Ersteres in Frage kommen kann, und leitet daraus den Typ für den Paramter s ab. Eine andere Art von Typinferenz findet in Zusammenhang mit generischen Methoden statt. Im folgenden Listing liefert der erste Aufruf eine Fehlermeldung. Wird das Ergebnis des Aufrufs dagegen einer Variablen vom Typ String zugewiesen, so leitet der Compiler zunächst den Typparameter der Methode bar und damit indirekt den Typ des Parameters s ab.

<T> T bar(Function<T, T> function) { return function.apply(null); } void test() { // ERROR: cannot find symbol (method length(), variable s of type Object) bar(s -> s.substring(0, s.length() / 2)); // OK: s is String String t = bar(s -> s.substring(0, s.length() / 2)); }

Das wichtige Leitmotiv in Java lautet also Deduktion aus Zieltypen. Dabei werden Typinformationen in Ausdrücken und Anweisungen von außen nach innen transportiert – also entgegen der sonstigen Auswertelogik.

Fazit

In C++ bauen die Lambda-Ausdrücke auf den bereits vorhandenen Template-Mechanismen der Sprache auf, und kommen dadurch mit minimalen Erweiterungen der Sprache aus. Der syntaktische Zucker lässt sich relativ einfach auf die bekannten Konstrukte zurückführen, und damit auch erklären. Die Unterstützung für Lambda-Ausdrücke in Java greift dagegen sehr tief in die Sprache ein, um sie bequem an vielen Stellen nutzbar zu machen. Zahlreiche Regeln legen fest, wie Kontextinformationen den funktionalen Typ eines Lambda-Ausdrucks bestimmen. Die wichtigste Voraussetzung dafür ist die Deduktion von Zieltypen für die Argumente aller Ausdrücke – ein Mechanismus, den es in C++ nicht gibt, der dort allerdings auch nicht notwendig ist.