Zum Inhalt
wannacode

Lambdas und Delegates

Kurz gesagt: Ein Lambda ist eine kurze Funktion ohne Namen, die du als Wert übergibst; in C# ist sein Typ ein Delegate wie Func, Action oder Predicate.

Problem

Eine ganze Klasse für einen Vergleich

Die Bestellungen sollen nach Summe sortiert werden. Wie das geht, weißt du aus dem Modul „Collections“: Sort mit einem Objekt einer Klasse, die IComparer<Order> implementiert.

Order
class Order
{
public string Customer { get; }
public double Total { get; }
public Order(string customer, double total)
{
Customer = customer;
Total = total;
}
}
class ByTotal : IComparer<Order>
{
public int Compare(Order a, Order b)
{
return a.Total.CompareTo(b.Total);
}
}
var orders = new List<Order>
{
new Order("Maria", 75.50),
new Order("Tom", 12.90),
new Order("Laura", 40.00)
};
orders.Sort(new ByTotal());
foreach (Order o in orders)
{
Console.WriteLine($"{o.Customer}: {o.Total:F2} €");
}
Ausgabe
Tom: 12.90 €
Laura: 40.00 €
Maria: 75.50 €

Es funktioniert. Aber für einen Vergleich schreibst du eine ganze Klasse: class ByTotal : IComparer<Order>, public int Compare, Klammern und dann new ByTotal(). Nur eine Zeile sagt etwas: a.Total.CompareTo(b.Total). Und die Klasse steht weit weg von der Stelle, an der du sie benutzt. Du willst nur die Regel übergeben.

Die Idee

Ein Lambda: nur die Regel

Dieselbe Sortierung in einer Zeile:

orders.Sort((a, b) => a.Total.CompareTo(b.Total));

(a, b) => a.Total.CompareTo(b.Total) heißt Lambda-Ausdruck, kurz Lambda. Links von => stehen die Parameter, rechts der Rumpf. Das ist die Methode Compare ohne Namen, ohne Typen und ohne return.

Ein Lambda kann man auf drei Arten schreiben:

  • Ein einzelner Parameter darf ohne Klammern stehen: o => o.Total > 50. Ohne Parameter ist es () => ....
  • Die Typen der Parameter lässt man meist weg, weil der Compiler sie von Sort kennt. Du kannst sie auch hinschreiben: (Order a, Order b) => ....
  • Ein Rumpf mit mehreren Zeilen steht in { }, und dann ist return Pflicht.
Comparison<Order> byTotal = (a, b) => a.Total.CompareTo(b.Total);
Comparison<Order> byTotalLong = (Order a, Order b) =>
{
double diff = a.Total - b.Total;
return diff < 0 ? -1 : diff > 0 ? 1 : 0;
};
orders.Sort(byTotalLong);
foreach (Order o in orders)
{
Console.WriteLine($"{o.Customer}: {o.Total:F2} €");
}
Ausgabe
Tom: 12.90 €
Laura: 40.00 €
Maria: 75.50 €
Ein Lambda ist ein Wert: Du kannst es in eine Variable legen und später übergeben. Der Typ der Variable ist hier Comparison<Order>. Was das ist, steht auf der nächsten Karte.

Die Idee

Ein Delegate: der Typ der Regel

Woher weiß C#, dass (a, b) => ... ein Vergleich von zwei Bestellungen ist? Aus dem Typ: Sort hat eine Variante, die ein Comparison<Order> nimmt. Das ist ein Delegate: ein Typ, der eine Methode beschreibt, ihre Parameter und ihr Ergebnis. Comparison<Order> ist „eine Methode, die zwei Bestellungen nimmt und einen int zurückgibt“. Das Lambda wird der Rumpf so einer Methode.

Du kannst dir ein eigenes Delegate machen. Man schreibt es wie eine Methode, mit dem Wort delegate und ohne Rumpf:

delegate double PriceRule(double price);
PriceRule student = price => price * 0.8;
PriceRule minusFive = price => price - 5;
Console.WriteLine(student(50));
Console.WriteLine(minusFive(50));
Ausgabe
40
45
student ist ein Objekt vom Typ PriceRule. Du rufst es wie eine Methode auf, mit Klammern: student(50). Zwei Preisregeln und keine einzige Klasse. C# gibt 40 aus, nicht 40.0: Die Zahl ist ganz.

PriceRule student = price => price * 0.8;
PriceRule minusFive = price => price - 5;
Console.WriteLine(student(50));
Console.WriteLine(minusFive(50));
Schritt für Schritt, in Worten
  1. Das Lambda ist ein Objekt vom Typ PriceRule, und student zeigt darauf. Sein Rumpf läuft noch nicht.
  2. Eine zweite Regel, ein zweites Objekt. Keine einzige Klasse ist geschrieben.
  3. student(50) übergibt 50 an das Lambda: price ist 50, und price * 0.8 ergibt 40.
  4. minusFive(50): dieselbe Zahl, eine andere Regel, 45.
Genauer, für Neugierige
  • In Java steht ein Lambda dort, wo ein Interface mit einer Methode (ein funktionales Interface) erwartet wird. In C# kann ein Lambda nicht an ein Interface gehen, selbst wenn es nur eine einzige Methode hat:
interface IPriceRule
{
double Apply(double price);
}
IPriceRule student = price => price * 0.8;
Kompilierfehler
error CS1660: Cannot convert lambda expression to type 'IPriceRule' because it is not a delegate type

Deshalb ist in C# eine Regel, die als Lambda übergeben wird, ein Delegate und kein Interface.

  • student(50) ist kurz für student.Invoke(50). Und GetType() zeigt, dass das Objekt wirklich vom Typ des Delegates ist:
PriceRule student = price => price * 0.8;
Console.WriteLine(student.GetType().Name);
Console.WriteLine(student.Invoke(50));
Ausgabe
PriceRule
40

Die Idee

Die fertigen: Predicate, Func, Action

Du musst nicht für jede Regel ein Delegate schreiben. System hat fertige für die häufigsten Fälle:

  • Predicate<T>: nimmt ein T, gibt einen bool zurück. Das ist eine Bedingung: „Ist die Bestellung groß?“.
  • Func<T, TResult>: nimmt ein T, gibt ein TResult zurück. Der letzte Typ in den < > ist immer das Ergebnis. Es macht aus einem etwas anderes: aus einer Bestellung ein Etikett.
  • Action<T>: nimmt ein T, gibt nichts zurück. Es tut etwas damit: gibt es aus, speichert es.
  • Func<TResult>: nimmt nichts, gibt ein TResult zurück. Es gibt dir ein neues Objekt, wenn du fragst.

Alle werden gleich aufgerufen, mit Klammern, wie eine Methode.

Predicate<Order> big = o => o.Total >= 50;
Func<Order, string> label = o => o.Customer.ToUpper();
Action<string> print = text => Console.WriteLine("-> " + text);
Func<Order> guest = () => new Order("Gast", 0);
var order = new Order("Maria", 75.50);
Console.WriteLine(big(order));
print(label(order));
Console.WriteLine(guest().Customer);
Ausgabe
True
-> MARIA
Gast

In Java sind Regeln Objekte mit Methoden, die sie zusammensetzen: Bedingungen verbindet man mit and, or und negate („nicht“), und Funktionen reiht man mit andThen hintereinander. Delegates in C# haben solche Methoden nicht. Die neue Regel ist ein neues Lambda, das die alten aufruft: o => big(o) && maria(o). Zwei Funktionen hintereinander setzt man auch mit einem Lambda zusammen, das eine Methode zurückgibt:

static Func<double, double> Then(Func<double, double> f, Func<double, double> g)
{
return x => g(f(x));
}
Func<double, double> withVat = price => price * 1.2;
Func<double, double> rounded = price => Math.Round(price, 2);
Func<double, double> finalPrice = Then(withVat, rounded);
Console.WriteLine(finalPrice(10.555));
Ausgabe
12.67

Then nimmt zwei Delegates und gibt ein drittes zurück: Es ruft das erste auf und übergibt dessen Ergebnis an das zweite.

Genauer, für Neugierige
  • Es gibt Varianten mit mehr Parametern: Func<T1, T2, TResult> nimmt zwei und gibt ein Ergebnis zurück, Action<T1, T2> nimmt zwei und gibt nichts zurück. Sie gehen bis zu 16 Parametern.
  • Predicate<Order> und Func<Order, bool> beschreiben dasselbe, sind aber verschiedene Typen. Eine Variable des einen kann man nicht dort übergeben, wo das andere erwartet wird.
  • Delegates für Aktionen kann man mit += zusammenzählen. Dann ruft ein Aufruf alle der Reihe nach auf:
Action<string> notify = text => Console.WriteLine("E-Mail: " + text);
notify += text => Console.WriteLine("SMS: " + text);
notify("deine Bestellung ist unterwegs");
Ausgabe
E-Mail: deine Bestellung ist unterwegs
SMS: deine Bestellung ist unterwegs

Probier es aus

Zusammengesetzte Bedingungen

Was gibt das Programm aus?

Predicate<Order> big = o => o.Total >= 50;
Predicate<Order> maria = o => o.Customer == "Maria";
Predicate<Order> bigAndMaria = o => big(o) && maria(o);
Predicate<Order> bigOrMaria = o => big(o) || maria(o);
Predicate<Order> notBig = o => !big(o);
var small = new Order("Maria", 30);
var large = new Order("Tom", 80);
Console.WriteLine(bigAndMaria(small));
Console.WriteLine(bigOrMaria(small));
Console.WriteLine(notBig(large));
Antwort

Sieh es dir an

Listen nehmen Lambdas

List<T> hat Methoden, die statt einer Schleife eine Regel nehmen:
var cart = new List<string> { "Brot", "", "Milch", " ", "Kaffee" };
int removed = cart.RemoveAll(item => string.IsNullOrWhiteSpace(item));
cart.ForEach(item => Console.WriteLine(item));
Console.WriteLine($"Entfernt: {removed}");
Console.WriteLine(cart.Find(item => item.StartsWith('M')));
Console.WriteLine(cart.Exists(item => item.Length > 5));
List<int> lengths = cart.ConvertAll(item => item.Length);
Console.WriteLine(string.Join(", ", lengths));
Ausgabe
Brot
Milch
Kaffee
Entfernt: 2
Milch
True
4, 5, 6
  • RemoveAll(bedingung) entfernt alle Elemente, für die die Bedingung wahr ist, und gibt zurück, wie viele es entfernt hat. Die Bedingung ist ein Predicate.
  • ForEach(aktion) führt die Aktion mit jedem Element aus. Sie ist eine Action.
  • Find(bedingung) gibt das erste Element zurück, für das die Bedingung wahr ist, oder null, wenn es keines gibt. Exists sagt, ob es mindestens eines gibt.
  • ConvertAll(umwandlung) macht eine neue Liste aus den Ergebnissen. Hier werden die Wörter zu einer Liste ihrer Längen.

Dictionary hat solche Methoden nicht. computeIfAbsent und forEach aus Java gibt es dort nicht, und byCity.ForEach(...) lässt sich nicht kompilieren:

Kompilierfehler
error CS1061: 'Dictionary<string, List<string>>' does not contain a definition for 'ForEach' and no accessible extension method 'ForEach' accepting a first argument of type 'Dictionary<string, List<string>>' could be found (are you missing a using directive or an assembly reference?)

Ein Dictionary durchläufst du mit foreach, und Gruppieren in einer Zeile kommt in der nächsten Lektion.

Die Idee

Eine Methode über ihren Namen, ohne Klammern

Oft gibt ein Lambda seinen Parameter nur an eine vorhandene Methode weiter: s => Console.WriteLine(s). Dann übergib die Methode selbst, über ihren Namen und ohne Klammern:
var names = new List<string> { "maria", "tom" };
names.ForEach(Console.WriteLine);
Func<string, int> parse = int.Parse;
var vips = new List<string> { "maria" };
Predicate<string> isVip = vips.Contains;
Func<string, string> upper = s => s.ToUpper();
Func<List<string>> newList = () => new List<string>();
Console.WriteLine(upper("laura"));
Console.WriteLine(parse("42") + 1);
Console.WriteLine(isVip("tom"));
List<string> list = newList();
list.Add("Kaffee");
Console.WriteLine(string.Join(", ", list));
Ausgabe
maria
tom
LAURA
43
False
Kaffee

Der Name einer Methode ohne Klammern heißt Methodengruppe (method group): Die Gruppe sind alle Methoden mit diesem Namen. Der Compiler wählt die, die zum Delegate passt. Console.WriteLine hat viele Varianten, und ForEach will ein Action<string>, also nimmt er WriteLine(string). Java hat dafür eine eigene Schreibweise, die Methodenreferenz mit ::. Es gibt vier Arten, aber in C# gehen nur zwei über den Namen:

  • eine Methode eines bestimmten Objekts: vips.Contains ist name => vips.Contains(name);
  • eine statische Methode: int.Parse ist s => int.Parse(s), und Console.WriteLine ist s => Console.WriteLine(s);
  • eine Methode des Parameters: Dafür gibt es keine Kurzform. String::toUpperCase aus Java ist in C# ein Lambda, s => s.ToUpper();
  • ein Konstruktor: auch keine Kurzform. ArrayList::new ist in C# () => new List<string>().

Tut das Lambda etwas mehr, zum Beispiel s => s.Trim().ToUpper(), lass es ein Lambda.

Sieh es dir an

Sortieren nach mehreren Feldern

Das Delegate Comparison<T> hat keine Methoden wie comparing und thenComparing in Java. Die Regel für mehrere Felder ist ein einziges Lambda: Ist der Vergleich nach dem ersten Feld nicht 0, entscheidet er, sonst vergleicht es das nächste.

Die Schüler werden nach Note sortiert, von der höchsten an, und bei gleicher Note nach Name:

var students = new List<Student>
{
new Student("Jonas", 5.50),
new Student("Anna", 6.00),
new Student("Peter", 5.50),
new Student("Finn", 4.75)
};
students.Sort((a, b) =>
{
int byGrade = b.Grade.CompareTo(a.Grade);
return byGrade != 0 ? byGrade : string.Compare(a.Name, b.Name);
});
Ausgabe
Anna 6
Jonas 5.5
Peter 5.5
Finn 4.75

b.Grade.CompareTo(a.Grade) stellt die höhere Note nach vorn. Bei gleicher Note ist byGrade 0, und string.Compare(a.Name, b.Name) vergleicht die Namen alphabetisch. Es liest sich von oben nach unten: „nach Note umgekehrt, und bei Gleichheit nach Name“.

Genauer, für Neugierige
  • Ohne Typ links weiß der Compiler nicht, welches Delegate das Lambda ist, weil er nicht weiß, was a und b sind:
var byTotal = (a, b) => a.Total.CompareTo(b.Total);
Kompilierfehler
error CS8917: The delegate type could not be inferred.

Die Lösung ist der Typ des Delegates links:

Comparison<Order> byTotal = (a, b) => b.Total.CompareTo(a.Total);
orders.Sort(byTotal);
  • Mit Typen an den Parametern lässt sich var kompilieren, ergibt aber Func<Order, Order, int>. Das beschreibt dasselbe wie Comparison<Order>, ist aber ein anderer Typ, und Sort nimmt es nicht an:
var byTotal = (Order a, Order b) => a.Total.CompareTo(b.Total);
orders.Sort(byTotal);
Kompilierfehler
error CS1503: Argument 1: cannot convert from 'System.Func<Order, Order, int>' to 'System.Collections.Generic.IComparer<Order>?'

Der Compiler probiert die Variante von Sort mit IComparer, deshalb steht sie in der Meldung.

  • Sortieren, das sich wie comparing und thenComparing in Java liest, hat C# in LINQ: OrderByDescending(s => s.Grade).ThenBy(s => s.Name). Das kommt in der nächsten Lektion.

Probier es aus

Was kehrt das Vertauschen von a und b um?

Hier vergleicht byGradeThenName nach Note, dann nach Name, und Sort bekommt ein Lambda, das es mit vertauschten a und b aufruft.

Was gibt das Programm aus?

var students = new List<Student>
{
new Student("Jonas", 5.50),
new Student("Anna", 6.00),
new Student("Peter", 5.50),
new Student("Finn", 4.75)
};
Comparison<Student> byGradeThenName = (a, b) =>
{
int byGrade = a.Grade.CompareTo(b.Grade);
return byGrade != 0 ? byGrade : string.Compare(a.Name, b.Name);
};
students.Sort((a, b) => byGradeThenName(b, a));
students.ForEach(s => Console.WriteLine($"{s.Name} {s.Grade}"));
Antwort

Probier es aus

Finde den Fehler

Das Programm soll die Preise mit ihren Nummern ausgeben: 1. 12.00 € und so weiter. Es bricht schon beim ersten Preis mit einem Fehler ab. In welcher Zeile ist der Fehler?

Wähle die Zeile mit dem Fehler.

Genauer, für Neugierige

Hier unterscheidet sich C# von Java. Dort bekommt ein Lambda eine Kopie des Werts und kann die lokale Variable gar nicht ändern. In C# kann das Lambda sie auch ändern: Die Summe aus Java lässt sich hier kompilieren und gibt 127 aus:

var prices = new List<double> { 12, 75, 40 };
double total = 0;
prices.ForEach(p => total += p);
Console.WriteLine(total);
Ausgabe
127

Und weil es die Variable selbst sieht, sieht das Lambda auch eine Änderung, die nach ihm kommt:

double rate = 0.2;
Func<double, double> discount = price => price * (1 - rate);
Console.WriteLine(discount(100));
rate = 0.5;
Console.WriteLine(discount(100));
Ausgabe
80
50

Muss sich die Regel einen Wert merken, übergib ihn als Parameter. Für eine Summe gibt es aber einen klareren Weg, Sum, in der nächsten Lektion.

Übung

Regeln als Werte

Löse die Aufgaben in deinem Editor.

1. Sortieren nach mehreren Feldern. Eine Klasse Product mit Name, Kategorie und Preis. Sortiere die Produkte nach Kategorie, innerhalb einer Kategorie vom teuersten an und bei gleichem Preis nach Name, mit einem Sort und einem Lambda. Die Produkte sind: Maus, Zubehör, 25 €; Laptop, Computer, 1299 €; Tastatur, Zubehör, 49,90 €; Tablet, Computer, 399 €; Kopfhörer, Zubehör, 49,90 €.

Ausgabe
Computer | Laptop | 1299.00 €
Computer | Tablet | 399.00 €
Zubehör | Kopfhörer | 49.90 €
Zubehör | Tastatur | 49.90 €
Zubehör | Maus | 25.00 €

2. Bestellungen filtern. Gib Order eine Stadt. Schreib eine Methode Select(List<Order> orders, Predicate<Order> rule), die eine neue Liste nur mit den Bestellungen zurückgibt, für die die Regel wahr ist. Mach zwei Regeln, hamburg und big (mindestens 50 €), und gib die Bestellungen aus, für die beide wahr sind, die außerhalb von Hamburg und die „in Hamburg oder mindestens 100 €“. Jede der drei Regeln ist ein Lambda, das hamburg und big aufruft. Die Bestellungen: Maria, Hamburg, 75,50 €; Tom, Dresden, 12,90 €; Laura, Hamburg, 40 €; Lukas, Berlin, 120 €.

Ausgabe
Hamburg, ab 50 €:
Maria, Hamburg: 75.50 €
Außerhalb von Hamburg:
Tom, Dresden: 12.90 €
Lukas, Berlin: 120.00 €
Kostenlose Lieferung (Hamburg oder ab 100 €):
Maria, Hamburg: 75.50 €
Laura, Hamburg: 40.00 €
Lukas, Berlin: 120.00 €
3. Rabatte nacheinander. Mach drei Rabatte als Func<double, double>: einen Studentenrabatt (−10 %), einen Black-Friday-Rabatt (−20 €) und „nicht unter null“. Verkette sie mit der Methode Then(f, g) aus der Lektion in zwei Reihenfolgen: zuerst den Studentenrabatt und zuerst den Freitagsrabatt. Probier es mit 120 € und mit 15 €. Spielt die Reihenfolge eine Rolle?
Ausgabe
120.00 € -> Student, dann Freitag: 88.00 €; Freitag, dann Student: 90.00 €
15.00 € -> Student, dann Freitag: 0.00 €; Freitag, dann Student: 0.00 €

Weitere Aufgaben

  • Eine Liste aufräumen. Entferne aus einer Liste von E-Mail-Adressen die leeren und die ohne @ mit einem einzigen RemoveAll und gib den Rest mit ForEach(Console.WriteLine) aus.
  • Schüler nach Klasse. Eine Liste von Schülern und ihren Klassen ("10A", "10B"). Gib für jede Klasse die Namen aus, mit FindAll und einem Lambda, das die Klasse vergleicht, und ConvertAll, das den Namen nimmt.
  • Dein eigenes Delegate. Mach delegate bool Validator(string value); und drei Validatoren als Lambdas: nicht leerer Text, nur Ziffern (char.IsDigit für jedes Zeichen), Länge bis 10. Probier sie an ein paar Strings aus.

Test

Teste dich

1. Wo kann ein Lambda-Ausdruck stehen?
2. Welches fertige Delegate nimmt eine Bestellung und gibt true oder false zurück?
3. Wodurch kann man das Lambda s => int.Parse(s) ersetzen?
4. byGradeThenName vergleicht nach Note, dann nach Name. Was kehrt students.Sort((a, b) => byGradeThenName(b, a)) um?
5. In einer Schleife for (int i = 0; i < 3; i++) gibt jedes Lambda i aus. Was geben die drei aus, wenn sie nach der Schleife aufgerufen werden?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·