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:
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));Anna, Lukas, MariaZehn 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));Anna, Lukas, MariaDu 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 einFunc<Order, bool>.Select(Funktion)macht aus jedem Element ein anderes: aus einerOrdereinenstring. Das ist einFunc<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 neuenList<string>.
Die Liste orders ändert sich nicht. Die Abfrage liest sie nur.
Genauer, für Neugierige
Where,Select,Order,OrderByundTakegeben eine neue Abfrage zurück und tun noch nichts.ToList,Count,Sum,Firstundforeachverlangen ein Ergebnis und starten das Förderband.ToList()gibt eine gewöhnlicheList<T>zurück, die du ändern kannst. Ein Array liefertToArray().Order()gibt es seit .NET 7. In älterem Code siehst duOrderBy(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));Anna, Lukas, MariaSieh 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
- Fünf Preise. Die Abfrage legt nur den Weg an: Noch ist nichts hindurchgegangen.
- 12 besteht p >= 50 nicht und fällt heraus. Es kommt nie bei Select an.
- 75 besteht Where, Select macht daraus den Text „75 €“, ToList() sammelt ihn.
- Auch 40 fällt heraus. Jedes Element geht den ganzen Weg, bevor das nächste startet.
- 130 und 58 kommen durch. Die Liste prices ändert sich nicht.
- 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()));Die Abfrage ist bereit, noch ist nichts hindurchgegangen.Where 12Where 75Select 75Where 40150- 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 75kommt direkt nachWhere 75: Die Elemente warten nicht, bis alleWherepassiert 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));Where 75Where 13075, 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 mitToList().
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);22Prüfungen: 8AsParallel()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);Summe der geraden Zahlen: 6Sum() 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} €");Bestellungen ab 50 €: 3Umsatz: 312.40 €Durchschnitt: 62.48 €Größte: 120.00 €Count(Bedingung)zählt die Elemente, für die die Bedingung wahr ist. Der Typ istint.AverageundMaxkönnen für eine leere Liste keine Antwort geben: Einen Durchschnitt von nichts gibt es nicht. Dann werfen sie eineInvalidOperationException.Sumeiner 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);}00InvalidOperationException: Sequence contains no elementsDie 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);}2, 3, 4, 5Lukas, Maria, AnnaTisch 1Tisch 2Tisch 3Distinct()entfernt die Wiederholungen. Es vergleicht mitEqualsundGetHashCode.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.ForEachhat nurList<T>. Eine Abfrage durchläufst du mit einem gewöhnlichenforeach.
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));Distinct() lässt kaffee, tee, wasser, saft übrig. Order() sortiert sie alphabetisch: kaffee, saft, tee, wasser. Take(3) behält die ersten drei, und Select schreibt sie groß.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");Größte: LukasFalsekeine gefundenMaxBy(o => o.Total)liefert die Bestellung mit dem größten Betrag, nicht den Betrag selbst. Für eine leere Liste von Objekten liefert esnull.FirstOrDefault(Bedingung)liefert das erste Element, für das die Bedingung wahr ist, odernull, wenn es keins gibt.huge?.Customer: Isthugegleichnull, ist der ganze Ausdrucknull, statt dass das Programm abbricht.x ?? ylieferty, wennxgleichnullist, und sonstx.
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");Spitzname: Mimikein SpitznameGenauer, für Neugierige
- Wenn du die Prüfung vergisst:
Order huge = orders.FirstOrDefault(o => o.Total > 500);Console.WriteLine(huge.Customer);Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object. at Program.Main()- Ist ein leeres Ergebnis ein Fehler, schreib
Firstund nichtFirstOrDefault. 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);}InvalidOperationException: Sequence contains no matching elementint,decimalund die anderen Werttypen haben keinnull. Dann liefertFirstOrDefault0, 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));0-1- In einem neuen Projekt (
dotnet new console) sind dienull-Prüfungen eingeschaltet (<Nullable>enable</Nullable>). Dort schreibt man einen Typ, dernullsein kann, mit?:Order? huge. Speicherst du das Ergebnis vonFirstOrDefaultin einerOrderohne?, warnt dich der Compiler. AnyundAllantworten mitTrueoderFalse, 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));TrueTrueProbier es aus
Finde den Fehler
Wähle die Zeile mit dem Fehler.
Eine Abfrage ist keine Liste, sondern ein Rezept. In Zeile 2 hat sie noch nichts gefiltert. Count() lässt sie einmal laufen, und string.Join in Zeile 5 lässt sie noch einmal laufen, auf der Liste, wie sie in diesem Moment ist, mit der 99 darin:
Anzahl: 2Preise: 75, 130, 99Brauchst du das Ergebnis zu einem bestimmten Zeitpunkt, sammle es in Zeile 2 mit ToList(). Dann ist expensive eine eigene Liste, und der neue Preis ändert sie nicht:
List<int> prices = new List<int> { 12, 75, 40, 130 };List<int> expensive = prices.Where(p => p >= 50).ToList();Console.WriteLine("Anzahl: " + expensive.Count);prices.Add(99);Console.WriteLine("Preise: " + string.Join(", ", expensive));Anzahl: 2Preise: 75, 130Umgekehrt ist es ein Fehler: etwas zur Liste hinzuzufügen, während eine Abfrage über sie durchlaufen wird. Dann bricht das Programm ab:
List<int> prices = new List<int> { 12, 75, 40, 130 };try{ foreach (int price in prices.Where(p => p >= 50)) { prices.Add(price * 2); }}catch (InvalidOperationException e){ Console.WriteLine("InvalidOperationException: " + e.Message);}InvalidOperationException: Collection was modified; enumeration operation may not execute.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);Berlin: Anzahl 1, Umsatz 120.00 €Dresden: Anzahl 2, Umsatz 76.90 €Hamburg: Anzahl 2, Umsatz 115.50 €Anna, Laura, Lukas, Maria, TomGroupBy(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
ToDictionarymacht aus den Gruppen ein Dictionary: für jede Stadt eine Liste der Namen. Die Reihenfolge der Schlüssel in einemDictionaryist 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]));}Berlin: LukasDresden: Tom, AnnaHamburg: Maria, LauraToDictionary(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);}ArgumentException: An item with the same key has already been added. Key: HamburgÜbung
Berichte mit LINQ
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 €.
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.
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).
4.50 5.75 6.00 3.00 5.50Durchschnitt: 4.95Höchste: 6.00Ausgezeichnet: 34. 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.
Kaffee mit Milch, Kaffee ohne Zucker. Tee mit Honig!honig, kaffee, milch, mit, ohne, tee, zuckerWeitere 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
GroupByundAverage. - Große und kleine Bestellungen. Prüfe mit
Any, ob es eine Bestellung über 1000 € gibt, und mitAll, ob alle mindestens 10 € betragen.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Listen · Dictionary und HashSet · Klassen in Klassen · Lambdas und Delegates
- Geht weiter in: record, switch und Muster · Tests mit xUnit · Datenbanken · Reflection und Attribute
- Aufgaben zum Thema: Modernes C#