Did you know that you can navigate the posts by swiping left and right?

Die Dreifaltigkeit in C# 3.0 - λ, LINQ und Extension-Methods (Teil 2/3)

24 Jul 2008 . Unknown . Comments

Der erste Artikel meiner dreiteiligen Serie „Die Dreifaltigkeit in C# 3.0“ hat sich mit den Erweiterungsmethoden, im Englischen Extension-Methods, auseinandergesetzt. Nachdem wir nun die Vor- und auch Nachteile dieser Erweiterung des C#-Sprachschatzes besser kennengelernt haben möchte ich dieses mal auf die so genannten Lambda(λ)-Expressions zu sprechen kommen – welche die Grundlagen für LINQ bilden.

Obwohl viele Neuerungen sowohl für C# 3.0 als auch Visual Basic .NET 9.0 und Chrome 2.0 verfügbar sind, werde ich im Folgenden lediglich auf die Implementierung in C# eingehen. Als IDE wurde die englische Version von Microsoft Visual Studio 2008 unter Vista SP1 und Windows XP SP3 eingesetzt. Die Beispiele sind allesamt in C# geschrieben und basieren auf der Version 3.5 des .NET-Frameworks.

[more]

Lambda(λ)-Expressions

Nachdem wir nun ausführlich über die Erweiterungsmethoden gesprochen haben, können wir uns den so genannten Lambda-Expressions oder -Ausdrücken widmen. Sie sind lediglich eine andere Schreibweise für anonyme Methoden wie man sie bereits bei der Verwendung von Delegaten einsetzen konnte. Die Entscheidung eine Methode anonym zu erstellen sollte immer dann gefällt werden, wenn sie eigentlich zu „unwichtig“ für einen eigenen Namen ist (da man sie beispielsweise nur einmal benötigt). Die Schreibweise eines solchen Ausdrucks ist auf den ersten Blick etwas komplex; sobald man aber die Idee dahinter verinnerlicht hat, sollte sie kein Problem mehr darstellen.

In den folgenden Beispielen werde ich eine Liste vom Typ „Person“ aus dem Buch „Visual C# 2008“ verwenden, die mit einigen Beispieldaten gefüllt ist:

public class Person {
  #region Automatisch implementierte Eigenschaften
  public string Title { get; set; }
  public string LastName { get; set; }
  public string FirstName { get; set; }
  public string Street { get; set; }
  public string Zip { get; set; }
  public string City { get; set; }
  public int Age { get; set; }
  #endregion

  #region Methode ToString()
  public override string ToString() {
    StringBuilder result = new StringBuilder();
    result.Append( Title );
    result.Append( " " );
    result.Append( FirstName );
    result.Append( " " );
    result.Append( LastName );
    result.Append( ", " );
    result.AppendFormat( "Alter: {0}, ", Age );
    result.Append( Street );
    result.Append( ", " );
    result.Append( Zip );
    result.Append( " " );
    result.Append( City );
    return result.ToString();
  }
  #endregion
}

Springen wir also gleich ins kalte Wasser mit einem Beispiel, in dem wir nur die Personen aus der Liste wissen wollen, deren Nachname mit einem „P“ beginnt. Bisher musste man dafür ein ähnliches Konstrukt wie das folgende verwenden:

List<Person> Adressbuch = PersonFactory.CreateRandomPeople( 100 );
List<Person> Ergebnis = null;
foreach ( Person p in Adressbuch ) {
  if ( p.LastName.StartsWith( "P" ) ) {
    Ergebnis.Add( p );
  }
}

Das gleiche Ergebnis lässt sich nun mit Lambda-Expressions wie folgt erreichen:

List<Person> Adressbuch = PersonFactory.CreateRandomPeople( 100 );
List<Person> Ergebnis = Adressbuch.Where( p => p.LastName.StartsWith( "P" ) );

Was ist hier geschehen? Zum Verständnis dieser Abfrage ist die Funktionsweise der Erweiterungsmethode Where() aus dem Namensraum System.Linq wichtig. Die genaue Implementierung von Where() in diesem speziellen Fall ist etwas komplexer, im Wesentlichen handelt es sich aber um die folgende Methode:

public static IEnumerable<T> Where<T>( this IEnumerable<T> source, Func<T, bool> predicate ) {
  foreach ( T item in source ) {
    if ( predicate( item ) ) {
      yield return item;
    }
  }
}

Wir haben hier also eine Erweiterungsmethode vor uns die man auf alle Elemente anwenden kann, welche das Interface IEnumerable<T> implementieren (der erster Parameter mit dem Schlüsselwort this). Außerdem hat sie eine Rückgabe vom gleichen Typ und benötigt einen Übergabeparameter. Bei letztgenannten handelt es sich um einen Delegaten, das heißt es wird an dieser Stelle eine Funktion erwartet die als Übergabeparameter den Typ T entgegen nimmt und einen boolschen Wert als Ergebnis zurückgibt.

Werfen wir einen Blick in den Methodenrumpf um den Aufruf besser zu verstehen. Hier wird in Zeile 2 zunächst jedes Element der ursprünglichen Liste durchlaufen. Wir erinnern uns: Innerhalb einer Erweiterungsmethode kann man auf den Datentyp und seine Werte über den beim Schlüsselwort this angegebenen Namen zugreifen, hier also source. Innerhalb unserer foreach-Schleife haben wir nun die Variabel item vom Typ T (in unserem Beispiel ist T der Typ Person). In der 3. Zeile kommt nun unser Delegate mit dem Namen predicate zum Einsatz: Wir übergeben der beim Aufruf angegebenen Methode unser aktuelles Objekt item und erhalten als Ergebnis entweder true oder false. Die Erweiterungsmethode where soll ja nur die Elemente zurückgeben für die die Bedingung erfüllt ist, deshalb benutzen wir noch die if-Abfrage und geben das aktuelle Objekt nur dann zurück wenn der Aufruf der über predicate referenzierten Methode true zurückgibt. Das Schlüsselwort yield in Zeile 4 speichert die interne Laufvariabel der foreach-Schleife zwischen um beim erneuten Aufruf nicht an den Anfang zu springen, sondern vielmehr hinter die durch yield festgelegte Position innerhalb der Liste.

Kommen wir zu unserem Beispiel-Aufruf zurück. Hier fällt uns als nächstes das Zeichen => auf. Hierbei handelt es sich um den so genannten Lambda-Operator. Man liest ihn als „geht zu“ bzw. im Englischen als „goes to“. Vor dem Lambda-Operator steht der zu übergebene Parameter. Den Datentyp lässt man in der Regel weg, da er bereits durch die verwendete Liste bekannt ist. Der Name ist frei wählbar bzw. unterliegt den üblichen Konventionen für Variabelnnamen. Auf der rechten Seite des Lambda-Operators steht der Code, der in der anonymen Methode ausgeführt werden soll. Das Schlüsselwort return läßt man hier weg, da es beim Aufruf implizit aufgerufen wird. Da unser Lambda-Ausdruck nur eine verkürzte Schreibweise für eine anonyme Methode ist, gibt es natürlich auch noch eine alternative Schreibweise wie man sie so auch mit dem .NET-Framework in der Version 2.0 hätte schreiben können:

List<Person> Adressbuch = PersonFactory.CreateRandomPeople( 100 );
List<Person> Ergebnis = Adressbuch.Where( delegate( Person p ) {
                                            return p.LastName.StartsWith( "P" );
                                          }
                                        );

Diese Schreibweise ist allerdings nicht so schön lesbar und kompakt wie die Lambda-Expression obwohl sie genau das gleiche macht. Der neue Syntax ist hier deutlich überlegen und sollte deshalb auch bevorzugt werden.

Steuern der Rückgabe / Projektionen

Als Rückgabewert haben wir bisher immer eine Liste vom gleichen Datentyp gehabt wie den der Ursprünglichen Liste. Das funktioniert auch ohne zusätzliche Befehle problemlos, aber manchmal benötigt man als Ergebnis gar nicht das vollständige Objekt oder möchte beispielsweise einen zusätzlichen Wert haben, der erst berechnet werden muss. Für diese Fälle kann man das Ergebnis unseres Lambda-Ausdrucks auch verändern, man spricht in diesem Fall von einer Projektion auf einen neuen Datentypen. Veranschaulichen wir das ebenfalls an einem kleinen Beispiel:

List<Person> Adressbuch = PersonFactory.CreateRandomPeople( 100 );
List<string> Ergebnis = Adressbuch.Select( p => p.FirstName + " " + p.LastName + " (" + p.Age + ")" );

Wie man am Datentyp des Lambda-Ausdrucks bereits sieht erhalten wir dieses mal keine Liste mit Objekten des Typs Person sondern lediglich eine Liste mit Zeichenketten. Jeder Einzelne Eintrag besteht aus dem Vor- und Nachnamen einer Person und deren Alter dahinter in runden Klammern. Diese Liste mit Objekten vom Typ string tritt somit an die Stelle des eigentlich verwendeten Datentyps, die ursprünglichen Daten werden quasi auf einen neuen Typ projeziert.

Für das nächste Beispiel greife ich auf ein weiteres neues Feature von C# 3.0 zurück, die so genannten anonymen Datentypen. Es handelt sich hierbei analog zu den anonymen Methoden um einen Datentyp, den man beispielsweise nur an einer Stelle benötigt und für den man deshalb keine eigene Klasse schreiben möchte. Die Verwendung ergibt sich aus dem Beispiel, für nähere Informationen zu anonymen Datentypen empfehle ich den Artikel „Anonyme Typen“ im C#-Programmierhandbuch.

List<Person> Adressbuch = PersonFactory.CreateRandomPeople( 100 );
var Ergebnis = Adressbuch.Where( p => p.LastName.StartsWith( "P" ) )
                                  .Select( p => new {
                                    Vorname = p.FirstName;
                                    Nachname = p.LastName;
                                    Geburtsjahr = DateTime.Now.Year - p.Age;
                                    Weiblich = p.Title == "Frau";
                                  } );

Der Zeilenumbruch und die Einrückung sind rein optischer Natur. Man könnte sie auch weglassen und den Ausdruck in eine einzige Zeile schreiben. Eine saubere Formatierung macht es allerdings einfacher den Überblick zu wahren und man erkennt die einzelnen Operatoren auf einen Blick. Die Berechnung des Geburtsjahres ist nicht ganz sauber da das genaue Datum außer acht gelassen wird. Die Eigenschaft soll hier lediglich als Beispiel für eine „berechnete“ Eigenschaft dienen.

Als Ergebnis des obigen Beispiels erhält man eine Variabel Ergebnis mit unbekanntem Datentyp, allerdings spielt dieser Datentyp auch keine große Rolle da wir mit diesem nun einfach arbeiten können. Visual Studio 2008 unterstützt uns hierbei mit Intellisense, so dass wir beispielsweise über Ergebnis.Weiblich abfragen können, ob es sich bei der aktuellen Person um eine Frau (true) oder einen Mann (false) handelt.

Wie man im letzten Beispiel sieht kann man die Erweiterungsmethoden natürlich auch kombinieren, im Beispiel die Bedingung (Where) mit einer Auswahl / Projektion (Select). Neben diesen beiden Erweiterungsmethoden gibt es noch zahlreiche andere, von denen ich im Folgenden ein paar wichtige kurz vorstellen möchte.

Erweiterungsmethoden für λ-Ausdrücke im Namensraum System.Linq

Sortieren

Mit Hilfe der Erweiterungsmethode OrderBy() können die einzelnen Elemente in eine neue Reihenfolge gebracht werden. Als Parameter übergibt man die Eigenschaft nach der die Sortierung erfolgen soll. Bei der Verwendung von OrderBy() wird immer aufsteigend (ascending) sortiert. Möchte man die Reihenfolge umdrehen, also absteigend (descending) sortieren, verwendet man stattdessen die Methode OrderByDescending().

Für die Sortierung nach mehreren Kriterien, beispielsweise erst nach Alter und dann nach Nachname, gibt es weitere Erweiterungsmethoden: ThenBy() bzw. ThenByDescending().

List<Person> Adressbuch = PersonFactory.CreateRandomPeople( 100 );
var Ergebnis = Adressbuch.OrderByDescending( p => p.Age )
                         .ThenBy( p => p.LastName )
                         .ThenBy( p => p.FirstName );

Berechnungen

Es gibt fünf vordefinierte Methoden um Berechnungen durchzuführen: Count(), Average(), Sum(), Min() und Max(). Die Funktionsweise erschließt sich denke ich aus dem jeweiligen Namen, so dass wir direkt ein Beispiel einschieben bei dem man auch den Datentyp der Rückgabe sieht:

List<Person> Adressbuch = PersonFactory.CreateRandomPeople( 100 );
double Durchschnittsalter = Adressbuch.Average( p => p.Age );
int Gesamtalter = Adressbuch.Sum( p => p.Age );
int Hoechstalter = Adressbuch.Max( p => p.Age );
int JuengstesAlter = Adressbuch.Min( p => p.Age );
int AnzahlTeenager = Adressbuch.Count( p => p.Age < 19 );

Neben diesen vordefinierten Methoden gibt es allerdings auch noch eine allgemeine Aggregatfunktion die passenderweise Aggregate() heißt und gleich mehrfach überladen ist. In einer einfachen Variante wird als erster Parameter ein Startwert erwartet, der für die folgenden Berechnungen herangezogen wird. Dementsprechend gibt diese Erweiterungsmethode auch den gleichen Datentyp zurück wie der dieses Startwertes. Als zweiten Parameter erwartet die Methode das jeweils nächste Element der Liste (in unserem Beispiel also vom Typ Person). Wir wollen nun in einem Beispiel die Methode Sum() nachbilden:

List<Person> Adressbuch = PersonFactory.CreateRandomPeople( 100 );
int GesamtalterMitAggregat = Adressbuch.Aggregate( 0, ( ergebnis, naechster ) => ergebnis += naechster.Age );

Der Startwert wurde hier auf 0 gesetzt, ergebnis ist folglich vom Datentyp int. Die Variabel naechster ist vom Typ Person und stellt jeweils das nächste Element der Liste da. Innerhalb unserer Lambda-Expression sind beliebige Berechnungen möglich so daß die Methode Aggregat() ein enormes Potential bietet.

Wiederholungen löschen

Bei einigen Abfragen gilt es redundante Informationen aus dem Ergebnis auszufiltern. Wenn wir beispielsweise alle Orte wissen wollen, in denen Personen aus unserem Adressbuch leben, dann würden viele Orte mehrfach auftauchen. Um die unnötigen Wiederholungen aus der Ergebnisliste zu löschen kann man die Methode Distinct() verwenden, die die meißten vermutlich auch aus dem Sprachrepertoire von SQL kennen. Um die doppelten Einträge rauszufinden verwendet die Erweiterungsmethode ohne Übergabeparameter den Standartvergleich, sofern ein solcher existiert. Alternativ kann man auch als Parameter ein Objekt übergeben, das das generische Interface IEqualityComparer<T> implementiert. Zu diesem gehören die beiden Methoden Equals() und GetHashCode(), die von Distinct() auch beide für den Vergleich herangezogen werden. Möchte man also nur eine der beiden Implementierungen verwenden, kann die jeweils andere Methode immer true (oder einen beliebigen anderen, aber immer identischen Wert) zurückgeben. Um nun also eine Liste mit allen Wohnorten zu erhalten müssen wir zuvor eine eigene Implementierung von IEqualityComparer schreiben. Dabei können wir natürlich auf bereits implementierte Vergleichsmethoden, beispielsweise von der Klasse string, zurückgreifen:

class WohnortVergleich : IEqualityComparer<Person> {
  public bool Equals( Person a, Person b ) {
    return a.City.Equals( b.City );
  }

  public int GetHashCode( Person p ) {
    return Convert.ToInt32( p.ZIP );
    // Alternativ: return -1;
  }
}

Die eigentliche Ausgabe unter Verwendung von Distinct() kann dann wiefolgt realisiert werden:

List<Person> Adressbuch = PersonFactory.CreateRandomPeople( 100 );
List<string> Wohnorte = Adressbuch.Distinct( new WohnortVergleich() )
                                  .Select( p => p.City );

Weitere Erweiterungsmethoden

Mit den wenigen hier vorgestellten Erweiterungsmethoden aus dem Namensraum System.Linq hat man noch lange nicht alle Möglichkeiten ausgeschöpft. So gibt es beispielsweise noch Methoden zur Quantifizierung (All(), Any(), Contains()), zur Gruppierung von Abfragen (GroupBy()) und zur Verknüpfung mehrerer Listen (Join()) die ich hier aber nicht alle im Detail vorstellen kann. Hier verweise ich auf andere Artikel und die msdn C#-Sprachreferenz.

Fazit

Das Prinzip, das hinter den Lambda-Ausdrücken steckt, ist nicht ganz neu. Auch in C# 2.0 kannte man Delegaten und konnte mit entsprechenden Aufwand Listen sortieren und filtern. Die Lambda-Ausdrücke reduzieren die notwendige Tipparbeit aber auf ein Minimum und sind vom Syntax mit den zahlreichen Erweiterungsmethoden deutlich vereinfacht worden. So muß man sich keine Gedanken mehr darum machen, wie man eine Sortierung für eine Liste implementiert sondern verwendet einfach Methoden die an die gängigen Bezeichnungen des SQL-Syntax angelehnt sind und dem Programmierer so das Leben ein gutes Stück einfacher machen.

In diesem zweiten Abschnitt wurde auch deutlich wieso die Einführung der Erweiterungsmethoden notwendig geworden ist. Erst in der Kombination von Erweiterungsmethode und der dazugehörigen anonymen Methode können die Lambda-Ausdrücke ihr volles Potential entfalten. Das heißt aber nicht, das sie auf diese Abfragen beschränkt sind. Überall wo ein Delegat zum Einsatz kommt, beispielsweise bei einem Event, lassen sich die anonymen Methoden auch mittels einer Lambda-Expression beschreiben. Sie sind deshalb eine Bereicherung für jeden Programmierer wenn man sich denn zuvor auf das Konzept, das dahinter steht, einlässt.

Quellennachweise

Questions/Suggestions
As always, for questions or feedback, contact me or leave a comment.

Octocat by GitHubEdit this page on GitHub