… & Kontext: Funktionsabschlüsse
Lambda-Ausdrücke sind an und für sich ja schon bemerkenswert, aber erst als Funktionsabschlüsse oder Closures offenbaren sie ihr eigentliches Potenzial.
- Michael Wiedeking
"Konsole & Kontext" ist eine gemeinsame Serie von Golo Roden und Michael Wiedeking, in der sich die beiden regelmäßig mit Konzepten der (funktionalen) Programmierung beschäftigen. Während "Konsole & …" die praktische Anwendung dieser Konzepte in JavaScript und Node.js zeigt, erläutert "... & Kontext" deren jeweiligen eher theoretischen Hintergrund.
Lambda-Ausdrücke sind ja eine feine Sache, ersparen sie uns doch – neben unzähligen Vorzügen, die hier unerwähnt bleiben sollen – eine Menge Schreibarbeit. Dennoch tun sich einige "Probleme" auf, die sich immer dann ergeben, wenn in Lambda-Ausdrücken Variablen aus der Umgebung verwendet werden.
Sollen etwa wie im nachfolgenden Beispiel die Zahlen in der Collection c verdoppelt werden:
c.map(x ⟼ 2 · x)
dann braucht der Lambda-Ausdruck sozusagen keine Hilfe von außen; alle benötigten Informationen kommen entweder über die Variable oder sind bereits im Ausdruck enthalten. Was ist aber, wenn dieser Ausdruck von außen beeinflusst werden muss?
a ⟵ 2
c.map(x ⟼ a · x)
Nun kommt der Faktor a von außen. Das Beispiel suggeriert allerdings, die Verdopplung komme dadurch zustande, dass zum Zeitpunkt des Aufrufs von map der Wert von a ausgewertet und durch 2 ersetzt wird. Diese Annahme kann dadurch motiviert sein, dass es aus objektorientierter Sicht wie eine Art Instanziierung mit a = 2 wirkt.
Aber dieser Eindruck trügt, denn der Ausdruck wird erst irgendwann in den Tiefen der map-Funktion aufgerufen. Es passiert also tatsächlich etwas mehr. Dem Lambda-Ausdruck wird nämlich seine Umgebung mitgegeben: Die Variable a wird an den Lambda-Ausdruck gebunden.
Das mag auf erste Sicht nicht besonders beeindruckend sein, werden doch die Werte immer noch verdoppelt. Der Unterschied wird aber klar, wenn man diese Variable verändert.
a ⟵ 2
f1 ⟵ x ⟼ a · x
a ⟵ 3
f2 ⟵ x ⟼ a · x
print("(", f1(1), ", ", f2(1), ")")
Die Ausgabe ist jetzt nicht wie vielleicht erwartet (2, 3), sondern (3, 3). Da der Lambda-Ausdruck die äußere Umgebung an sich bindet, steht ihm die Variable a auf diese Weise zur Verfügung. Zum Zeitpunkt der Zuweisung an f1, ist demnach völlig egal, welcher Wert in a enthalten ist. In der Zuweisung an f2 wird dann dieselbe Umgebung an den Lambda-Ausdruck gebunden.
Finden nun die Auswertungen statt, werden die Ausdrücke unter Zuhilfenahme der gebundenen Umgebungen ausgewertet. Beide verwenden dazu die Variable a aus derselben Umgebung. Und dort hat a zuletzt den Wert 3. Die Variable wird also erst zum Zeitpunkt des Aufrufs ausgewertet und nicht – wie man leicht annehmen könnte – zum Zeitpunkt der Definition.
Das ist ein unerwünschter Nebeneffekt bei nicht ganz so funktionalen Sprachen. Um diesem Problem aus dem Weg zu gehen, sind deshalb etwa in Java Lambda-Ausdrücke mit Variablen nicht zulässig. Dort müssen Variablen effektiv final sein und der Wert der Variablen darf nach dem Einsatz in einem Lambda-Ausdruck nicht mehr verändert werden.
Übrigens bleibt ein Funktionsabschluss (engl. Closure), diese Bindung an die umgebenden Variablen, auch bei Verlassen des definierenden Gültigkeitsbereichs erhalten. Wird also ein Lambda-Ausdruck etwa als Wert einer Funktion geliefert, ist jetzt auch die Lebensdauer der gebundenen Variablen über diesen Abschluss an die des Lambda-Ausdrucks gebunden.
Mit den Funktionsabschlüssen hat man so ein weiteres sehr mächtiges Hilfsmittel an die Hand bekommen. Auf diese Weise lassen sich nämlich Funktionen nach Belieben konfigurieren – wie Generics für Algorithmen. ()