… & Kontext: Lambda-Ausdrücke

So wichtig in den objektorientierten Sprachen ein Objekt ist, so unverzichtbar ist in den funktionalen Programmiersprachen ein Lambda-Ausdruck. Nun stellt sich – seit die Lambdas auch in den OO-Sprachen Einzug halten – natürlich die Frage, wie jene in der imperativen Welt die Art der Programmierung beeinflussen.

In Pocket speichern vorlesen Druckansicht 3 Kommentare lesen
Lesezeit: 11 Min.
Von
  • 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 Golo Roden in "Konsole & …" die praktische Anwendung dieser Konzepte in JavaScript und Node.js zeigt, erläutert Michael Wiedeking in „… & Kontext“ deren jeweiligen eher theoretischen Hintergrund.

In der Mathematik lässt sich eine Funktion recht einfach definieren. Zuerst bestimmt man Definitions- und Wertemenge der Funktion. Beispielsweise beschreibt

f : A ⟶ B

dass mit der Funktion f Elemente aus der Menge A auf Elemente der Menge B abgebildet werden. Wenn dies einmal geklärt ist, stellt sich nur noch die Frage, wie die eigentliche Abbildungsvorschrift aussieht.

f : x ⟼ 2 · x

In diesem Fall wird ein beliebiges x, was einem Element aus A entspricht, auf 2 · x abgebildet, in der Hoffnung, dass dies einem Element aus B entspricht. Dabei bedarf es anscheinend einer zweistelligen Operation „·“ – was nebenbei bemerkt auch eine Funktion ist –, die etwas mit „2“ und Elementen von A anfangen kann.

f(x)

liefert dann für ein beliebiges, aber konkretes x den verdoppelten Wert (in der Annahme, dass man bei 2 · x überhaupt von einer Verdopplung reden kann). Übrigens müssen im Allgemeinen die Mengen A und B nicht verschieden sein. In dem Beispiel etwa könnten die Mengen miteinander verwandt sein. A könnte die Menge der ganzen Zahlen und B die Menge der geraden Zahlen sein. Damit wäre B eine (echte) Teilmenge von A und man würde BA oder sogar BA schreiben, weil B mit B ≠ A sogar echte Teilmenge von A ist.

Für f würde dies wiederum bedeutet, dass die Funktion im Grunde nach Belieben ausgetauscht werden kann, wenn nur die Ergebnisse aus B sind. Das ist eben bei 2 · x der Fall, aber auch bei 4 · x und sogar allgemein bei k · x, wenn k eine gerade Zahl ist. Natürlich kann man auch komplizierte Abbildungsvorschriften, etwa 2 · x · x, solange nur mindestens einer der Faktoren gerade ist.

In der Informatik funktioniert so etwas natürlich auch. Hier spricht man allerdings nicht von Mengen sondern von Typen. Letztlich sind das auch irgendwelche Mengen, nur das in der Typdefinition noch sämtliche definierte Operationen mitschwingen. Ist etwa die Rede von einem „int“, dann weiß man in der Regel auch etwas über dessen Darstellung im Rechner, die darauf definierten Basisoperationen und so weiter.

Bei vielen Programmiersprachen sind Funktionen immer benannt. In Java beispielsweise beschreibt

int f(int x) {
return 2 * x;
}

eine Funktion namens f, bei der ein int auf ein int abgebildet wird. Leider haben wir weder in Java noch in den meisten anderen Sprachen die Möglichkeit, einfach auf den Umstand hinzuweisen, dass es sich bei dem Ergebnis um eine gerade Zahl handelt. Das müssen wir entweder dokumentieren (was bedeutet, dass es kaum einer liest oder sich kaum einer daran hält) oder doch sehr umständlich auf Objekte von Klassen umsteigen, wobei die Ergebnismenge etwa durch eine Klasse GeradeZahl repräsentiert werden könnte, deren Invariante nur gerade Zahlen zulässt.

Wie dem auch sei, die Benutzung der Funktion erfolgt vergleichbar zur mathematischen Variante. Wenn diese benannte Funktion f benutzt wird, wie in

f(3)

dann wird der aktuelle Parameter (hier: 3) übergeben, die Berechnung durchgeführt und das Ergebnis geliefert (hier: 6). Das Verhalten bei dem Aufruf von f ist immer gleich und kann nur dadurch geändert werden, indem man den Quellcode verändert und das Ganze neu übersetzt und erneut laufen lässt.

Letzteres ist allerdings etwa unpraktisch, und die Mathematiker tun sich wesentlich leichter, Funktionen einfach auszutauschen. Wäre das nicht schön, wenn das in einer Programmiersprache auch ginge?

Funktionale Programmiersprachen bieten auch Entsprechendes an. In diesem Fall definiert man einfach eine Variable, die einen entsprechenden Abbildungstyp hat:

f : Int ⟶ Int

In dieser fiktiven Programmiersprache wird also eine Variable f definiert, die eine Funktion enthalten kann, mit der man eine ganze Zahl auf eine ganze Zahl abbilden kann. Das ist vergleichbar zu

i : Int

womit eine Variable i definiert wird, die eine ganze Zahl enthalten kann. Während man in dieser fiktiven Sprache mit

i ← 5

eine Zuweisung machen kann, sodass die Variable i nun den Wert 5 enthält, so benötigt man natürlich Ähnliches für unsere Funktionsvariable. In Anlehnung an die mathematische Syntax erlaubt uns die Sprache tatsächlich eine entsprechende Zuweisung:

fx ⟼ 2 · x

Und damit liefert uns f(5) erwartungsgemäß den Wert 10 (wenn denn „·“ der normale Multiplikationsoperator ist).

Eine solche namenlose Abbildungsvorschrift nennt man gelegentlich Lambda-Ausdruck. „Namenlos“ (oder auch „anonym“) ist hier sehr wichtig, da es ohne eine Zuweisung – wie hier an die Variable f (eine Variable, die sehr wohl einen Namen hat) – keine Möglichkeit gibt, an diese hier definierte Abbildungsvorschrift heranzukommen. Es gibt keinen Namen oder Bezeichner, über den man – wie auch immer – in den Namensräumen und Gefilden eines Programms danach suchen könnte.

Aus Sicht des Programms ist es eigentlich irrelevant, was genau gemacht wird, denn es genügt zu wissen, dass ein Int in ein Int umgewandelt wird. Das eröffnet ganz neue Möglichkeiten – nun gut, neu nur für diejenigen, die noch nicht funktional programmiert haben. Aber das Konzept ist durchaus vergleichbar zur Objektorientierung: Nur die Methodenschnittstellen sind bekannt, nicht aber die konkrete Implementierung. Diese ist für die Anwendung verborgen und kann auch später noch (idealerweise ohne Neuübersetzung) geändert werden.

Bei der Objektorientierung kann gemäß dem nach Frau Liskov benannten Prinzips ein Objekt einer Oberklasse durch ein Objekt einer ableitenden Klasse ersetzt werden, wenn das ersetzende Objekt schnittstellenkonform ist. So kann auch ein Lambda-Ausdruck einen anderen ersetzen, solange es denselben Typ hat, also den richtigen Eingabetyp auf den gewünschten Ausgabetyp abbildet.

Will man beispielsweise eine Hilfsfunktion schreiben, mit der man für einen Betrag die Mehrwertsteuer berechnet, könnte man das wie folgt tun: Man übergibt dem Buchhaltungsprogramm einfach den Mehrwertsteuersatz p in Prozent, und wann immer dann die Mehrwertsteuer berechnen muss, wird etwas wie

brutto ← ((100 + p) · netto)/100

gemacht. Das reicht auch aus, bis man das Programm nach Pecunien ausliefern soll. Denn dort wird die Mehrwertsteuer nicht nur einfach mit einem Prozentsatz berechnet, sondern stufenweise logarithmisch. Das heißt, Beträge bis (einschließlich) 1 Pecu sind steuerfrei, bis 10 Pecu werden mit 1 Prozent versteuert, bis 100 Pecu mit 2 Prozent, bis 1000 Pecu mit 3Prozent, also allgemein bis einschließlich 10n Pecu mit n %.

Um das Programm von solch abstrusen Ausnahmefällen freizuhalten, bietet es sich an, dieses wie folgt anzupassen: Man definiert eine Funktion

mwst : Betrag ⟶ Betrag

und immer wenn jetzt die Mehrwertsteuer berechnet werden muss, wird einfach

brutto ← mwst(netto)

aufgerufen. Jetzt muss in dem Programm nur noch der Funktionsvariablen mwst die korrekte Berechnungsvorschrift zugewiesen werden:

if country = Deutschland then
    mwst ← (a) ⟼ ((100 + 19) · a) / 100
elseif country = Pecunien then
mwst ← (a) ⟼ (100 + ceil(log10(a)) · a) / 100
elseif

wobei ceil die kleinste ganze Zahl liefert, die größer oder gleich der gegebenen Zahl ist.

Das ist ein sehr mächtiges Mittel. Dieselbe Unabhängigkeit, die man im Zusammenhang mit Objekten nutzen konnte, lässt sich mit den Lambda-Ausdrücken auf rein funktionale Aspekte übertragen. Damit wird man etwa den Ballast los, den man bei der objektorientierten Programmierung billigend in Kauf genommen hat: Um die Implementierung einer Funktion tauschen zu können, bedarf es keines neuen Objekts (das erfahrungsgemäß ohnehin keine Daten enthält).

In vielen Programmiersprachen gibt es Lambda-Ausdrücke, obwohl sie da durchaus andere Namen haben; in C# zum Beispiel die Delegates. Ein Delegate erlaubt es, einen Verweis auf eine Methode mit konkreter Signatur zu definieren. Mit diesem Delegate kann durch Instantiierung oder Zuweisung eine Methode assoziiert werden, die dann bei Bedarf aufgerufen werden kann.

public delegate Betrag MwStFunction(Betrag a);

public
class DeutschlandMwSt
{
public Amount Apply(Amount a)
{
return new Amount(((100 + 19) * a) / 100.0);
}
}

MwStFunction mwst =
new
MwStFunction(DeutschlandMwSt.Apply);

brutto = mwst(netto);

Um ein Delegate zu bestücken (instanziieren), bedurfte es früher einer bereits existierenden (benannten) Methode (hier Apply aus der Klasse DeutschlandMwSt). Seit Neuerem gibt es aber auch in C# die Lambdas, die sozusagen eine syntaktische Versüßung mit sich bringen. Intern beruhen sie nach wie vor auf den Delegates, aber nun kann auf den Umweg über eine benannten Methode verzichtet und der benötigte Code in Form von Lambdas direkt an der Stelle angebracht werden, wo er benötigt wird.

MwStFunction mwst = 
(Amount a) => new Amount(((100 + 19) * a) / 100.0);

Wie in vielen Sprachen wird damit in C# syntaktisch ein Ortsproblem gelöst.

Die Programmiersprache Java, bei der sich der Einzug von Lambdas leider massiv verzögert hat und erst MItte nächsten Jahres zu erwarten ist, stellt überraschenderweise keinen vergleichbaren Funktionstyp zur Verfügung. Hier müssen Schnittstellen herhalten, die genau eine abstrakte Methode haben. Das ist insofern interessant, weil es zur Folge hat, dass mit den Lambdas zwar anonyme Funktionen definiert werden können, aber keine unbenannten Funktionstypen der Form AB. Für das Beispiel der Mehrwertsteuer ist also ein Interface nötig.

interface MwStFunction {
public Amount apply(Amount a);
}

MwStFunction mwst =
(Amount a) -> new Amount(((100 + 19) * a.doubleValue()) / 100.0);

brutto = mwst.apply(netto);

Unabhängig davon wie Lambdas syntaktisch in eine Sprache eingebettet sind, eröffnen sie – wie oben schon angedeutet – den OO-Programmierern ganz neue Möglichkeiten. Denn überall dort, wo ein "normaler" Typ stehen darf, kann nun auch ein Funktionstyp stehen. Ein Lambda-Ausdruck kann also wie ein Objekt (ohne den Objektbegriff über Gebühr strapazieren zu wollen) umhergereicht werden: als Eingabeparameter, als Ausgabewert oder gar als Ergebnis einer Berechnung – Elemente erster Klasse eben.

Diese Möglichkeiten bringen natürlich auch andere Denkmuster mit sich. Zum Glück muss das Rad dennoch nicht neu erfunden werden, denn die funktionale Programmierung stammt schon aus Zeiten, als es noch gar keine (elektronischen) Rechner gab. Unabhängig davon gibt es seit Jahrzehnten – eben seit es Programmiersprachen gibt – auch funktionale Programmiersprachen, auf deren Erfahrungen wir zurückgreifen können.

Und genau darum wird es auch beim nächsten Mal gehen, nämlich was man wie anders und auch besser machen kann. ()