Zum Inhalt
wannacode

LINQ

Kurz gesagt: LINQ verarbeitet eine Collection als Folge von Schritten: Where siebt aus, Select wandelt um, OrderBy sortiert, und Sum und Average rechnen.

Problem

Ein Bericht in einer Schleife

Die Bestellungen haben jetzt auch eine Stadt:

Order
class Order
{
public string Customer { get; }
public string City { get; }
public decimal Total { get; }
public Order(string customer, string city, decimal total)
{
Customer = customer;
City = city;
Total = total;
}
}
List<Order> orders = new List<Order>
{
new Order("Maria", "Hamburg", 75.50m),
new Order("Tom", "Dresden", 12.90m),
new Order("Laura", "Hamburg", 40.00m),
new Order("Lukas", "Berlin", 120.00m),
new Order("Anna", "Dresden", 64.00m),
};

Du willst die Kunden mit Bestellungen ab 50 €, alphabetisch sortiert:

List<string> result = new List<string>();
foreach (Order o in orders)
{
if (o.Total >= 50)
{
result.Add(o.Customer);
}
}
result.Sort();
Console.WriteLine(string.Join(", ", result));
Ausgabe
Anna, Lukas, Maria

Zehn Zeilen, und die Frage ist nur eine. Der Code sagt, wie: eine leere Liste anlegen, durchlaufen, prüfen, hinzufügen, sortieren. Um zu verstehen, was er tut, musst du ihn im Kopf ausführen. Kommt „nur die ersten drei“ oder „ohne Wiederholungen“ dazu, wächst die Schleife weiter.

Die Idee

LINQ: eine Folge von Schritten

Derselbe Bericht, als Schritte geschrieben:

List<string> result = orders
.Where(o => o.Total >= 50)
.Select(o => o.Customer)
.Order()
.ToList();
Console.WriteLine(string.Join(", ", result));
Ausgabe
Anna, Lukas, Maria

Du liest ihn von oben nach unten: Nimm die Bestellungen, behalte die ab 50 €, nimm den Kunden, sortiere, sammle in einer Liste.

Diese Methoden sind LINQ (Language Integrated Query, „in die Sprache eingebaute Abfragen“). Sie liegen in System.Linq und funktionieren mit allem, was du mit foreach durchlaufen kannst: einer Liste, einem Array, einem Dictionary. Was sie zurückgeben, ist eine Abfrage (IEnumerable<T>). Eine Abfrage speichert keine Daten. Sie ist ein Förderband, auf dem die Elemente eins nach dem anderen laufen. Jeder Schritt nimmt ein Lambda aus der vorigen Lektion:

  • Where(Bedingung) lässt nur die Elemente weiter, für die die Bedingung wahr ist. Das Lambda ist ein Func<Order, bool>.
  • Select(Funktion) macht aus jedem Element ein anderes: aus einer Order einen string. Das ist ein Func<Order, string>.
  • Order() sortiert nach der natürlichen Reihenfolge, OrderBy(o => o.Total) nach einem Schlüssel, den du wählst.
  • ToList() sammelt alles, was am Ende angekommen ist, in einer neuen List<string>.

Die Liste orders ändert sich nicht. Die Abfrage liest sie nur.

Genauer, für Neugierige
  • Where, Select, Order, OrderBy und Take geben eine neue Abfrage zurück und tun noch nichts. ToList, Count, Sum, First und foreach verlangen ein Ergebnis und starten das Förderband.
  • ToList() gibt eine gewöhnliche List<T> zurück, die du ändern kannst. Ein Array liefert ToArray().
  • Order() gibt es seit .NET 7. In älterem Code siehst du OrderBy(name => name): Das macht dasselbe.
  • Es gibt noch eine zweite Schreibweise, die wie SQL aussieht. Der Compiler macht daraus dieselben Aufrufe. In diesem Kurs schreiben wir die Methoden:
List<string> result = (from o in orders
where o.Total >= 50
orderby o.Customer
select o.Customer).ToList();
Console.WriteLine(string.Join(", ", result));
Ausgabe
Anna, Lukas, Maria

Sieh es dir an

Was durch den Filter kommt

List<int> prices = new List<int> { 12, 75, 40, 130, 58 };
List<string> labels = prices
.Where(p => p >= 50)
.Select(p => p + " €")
.ToList();
Console.WriteLine(string.Join(", ", labels));
Schritt für Schritt, in Worten
  1. Fünf Preise. Die Abfrage legt nur den Weg an: Noch ist nichts hindurchgegangen.
  2. 12 besteht p >= 50 nicht und fällt heraus. Es kommt nie bei Select an.
  3. 75 besteht Where, Select macht daraus den Text „75 €“, ToList() sammelt ihn.
  4. Auch 40 fällt heraus. Jedes Element geht den ganzen Weg, bevor das nächste startet.
  5. 130 und 58 kommen durch. Die Liste prices ändert sich nicht.
  6. Das Ergebnis ist eine neue Liste mit drei Strings.

Die fünf Preise laufen einer nach dem anderen über das Förderband. 12 kommt bei Where an und fällt heraus. 75 kommt durch, Select macht daraus den Text „75 €“, und er landet in der Liste. Dann fällt 40 heraus, und 130 und 58 kommen durch.

Jedes Element geht den ganzen Weg, bevor das nächste startet. Und nichts startet, bis ToList() ein Ergebnis verlangt.

Genauer, für Neugierige

Das heißt Lazy Evaluation (bedarfsgesteuerte Auswertung). Die Methoden der Abfrage merken sich nur, was zu tun ist. Sieh es dir mit einer Ausgabe in jedem Schritt an:

IEnumerable<int> query = new List<int> { 12, 75, 40 }
.Where(p =>
{
Console.WriteLine("Where " + p);
return p >= 50;
})
.Select(p =>
{
Console.WriteLine("Select " + p);
return p * 2;
});
Console.WriteLine("Die Abfrage ist bereit, noch ist nichts hindurchgegangen.");
Console.WriteLine(string.Join(", ", query.ToList()));
Ausgabe
Die Abfrage ist bereit, noch ist nichts hindurchgegangen.
Where 12
Where 75
Select 75
Where 40
150
  • Die erste Zeile der Ausgabe erscheint, bevor auch nur ein Element durch die Abfrage gelaufen ist. Die C#-Dokumentation nennt das verzögerte Ausführung (deferred execution).
  • Select 75 kommt direkt nach Where 75: Die Elemente warten nicht, bis alle Where passiert haben.
  • Deshalb hält Take(2) die Abfrage an, sobald zwei Elemente am Ende angekommen sind. Die übrigen werden gar nicht erst geprüft:
IEnumerable<int> firstTwo = new List<int> { 75, 130, 58, 12, 99 }
.Where(p =>
{
Console.WriteLine("Where " + p);
return p >= 50;
})
.Take(2);
Console.WriteLine(string.Join(", ", firstTwo));
Ausgabe
Where 75
Where 130
75, 130
  • Eine Abfrage wird nicht verbraucht. Jedes Durchlaufen startet sie von vorn: Die beiden Count() gehen beide über die vier Preise, also gibt es 8 Prüfungen. Brauchst du das Ergebnis mehrmals, sammle es einmal mit ToList().
int checks = 0;
IEnumerable<int> expensive = new List<int> { 12, 75, 40, 130 }.Where(p =>
{
checks++;
return p >= 50;
});
Console.WriteLine(expensive.Count());
Console.WriteLine(expensive.Count());
Console.WriteLine("Prüfungen: " + checks);
Ausgabe
2
2
Prüfungen: 8
  • AsParallel() schickt die Elemente gleichzeitig durch mehrere Threads (PLINQ). Das hilft bei vielen Daten und aufwendigen Berechnungen. Bei kleinen Listen ist es langsamer, und die Lambdas dürfen keine gemeinsamen Daten anfassen.

Die Idee

Summe, Anzahl, Durchschnitt

Das Ergebnis kann statt einer Liste auch eine Zahl sein. Die Summe der geraden Zahlen:

List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
int sum = numbers.Where(n => n % 2 == 0).Sum();
Console.WriteLine("Summe der geraden Zahlen: " + sum);
Ausgabe
Summe der geraden Zahlen: 6
Sum() addiert die Zahlen, die am Ende angekommen sind. Mit einem Selektor, Sum(o => o.Total), nimmt es zuerst die Zahl aus jedem Element.

Dasselbe für die Bestellungen:

int big = orders.Count(o => o.Total >= 50);
decimal revenue = orders.Sum(o => o.Total);
decimal average = orders.Average(o => o.Total);
decimal maxTotal = orders.Max(o => o.Total);
Console.WriteLine("Bestellungen ab 50 €: " + big);
Console.WriteLine($"Umsatz: {revenue:F2} €");
Console.WriteLine($"Durchschnitt: {average:F2} €");
Console.WriteLine($"Größte: {maxTotal:F2} €");
Ausgabe
Bestellungen ab 50 €: 3
Umsatz: 312.40 €
Durchschnitt: 62.48 €
Größte: 120.00 €
  • Count(Bedingung) zählt die Elemente, für die die Bedingung wahr ist. Der Typ ist int.
  • Average und Max können für eine leere Liste keine Antwort geben: Einen Durchschnitt von nichts gibt es nicht. Dann werfen sie eine InvalidOperationException. Sum einer leeren Liste ist 0. Was du mit einem Ergebnis machst, das fehlen kann, ist die nächste Idee.
Genauer, für Neugierige

Mit einer leeren Liste. DefaultIfEmpty(0) liefert einen Ersatzwert: Gibt es keine Elemente, liefert die Abfrage ein Element, 0.

List<decimal> prices = new List<decimal>();
Console.WriteLine(prices.Sum());
Console.WriteLine(prices.DefaultIfEmpty(0).Max());
try
{
Console.WriteLine(prices.Max());
}
catch (InvalidOperationException e)
{
Console.WriteLine("InvalidOperationException: " + e.Message);
}
Ausgabe
0
0
InvalidOperationException: Sequence contains no elements

Die Idee

Sortieren, abschneiden, ohne Wiederholungen

List<int> ratings = new List<int> { 5, 3, 5, 4, 2, 4, 5 };
Console.WriteLine(string.Join(", ", ratings.Distinct().Order()));
List<string> top = orders
.OrderByDescending(o => o.Total)
.Take(3)
.Select(o => o.Customer)
.ToList();
Console.WriteLine(string.Join(", ", top));
foreach (int i in Enumerable.Range(1, 3))
{
Console.WriteLine("Tisch " + i);
}
Ausgabe
2, 3, 4, 5
Lukas, Maria, Anna
Tisch 1
Tisch 2
Tisch 3
  • Distinct() entfernt die Wiederholungen. Es vergleicht mit Equals und GetHashCode.
  • OrderByDescending(o => o.Total) sortiert ab der größten Bestellung.
  • Take(3) lässt nur die ersten drei durch. Zusammen mit dem Sortieren ist das eine „Top 3“.
  • Enumerable.Range(1, 3) liefert die Zahlen 1, 2, 3. Der zweite Parameter ist die Anzahl der Zahlen, nicht das Ende.
  • ForEach hat nur List<T>. Eine Abfrage durchläufst du mit einem gewöhnlichen foreach.

Probier es aus

Was kommt am Ende an?

Was gibt das Programm aus?

List<string> drinks = new List<string> { "kaffee", "tee", "kaffee", "wasser", "tee", "saft" };
List<string> result = drinks
.Distinct()
.Order()
.Take(3)
.Select(d => d.ToUpper())
.ToList();
Console.WriteLine(string.Join(", ", result));
Antwort

Die Idee

null: ein Ergebnis, das fehlen kann

Welche Bestellung ist die größte? Ist die Liste leer, gibt es keine Antwort. Dann ist das Ergebnis null: „kein Objekt“. Aber der Aufrufer muss daran denken, es zu prüfen. Vergisst er es, bricht das Programm irgendwann später mit einer NullReferenceException ab.

Java hat dafür Optional: eine Box, die entweder einen Wert enthält oder leer ist. C# hat keine solche Box. Die LINQ-Methoden, die ein einzelnes Element suchen, geben null zurück, wenn es keins gibt, und die Prüfung ist deine Aufgabe:

Order biggest = orders.MaxBy(o => o.Total);
if (biggest != null)
{
Console.WriteLine("Größte: " + biggest.Customer);
}
Order huge = orders.FirstOrDefault(o => o.Total > 500);
Console.WriteLine(huge != null);
Console.WriteLine(huge?.Customer ?? "keine gefunden");
Ausgabe
Größte: Lukas
False
keine gefunden
  • MaxBy(o => o.Total) liefert die Bestellung mit dem größten Betrag, nicht den Betrag selbst. Für eine leere Liste von Objekten liefert es null.
  • FirstOrDefault(Bedingung) liefert das erste Element, für das die Bedingung wahr ist, oder null, wenn es keins gibt.
  • huge?.Customer: Ist huge gleich null, ist der ganze Ausdruck null, statt dass das Programm abbricht.
  • x ?? y liefert y, wenn x gleich null ist, und sonst x.

Auch deine eigenen Methoden geben null zurück, wenn sie keine Antwort haben:

static string Nickname(string user)
{
// Gibt den Spitznamen zurück oder null, wenn die Person keinen hat.
return user == "maria" ? "Mimi" : null;
}
string maria = Nickname("maria");
if (maria != null)
{
Console.WriteLine("Spitzname: " + maria);
}
string tom = Nickname("tom");
Console.WriteLine(tom ?? "kein Spitzname");
Ausgabe
Spitzname: Mimi
kein Spitzname
Genauer, für Neugierige
  • Wenn du die Prüfung vergisst:
Order huge = orders.FirstOrDefault(o => o.Total > 500);
Console.WriteLine(huge.Customer);
Fehler
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
at Program.Main()
  • Ist ein leeres Ergebnis ein Fehler, schreib First und nicht FirstOrDefault. Der Name sagt ehrlich, was passieren wird:
try
{
Order huge = orders.First(o => o.Total > 500);
Console.WriteLine(huge.Customer);
}
catch (InvalidOperationException e)
{
Console.WriteLine("InvalidOperationException: " + e.Message);
}
Ausgabe
InvalidOperationException: Sequence contains no matching element
  • int, decimal und die anderen Werttypen haben kein null. Dann liefert FirstOrDefault 0, außer du gibst ihm einen anderen Ersatzwert:
List<int> ratings = new List<int> { 5, 3, 4 };
Console.WriteLine(ratings.FirstOrDefault(r => r > 5));
Console.WriteLine(ratings.FirstOrDefault(r => r > 5, -1));
Ausgabe
0
-1
  • In einem neuen Projekt (dotnet new console) sind die null-Prüfungen eingeschaltet (<Nullable>enable</Nullable>). Dort schreibt man einen Typ, der null sein kann, mit ?: Order? huge. Speicherst du das Ergebnis von FirstOrDefault in einer Order ohne ?, warnt dich der Compiler.
  • Any und All antworten mit True oder False, ob irgendein Element oder jedes Element die Bedingung erfüllt:
Console.WriteLine(orders.Any(o => o.Total > 100));
Console.WriteLine(orders.All(o => o.Total >= 10));
Ausgabe
True
True

Probier es aus

Finde den Fehler

Das Programm zählt die teuren Preise und gibt sie dann aus. Dazwischen kommt ein neuer Preis, 99, in die Liste, aber der Bericht soll die Preise zum Zeitpunkt des Zählens zeigen. Die Anzahl ist 2, aber es erscheinen drei Preise. In welcher Zeile ist der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Gruppieren und verbinden

Wie viele Bestellungen gibt es aus jeder Stadt, und wie hoch ist der Umsatz?

var byCity = orders
.GroupBy(o => o.City)
.OrderBy(g => g.Key);
foreach (var group in byCity)
{
Console.WriteLine($"{group.Key}: Anzahl {group.Count()}, Umsatz {group.Sum(o => o.Total):F2} €");
}
string customers = string.Join(", ", orders.Select(o => o.Customer).Order());
Console.WriteLine(customers);
Ausgabe
Berlin: Anzahl 1, Umsatz 120.00 €
Dresden: Anzahl 2, Umsatz 76.90 €
Hamburg: Anzahl 2, Umsatz 115.50 €
Anna, Laura, Lukas, Maria, Tom

GroupBy(o => o.City) teilt die Bestellungen in Gruppen nach Stadt. Jede Gruppe hat einen Key (die Stadt), und du durchläufst sie wie eine Liste ihrer Bestellungen, deshalb funktionieren Count() und Sum(...) auf ihr.

  • OrderBy(g => g.Key) sortiert die Gruppen nach Stadt. Ohne es kommen die Gruppen in der Reihenfolge heraus, in der die Städte zum ersten Mal vorkommen: Hamburg, Dresden, Berlin.
  • string.Join(", ", ...) verbindet die Strings zu einem, mit einem Komma dazwischen.

Der Typ der Gruppen ist lang (IOrderedEnumerable<IGrouping<string, Order>>), deshalb steht dort var: Der Compiler ermittelt ihn selbst.

Genauer, für Neugierige
  • ToDictionary macht aus den Gruppen ein Dictionary: für jede Stadt eine Liste der Namen. Die Reihenfolge der Schlüssel in einem Dictionary ist nicht garantiert, deshalb werden die Städte ausdrücklich sortiert:
Dictionary<string, List<string>> namesByCity = orders
.GroupBy(o => o.City)
.ToDictionary(g => g.Key, g => g.Select(o => o.Customer).ToList());
foreach (string city in namesByCity.Keys.Order())
{
Console.WriteLine(city + ": " + string.Join(", ", namesByCity[city]));
}
Ausgabe
Berlin: Lukas
Dresden: Tom, Anna
Hamburg: Maria, Laura
  • ToDictionary(Schlüssel, Wert) macht ein Dictionary mit einem Wert pro Schlüssel und wirft eine Exception, wenn sich ein Schlüssel wiederholt:
try
{
Dictionary<string, string> customerByCity = orders.ToDictionary(o => o.City, o => o.Customer);
}
catch (ArgumentException e)
{
Console.WriteLine("ArgumentException: " + e.Message);
}
Ausgabe
ArgumentException: An item with the same key has already been added. Key: Hamburg

Übung

Berichte mit LINQ

Löse die Aufgaben in deinem Editor. In einem neuen Projekt ist System.Linq schon eingebunden.

1. Verkäufe nach Kategorie. Eine Klasse Sale mit Produkt, Kategorie, Menge und Stückpreis (decimal) und einer Property Revenue (Menge mal Preis). Gruppiere mit GroupBy nach Kategorie, addiere jede Gruppe mit Sum, gib die Kategorien alphabetisch und den Gesamtumsatz aus. Die Verkäufe: Kaffeebohnen (Kategorie „Kaffee und Tee“), 12 Stück zu 18,50 €; Tee („Kaffee und Tee“), 30 zu 4,20 €; Schokolade („Snacks“), 40 zu 2,80 €; Kekse („Snacks“), 25 zu 3,10 €; Honig („Feinkost“), 8 zu 12 €; noch einmal Kaffeebohnen, 5 zu 18,50 €.

Ausgabe
Feinkost: 96.00 €
Kaffee und Tee: 440.50 €
Snacks: 189.50 €
Gesamt: 726.00 €

2. Die Top 3 der Produkte. Finde mit denselben Verkäufen die drei Produkte mit dem höchsten Umsatz. Der Kaffee steht in zwei Verkäufen, deshalb gruppierst du zuerst nach Produkt und addierst, dann sortierst du die Gruppen nach ihrer Summe. Tipp: GroupBy(s => s.Product), dann OrderByDescending(g => g.Sum(s => s.Revenue)) und Take(3): Jede Gruppe hat einen Key (das Produkt) und ihre Verkäufe.

Ausgabe
Kaffeebohnen: 314.50 €
Tee: 126.00 €
Schokolade: 112.00 €

3. Die Durchschnittsnote. Lies eine Zeile mit Noten, getrennt durch Leerzeichen. Gib den Durchschnitt aus, die höchste Note und wie viele ausgezeichnet sind (5,50 und mehr). Tipp: Console.ReadLine().Split(' ').Select(double.Parse).

Eingabe
4.50 5.75 6.00 3.00 5.50
Ausgabe
Durchschnitt: 4.95
Höchste: 6.00
Ausgezeichnet: 3

4. Die Wörter in einem Text. Lies eine Zeile Text und gib die Wörter darin ohne Wiederholungen aus, alphabetisch, kleingeschrieben, durch Kommas getrennt. Mach aus jedem Zeichen, das kein Buchstabe ist, ein Leerzeichen: text.Select(c => char.IsLetter(c) ? c : ' '), und mit new string(...) einen String aus diesen Zeichen. Teile dann mit Split(' ', StringSplitOptions.RemoveEmptyEntries): Die leeren Teile fallen weg.

Eingabe
Kaffee mit Milch, Kaffee ohne Zucker. Tee mit Honig!
Ausgabe
honig, kaffee, milch, mit, ohne, tee, zucker

Weitere Aufgaben

  • Das längste Wort. Finde mit MaxBy(w => w.Length) das längste Wort in einem Satz. Was gibt das Programm bei einer leeren Zeile zurück?
  • Durchschnitt pro Klasse. Schüler mit Name, Klasse und Note: die Durchschnittsnote jeder Klasse mit GroupBy und Average.
  • Große und kleine Bestellungen. Prüfe mit Any, ob es eine Bestellung über 1000 € gibt, und mit All, ob alle mindestens 10 € betragen.

Test

Teste dich

1. Was passiert mit der Liste orders nach orders.Where(o => o.Total > 50).ToList()?
2. Welche davon startet die Abfrage?
3. orders.FirstOrDefault(o => o.Total > 500) findet keine Bestellung. Was gibt es zurück?
4. Warum hat der Bericht nach Städten OrderBy(g => g.Key) nach GroupBy?
5. IEnumerable<int> q = prices.Where(p => p >= 50); Console.WriteLine(q.Count()); prices.Add(99); Console.WriteLine(q.Count()); Was passiert?

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