Anonyme Methoden und Lambda-Ausdrücke

In diesem Artikel geht es um anonyme Methoden und wie in diesem Zusammenhang das Programmierparadigma Closure angewendet wird. Darauf aufbauend behandeln wir, was ein Lambda-Ausdruck ist und wo dieser eingesetzt wird. Im letzten Abschnitt habe ich ein etwas umfassenderes, aber praxisnahes Beispiel vorbereitet, das die Vorzüge der Lambda-Ausrücke in Verbindung mit der in C# integrierten Abfragesprache LINQ demonstriert.


1. Was ist eine Anonyme Methode

Anonyme Methoden werden immer dort verwendet, wo direkt eine kurze Funktion benötigt wird, die dem Ergebnis der höheren Methode nützt. Somit ist in der höheren Methode sofort erkennbar, um welche Gesamtfunktionalität es sich handelt, ohne gedanklich in den Kontext einer weiteren Methode zu wechseln. Will man ein Themengebiet einer Programmiersprache erläutern, ist es am sinnvollsten, dieses an der Programmiersprache selbst zu machen. Beginnen wir also mit einem Beispiel. Im Folgenden seht ihr eine Liste mit Planeten, die auf der Console ausgegeben werden soll.

var planetenImSonnensystem = new List<string>()
                             {
                "Merkur", "Venus", "Erde", "Mars",
                "Jupiter", "Saturn", "Uranus", "Neptun",
                             }                 

Um die Liste auf der Console auszugeben, verwenden wir nun eine anonyme Methode. Diese hat keinen Bezeichner und ist ein Anweisungsblock, der innerhalb einer Methode definiert wird. Oft spricht man hier auch von einer inneren Methode. Vorsicht: Lokale Funktionen sind etwas Anderes. Da anonyme Methoden keinen Bezeichner haben, werden sie in der Regel über einen Delegaten referenziert. Im nächsten Code-Block seht ihr eine anonyme Methode, die die Liste der Planeten auf der Console ausgibt.

Action<List<string>> anonymeMethode = delegate(List<string> planetenListe)
                                      {
                foreach(var planet in planetenListe)
                                          {
                                              Console.WriteLine(planet);
                                          }
                                      };

Links vom Zuweisungsoperator = wird die Variable anonymeMethode vom Typ Action<T> deklariert. Action<T> ist ein generisch parametrisierter Delegat, der vom .NET Framework mitgeliefert wird. Diesem Delegaten wird eine anonyme Methode zugewiesen. Eingeleitet wird sie von dem Schlüsselwort delegate, dann folgt in runden Klammern die Parameterliste. Der Typ des Parameters wird vom Delegaten abgeleitet. Für einen eventuellen Rückgabetypen würde das Gleiche gelten. Der Methodenrumpf beinhaltet, wie in anderen Methoden auch, einen prozeduralen Code. Das Semikolon hinter der letzten geschweiften Klammer gehört zur Anweisung und nicht zur anonymen Methode. Für eine Ausgabe wird der Delegat aufgerufen und die Liste mit Planeten übergeben.

anonymeMethode(planetenImSonnensystem);

Die obigen Beispiele dienen nur dem Verständnis anonymer Methoden. In der Praxis wäre eine Schleife oder eine separate Methode zur Ausgabe der Liste eher anzutreffen.

Merke:

Eine anonyme Methode

  • wird innerhalb einer umgebenden Methode definiert.
  • hat keinen Bezeichner und wird über einen Delegaten referenziert.
  • kann einen Rückgabewert und/oder Parameter haben, die von dem referenzierten Delegaten abgeleitet werden.
  • kann einer Variablen, einem Parameter oder einem Feld zugewiesen werden.
  • wird in der Regel über den endsprechenden Delegaten aufgerufen.

2. Was ist Closure

Sehen wir uns jetzt eine Technik an, die im Zusammenhang mit anonymen Methoden und Lambda-Ausdrücken oft zu sehen ist. Es handelt sich um ein Programmierparadigma, welches ursprünglich aus der funktionalen Programmierung stammt. Ein Closure ist eine Verbindung der anonymen Methode mit dem Kontext der um- bzw. abschließenden Methode. Das gilt auch außerhalb des Gültigkeitsbereichs der umschließenden Methode. Wer hier tiefer einsteigen möchte, dem kann ich den englischsprachigen sowie den deutschsprachigen Wikipedia Artikel zu Closure empfehlen. In C# müsste es dann eher Methodenabschluss heißen. Sehen wir uns zunächst den folgenden Quellcode an.

static void Main()
{
                int meineZahl = 77;
    Console.WriteLine();
    Console.WriteLine($"Die Variable \"meineZahl\" vor allen Aufrufen:.......................{meineZahl}");
    Console.WriteLine("---------------------------------------------------------------------");

    Action anonymeMethode1 = delegate
    {
        meineZahl++;
        Console.WriteLine($"In der anonymen Methode (mit Closure) nach der Inkrementierung:....{meineZahl}");
    };
    anonymeMethode2();
    Console.WriteLine($"Außerhalb und nach der anonymen Methode (mit Closure):............{meineZahl}");
    Console.WriteLine("---------------------------------------------------------------------");

    Action<int> anonymeMethode2 = delegate(int zahl)
    {
        zahl++;
        Console.WriteLine($"In der anonymen Methode (ohne Closure) nach der Inkrementierung:...{zahl}");
    };
    anonymeMethode3(meineZahl);
    Console.WriteLine($"Außerhalb und nach der anonymen Methode (ohne Closure):...........{meineZahl}");
    Console.WriteLine("---------------------------------------------------------------------");

    ErhöheMeineZahl(meineZahl);
    Console.WriteLine($"Außerhalb und nach ErhöheMeineZahl():............{meineZahl}");
}

private static void ErhöheMeineZahl(int meineZahl)
{
    meineZahl++;
    Console.WriteLine($"In ErhöheMeineZahl() nach der Inkrementierung:....{meineZahl}");
}
// Das Beispiel erzeugt folgende Ausgabe:
Die Variable "meineZahl" vor allen Aufrufen:.......................77
---------------------------------------------------------------------
In der anonymen Methode (mit Closure) nach der Inkrementierung:....78
Außerhalb und nach der anonymen Methode (mit Closure):............78
---------------------------------------------------------------------
In der anonymen Methode (ohne Closure) nach der Inkrementierung:...79
Außerhalb und nach der anonymen Methode (ohne Closure):...........78
---------------------------------------------------------------------
In ErhöheMeineZahl() nach der Inkrementierung:....79
Außerhalb und nach ErhöheMeineZahl():............78

In diesem einfachen Beispiel wird meineZahl jeweils um eins erhöht, einmal mit und einmal ohne Closure in den anonymen Methoden und ein drittes Mal in einer benannten Methode ErhöheMeineZahl() . Es fällt auf, dass anonymeMethode1 keine Parameter hat. Stattdessen wird in ihr direkt auf meineZahl zugegriffen. Die Methode Main() stellt hier die umschließende Methode dar. Angenommen Main() wäre irgendeine Methode und ich würde den Delegaten nicht in dieser Methode aufrufen, dann könnte man argumentieren, die Methode wäre im Stack wieder abgebaut und die Variable meineZahl hätte keine Gültigkeit mehr. Um das zu verhindern, wird meineZahl mit anonymeMethode1 fest verbunden und das Ganze nennt man Closure. Es macht einen Unterschied, ob Parameter an die anonyme Methode übergeben werden oder ob Closure genutzt wird. Bei Closure wird immer call by reference übergeben. Wertetypen werden nicht per call by value übergeben. D.h., wird eine Variable wie in unserem Beispiel 77 in der anonymen Methode mit Closure inkrementiert, so wird sie auch in der umgebenden Methode inkrementiert. Das Beispiel befindet sich im Quellcode zum Projekt.

Um diese Funktionalität zu gewährleisten und damit es der Softwareentwickler einfacher hat, erledigt der Compiler die Arbeit im Hintergrund. Er erzeugt eine eingebettete Klasse, in der die anonyme Methode die Variable referenziert. Öffnet man das Kompilat des vorangegangenen Beispiels mit dem IL Disassembler, ist die eingebettete Klasse zu sehen.

Abbildung zum Closure

Informationen zum IL Disassambler

Merke:

  • Anonyme Methoden können auf den Kontext der sie umgebenden Methoden zugreifen, ohne dass dabei ein Argument/Parameter übergeben werden muss.
  • Der Closure ist immer call by reference, auch Wertetypen.
  • Es könnte in hochperformanten Anwendungen zu Problemen kommen. Die Lesbarkeit sollte aber möglichst im Vordergrund stehen.

3. Lambda-Ausdrücke

Ein Lambda-Ausdruck ist ein spezieller Syntax, mit dem unter anderem anonyme Methoden ausgedrückt werden können. Der Lambdadeklarationsoperator => , auch goes over to operator genannt, ist charakteristisch für einen Lambda-Ausdruck. Der Hintergrund hierfür ist das Lambda-Kalkül aus den 1930er Jahren. Bei Interesse am mathematischen Hintergrund hier klicken.

//Lambda-Ausdruck bestehend aus nur einer Anweisung
(input-parameter) => Ausdruck

//Lambda-Ausdruck mit einem Anweisungsblock
(input-parameter) => { mehrere Anweisungen }

Am häufigsten ist der Lambda-Ausdruck mit nur einer return Anweisung anzutreffen. In diesem Fall können die geschweiften Klammern und das return weggelassen werden. Dazu ein paar Beispiele:

string meinText = "Hallo Welt";
// Eine anonyme Methode, die zwei Werte übergeben bekommt und diese addiert.
Func<int, int, int> anonymeMethode = delegate (int wert1, int wert2)
                                     {
                return wert1 + wert2;
                                     };

// Die gleiche anonyme Methode als Lambda-Ausdruck
Func<int, int, int> anonymeMethode = (int wert1, int wert2) =>
                                     {
                return wert1 + wert2;
                                     };

// In einer Zeile geschrieben wird es übersichtlicher.
Func<int, int, int> lambda1 = (int wert1, int wert2) => { return wert1 + wert2; };

// Die Parameter sind implizit typisiert und werden vom Delegaten abgeleitet.
Func<int, int, int> lambda2 = (wert1, wert2) => { return wert1 + wert2; };

// Befindet sich nur eine Anweisung in der anonymen Methode,
// kann das return und die geschweiften Klammern weggelassen werden.
Func<int, int, int> lambda3 = (wert1, wert2) => wert1 + wert2;

// Ein Lambda mit nur einem Parameter
Func<int, int> lambda4 = wert => wert * wert;

// Ein Lambda-Ausdruck ohne Parameter, der Closure nutzt.
// Achtung: Die leeren Klammern müssen angegeben werden.
Action lambda5 = () => Console.WriteLine(meinText);

Console.WriteLine(anonymeMethode(7, 3));
Console.WriteLine(anonymeMethode(7, 3));
Console.WriteLine(lambda1(7, 3));
Console.WriteLine(lambda2(7, 3));
Console.WriteLine(lambda3(7, 3));
Console.WriteLine(lambda4(7));
lambda5();

// Die Anweisungen ergeben folgende Ausgabe:
//  10
//  10
//  10
//  10
//  10
//  49
//  Hallo Welt

Von der ersten anonymenMethode bis lambda3 wird schrittweise gezeigt, wie die kurze Schreibweise entsteht. Der Rückgabetyp eines Lambda-Ausdrucks ist nicht von dem Typ der Parameter abhängig. Lambdas kommen nicht nur bei anonymen Methoden vor. Expression-bodied Member erlaubt es, Klassenmember als Lambda-Ausdrücke zu formulieren. Mittlerweile sind Konstruktor, Destruktor und Eigenschaften mit einbezogen. Auch in Statements, beispielsweise in einer switch-Anweisung, kann man Lambdas verwenden.

Merke:

  • Der Aufbau eines Lambdas ist:
(input-parameter) => Ausdruck
(input-parameter) => { mehrere Anweisungen }
  • Der Rückgabetyp kann sich von dem(n) Parametertyp(en) unterscheiden und wird vom endsprechenden Delegaten abgeleitet.
  • Lambda-Ausdrücke sind nicht nur auf anonyme Methoden beschränkt.

4. Ein umfassendes Beispiel

Im abschließenden Beispiel wird gezeigt, wie eine relativ große Methode durch Lambdas wesentlich besser lesbar und kürzer wird. Das Beispiel besteht aus zwei Klassen. Die Klasse Planet ist die Datenklasse. Die Klasse PlanetenVerwalter ordnet die Planeten und gibt sie auf der Console aus. Wir erstellen eine Planetenliste, indem die Daten direkt dem Konstruktor übergeben werden.

var planetenImSonnensystem = new List<Planet>
                             {
                new Planet( "Merkur", "Gesteinsplanet", 4879),
                new Planet( "Venus", "Gesteinsplanet", 12103),
                new Planet( "Erde", "Gesteinsplanet", 12735),
                new Planet( "Mars", "Gesteinsplanet", 6772),
                new Planet( "Jupiter", "Gasplanet", 138346),
                new Planet( "Saturn", "Gasplanet", 114632),
                new Planet( "Uranus", "Gasplanet", 50532),
                new Planet( "Neptun", "Gasplanet", 49105),
                new Planet( "Pluto", "Zwergplanet", 2374),
                new Planet( "Ceres", "Zwergplanet", 975),
                             };

Bevor wir die Liste sortieren, soll sie einmal auf der Console ausgegeben werden. Dieses könnte mit einer foreach - Schleife geschehen. In unserem Fall verwenden wir aber einen Lambda-Ausdruck. Die Klasse List<T> verfügt über die Methode ForEach(Action<T> action). Wie wir sehen, besitzt diese Methode einen Parameter vom Typ Action<T>, welcher ein Delegat ist und dem wir eine anonyme Methode übergeben können. Die Methode ForEach iteriert über jedes Listenelement und führt dann den Code der anonymen Methode aus. Für eine formatierte Ausgabe der Daten ist in der Klasse Planet die Methode ToString() dementsprechend überschrieben worden.

planetenImSonnensystem.ForEach(planet => Console.WriteLine(planet));

Kommen wir nun zum Kernstück unseres Beispiels. Wir wollen die Planetenliste nach Gesteinsplaneten und Gasplaneten kategorisieren und die einzelnen Kategorien nach Durchmesser sortieren. Um das zu realisieren, verwenden wir ein Dictionary<string, List<Planet>>, welches ein sogenanntes Key-Value-Pair ist.

public void ErstelleDurchmessersortiertePlanetenkategorien(IReadOnlyList<Planet> planetenListe)
{
                // Erzeugen eines Dictionary zur Aufnahme der Kategorie als Key und der Liste als Value.
                var kategorisiertePlaneten = new Dictionary<string, List<Planet>>();

                foreach (var planet in planetenListe)
    {
                // Zwergplaneten ausschließen
                if (planet.Kategorie == "Zwergplanet")
        {
                continue;
        }

                // Ist die endsprechende Kategorie im Dictionary nicht vorhanden,
                // dann eine Liste von Planeten unter dieser Kategorie initialisieren.
                if (kategorisiertePlaneten.ContainsKey(planet.Kategorie) == false)
        {
            kategorisiertePlaneten[planet.Kategorie] = new List<Planet>();
        }

                // Den Planeten zu der Liste hinzufügen
        kategorisiertePlaneten[planet.Kategorie].Add(planet);
    }

                // Fertige Liste einer Kategorie nach Durchmesser sortieren.
                foreach (var liste in kategorisiertePlaneten.Values)
    {
        liste.Sort(SortiereNachDurchmesser);
    }

    GibKategorisiertePlanetenAus(kategorisiertePlaneten);
}

private int SortiereNachDurchmesser(Planet ersterPlanet, Planet zweiterPlanet)
{
                return zweiterPlanet.Durchmesser - ersterPlanet.Durchmesser;
}

Die Planetenkategorie ist der Key und wird mit der Eigenschaft Kategorie der Klasse Planet dargestellt. Das dazugehörige Value ist eine Liste aus den Eigenschaften Name und Durchmesser. Die Methode besteht aus zwei Schleifen. In der ersten Schleife wird das Dictionary mit Daten befüllt. Dazu wird die Kategorie "Zwergplaneten" herausgefiltert. Im nächsten Schritt wird geprüft, ob sich die gefragte Kategorie nicht im Key des Dictionary befindet. Ist das der Fall, wird eine Liste unter dieser Kategorie initialisiert. In der letzten Anweisung der Schleife wird der endsprechende Planet der Liste hinzugefügt. In der zweiten Schleife wird auf der jeweiligen Liste die Methode Sort aufgerufen, welche die Planeten nach Durchmesser sortiert.

Verwendet man Lambda-Ausdrücke in Kombination mit LINQ-Operatoren, ist es möglich, die soeben beschriebene Methode auf drei Zeilen zu verkürzen. LINQ ist die Abkürzung für Language Integrated Query und ist eine in C# integrierte Abfragesprache.

public void ErstelleDurchmessersortiertePlanetenkategorienLambda(IReadOnlyList<Planet> planetenListe)
{
                var kategorisiertePlaneten = planetenListe.Where(planet => planet.Kategorie != "Zwergplanet")
                                              .OrderByDescending(planet => planet.Durchmesser)
                                              .GroupBy(planet => planet.Kategorie);

    GibKategorisiertePlanetenAus(kategorisiertePlaneten);
}

Diese Methode liest sich schon beinahe wie ein ganz normaler Text, ohne dabei zu verstehen, wie LINQ im Detail funktioniert.

Planetenliste.Wo (die Kategorie nicht Zwergplanet ist) 
             .AbsteigendGeordnet (nach PlanetenDurchmesser) 
             .Gruppiert (nach Planetenkategorie) 

Der Quellcode der Beispiele ist am Ende des Artikels verlinkt.

Fazit

Lambda-Ausdrücke sind syntaktische Sahnestücke, die es erlauben, viel Funktionalität in wenig Quellcode unterzubringen. Der Einsatz von Lambdas sollte trotzdem überlegt sein. Meines Erachtens ist die Lesbarkeit von Quellcodes eines der essentiellen Merkmale von Softwarequalität. Somit sollte der Einsatz von Lambdas in erster Linie das Ziel haben, die Lesbarkeit zu steigern. Es nützt nichts, ein Lambda mit nichtssagenden Bezeichnern zu erstellen, wenn man nach drei Wochen seinen eigenen Quellcode nicht mehr lesen kann. Das bedeutet, wie in vielen anderen Bereichen auch, erst die Summe aller Bestandteile fördert ein gutes Ergebnis. Diese Punkte sind aber schon Material für weitere Artikel.

Vielen Dank für die Aufmerksamkeit und das Interesse an meinem Artikel. Weiterhin viel Spaß beim Lernen und Nachrecherchieren.

Quellcode