LINQ
En resumen: LINQ procesa una colección como una serie de pasos: Where criba, Select transforma, OrderBy ordena, y Sum y Average hacen las cuentas.
Problema
Un informe con un bucle
Ahora los pedidos también tienen una ciudad:
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("María", "Madrid", 75.50m), new Order("Jorge", "Valencia", 12.90m), new Order("Elena", "Madrid", 40.00m), new Order("Diego", "Granada", 120.00m), new Order("Ana", "Valencia", 64.00m),};Quieres los clientes con pedidos de al menos 50 €, en orden alfabético:
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));Ana, Diego, MaríaDiez líneas, y la pregunta es una sola. El código dice cómo: crea una lista vacía, recorre, comprueba, añade, ordena. Para entender qué hace, tienes que ejecutarlo en tu cabeza. Añade «solo los tres primeros» o «sin repetidos», y el bucle crecerá.
La idea
LINQ: una serie de pasos
El mismo informe, escrito como pasos:
List<string> result = orders .Where(o => o.Total >= 50) .Select(o => o.Customer) .Order() .ToList();Console.WriteLine(string.Join(", ", result));Ana, Diego, MaríaSe lee de arriba abajo: toma los pedidos, quédate con los de al menos 50 €, toma el cliente, ordena, reúne en una lista.
Estos métodos son LINQ (Language Integrated Query). Viven en System.Linq y funcionan con todo lo que puedes recorrer con foreach: una lista, un array, un diccionario. Lo que devuelven es una consulta (IEnumerable<T>). Una consulta no guarda datos. Es una cinta transportadora por la que los elementos avanzan de uno en uno. Cada paso recibe una lambda de la lección anterior:
Where(condición)deja pasar solo los elementos para los que la condición es verdadera. La lambda es unFunc<Order, bool>.Select(función)convierte cada elemento en otro: crea unstringa partir de unOrder. Es unFunc<Order, string>.Order()ordena según el orden natural, yOrderBy(o => o.Total)según la clave que elijas.ToList()reúne en una nuevaList<string>todo lo que llegó al final.
La lista orders no cambia. La consulta solo la lee.
Más precisamente, para curiosos
Where,Select,Order,OrderByyTakedevuelven una consulta nueva y todavía no hacen nada.ToList,Count,Sum,Firstyforeachpiden un resultado y ponen en marcha la cinta.ToList()devuelve unaList<T>normal que puedes cambiar.ToArray()da un array.Order()es de .NET 7. En código más antiguo verásOrderBy(name => name): hace lo mismo.- Hay también una segunda forma de escribirlo, que se parece a SQL. El compilador la convierte en las mismas llamadas. Este curso escribe los métodos:
List<string> result = (from o in orders where o.Total >= 50 orderby o.Customer select o.Customer).ToList();Console.WriteLine(string.Join(", ", result));Ana, Diego, MaríaMíralo
Qué pasa el filtro
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));Paso a paso, en palabras
- Cinco precios. La consulta solo prepara el camino: todavía no ha pasado nada.
- 12 no pasa p >= 50 y se queda fuera. Nunca llega a Select.
- 75 pasa Where, Select lo convierte en el texto «75 €», ToList() lo reúne.
- 40 también se queda fuera. Cada elemento recorre todo el camino antes de que salga el siguiente.
- 130 y 58 pasan. La lista prices no cambia.
- El resultado es una lista nueva con tres cadenas.
Los cinco precios avanzan por la cinta de uno en uno. El 12 llega a Where y se queda fuera. El 75 pasa, Select lo convierte en el texto «75 €» y entra en la lista. Después el 40 se queda fuera, y el 130 y el 58 pasan.
Cada elemento hace todo el recorrido antes de que empiece el siguiente. Y nada empieza hasta que ToList() pide un resultado.
Más precisamente, para curiosos
Esto se llama evaluación perezosa. Los métodos de consulta solo anotan lo que hay que hacer. Míralo con un print en cada paso:
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("La consulta está lista, todavía no pasó nada por ella.");Console.WriteLine(string.Join(", ", query.ToList()));La consulta está lista, todavía no pasó nada por ella.Where 12Where 75Select 75Where 40150- La primera línea de la salida se imprime antes de que un solo elemento haya pasado por la consulta. La documentación de C# lo llama ejecución diferida.
Select 75llega justo después deWhere 75: los elementos no esperan a que todos pasen porWhere.- Por eso
Take(2)detiene la consulta en cuanto dos elementos llegan al final. Los demás ni siquiera se comprueban:
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- Una consulta no se consume. Cada vez que la recorres, se ejecuta desde el principio: las dos llamadas a
Count()recorren los cuatro precios, así que hay 8 comprobaciones. Si necesitas el resultado varias veces, reúnelo una vez conToList().
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("Comprobaciones: " + checks);22Comprobaciones: 8AsParallel()envía los elementos por varios hilos a la vez (PLINQ). Ayuda con muchos datos y cálculos pesados. Con listas pequeñas es más lento, y las lambdas no deben tocar datos compartidos.
La idea
Suma, cantidad, media
El resultado puede ser un número en lugar de una lista. La suma de los números pares:
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };int sum = numbers.Where(n => n % 2 == 0).Sum();Console.WriteLine("Suma de los números pares: " + sum);Suma de los números pares: 6Sum() suma los números que llegaron al final. Con un selector, Sum(o => o.Total), primero toma el número de cada elemento.Lo mismo para los pedidos:
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("Pedidos de desde 50 €: " + big);Console.WriteLine($"Ingresos: {revenue:F2} €");Console.WriteLine($"Media: {average:F2} €");Console.WriteLine($"Mayor: {maxTotal:F2} €");Pedidos de desde 50 €: 3Ingresos: 312.40 €Media: 62.48 €Mayor: 120.00 €Count(condición)cuenta los elementos para los que la condición es verdadera. El tipo esint.AverageyMaxno pueden dar una respuesta para una lista vacía: no hay media de la nada. Entonces lanzan unaInvalidOperationException.Sumde una lista vacía es 0. Qué hacer con un resultado que puede faltar es la siguiente idea.
Más precisamente, para curiosos
Con una lista vacía. DefaultIfEmpty(0) da un valor de reserva: si no hay elementos, la consulta da un elemento, el 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 elementsLa idea
Ordenar, recortar, sin repetidos
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("Mesa " + i);}2, 3, 4, 5Diego, María, AnaMesa 1Mesa 2Mesa 3Distinct()quita los repetidos. Compara conEqualsyGetHashCode.OrderByDescending(o => o.Total)ordena empezando por el pedido más grande.Take(3)deja pasar solo los tres primeros. Junto con la ordenación, eso es un «top 3».Enumerable.Range(1, 3)da los números 1, 2, 3. El segundo parámetro es cuántos números, no dónde parar.- Solo
List<T>tieneForEach. Una consulta se recorre con unforeachnormal.
Pruébalo
¿Qué llega al final?
¿Qué imprimirá el programa?
List<string> drinks = new List<string> { "café", "té", "café", "vino", "té", "jugo" };List<string> result = drinks .Distinct() .Order() .Take(3) .Select(d => d.ToUpper()) .ToList();Console.WriteLine(string.Join(", ", result));Distinct() deja café, té, vino, jugo. Order() los ordena alfabéticamente: café, jugo, té, vino. Take(3) se queda con los tres primeros, y Select los pasa a mayúsculas.La idea
null: un resultado que puede faltar
¿Cuál es el pedido más grande? Si la lista está vacía, no hay respuesta. Entonces el resultado es null: «ningún objeto». Pero quien llama tiene que acordarse de comprobarlo. Si se le olvida, el programa se detiene con una NullReferenceException en algún sitio más adelante.
Java tiene para esto Optional: una caja que o bien contiene un valor, o bien está vacía. C# no tiene una caja así. Los métodos de LINQ que buscan un elemento devuelven null si no lo hay, y la comprobación es cosa tuya:
Order biggest = orders.MaxBy(o => o.Total);if (biggest != null){ Console.WriteLine("Mayor: " + biggest.Customer);}
Order huge = orders.FirstOrDefault(o => o.Total > 500);Console.WriteLine(huge != null);Console.WriteLine(huge?.Customer ?? "no existe ese pedido");Mayor: DiegoFalseno existe ese pedidoMaxBy(o => o.Total)da el pedido con el total más grande, no el total en sí. Para una lista vacía de objetos danull.FirstOrDefault(condición)da el primer elemento para el que la condición es verdadera, onullsi no hay ninguno.huge?.Customer: sihugeesnull, toda la expresión esnullen lugar de que el programa se detenga.x ?? ydaysixesnull, yxen caso contrario.
Tus propios métodos también devuelven null cuando no tienen respuesta:
static string Nickname(string user){ // Devuelve el apodo, o null si la persona no tiene. return user == "maria" ? "Mimi" : null;}string maria = Nickname("maria");if (maria != null){ Console.WriteLine("Apodo: " + maria);}
string jorge = Nickname("jorge");Console.WriteLine(jorge ?? "sin apodo");Apodo: Mimisin apodoMás precisamente, para curiosos
- Si se te olvida la comprobación:
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()- Si un resultado vacío es un error, escribe
First, noFirstOrDefault. El nombre dice honestamente lo que va a pasar:
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,decimaly los demás tipos de valor no tienennull. EntoncesFirstOrDefaultda 0, a menos que le des otro valor de reserva:
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- En un proyecto nuevo (
dotnet new console) las comprobaciones denullestán activadas (<Nullable>enable</Nullable>). Allí, un tipo que puede sernullse escribe con?:Order? huge. Si guardas el resultado deFirstOrDefaulten unOrdersin?, el compilador te avisa. AnyyAllrespondenTrueoFalse: ¿algún elemento, o todos los elementos, cumplen la condición?
Console.WriteLine(orders.Any(o => o.Total > 100));Console.WriteLine(orders.All(o => o.Total >= 10));TrueTruePruébalo
Encuentra el error
Elige la línea con el error.
Una consulta no es una lista, es una receta. En la línea 2 todavía no ha filtrado nada. Count() la ejecuta una vez, y string.Join en la línea 5 la ejecuta otra vez, sobre la lista tal como está en ese momento, con el 99 dentro:
Cantidad: 2Precios: 75, 130, 99Si necesitas el resultado de un momento concreto, reúnelo en la línea 2 con ToList(). Entonces expensive es una lista aparte, y el precio nuevo no le afecta:
List<int> prices = new List<int> { 12, 75, 40, 130 };List<int> expensive = prices.Where(p => p >= 50).ToList();Console.WriteLine("Cantidad: " + expensive.Count);prices.Add(99);Console.WriteLine("Precios: " + string.Join(", ", expensive));Cantidad: 2Precios: 75, 130Al revés es un error: añadir a la lista mientras se recorre una consulta sobre ella. Entonces el programa se detiene:
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.La idea
Agrupar y unir
¿Cuántos pedidos hay de cada ciudad, y cuáles son los ingresos?
var byCity = orders .GroupBy(o => o.City) .OrderBy(g => g.Key);foreach (var group in byCity){ Console.WriteLine($"{group.Key}: cantidad {group.Count()}, ingresos {group.Sum(o => o.Total):F2} €");}
string customers = string.Join(", ", orders.Select(o => o.Customer).Order());Console.WriteLine(customers);Granada: cantidad 1, ingresos 120.00 €Madrid: cantidad 2, ingresos 115.50 €Valencia: cantidad 2, ingresos 76.90 €Ana, Diego, Elena, Jorge, MaríaGroupBy(o => o.City) divide los pedidos en grupos por ciudad. Cada grupo tiene una Key (la ciudad), y lo recorres como una lista de sus pedidos, así que Count() y Sum(...) funcionan con él.
OrderBy(g => g.Key)ordena los grupos por ciudad. Sin él, los grupos salen en el orden en que aparecen por primera vez las ciudades: Madrid, Valencia, Granada.string.Join(", ", ...)une las cadenas en una sola, con una coma entre ellas.
El tipo de los grupos es largo (IOrderedEnumerable<IGrouping<string, Order>>), así que es var: el compilador lo deduce solo.
Más precisamente, para curiosos
ToDictionarycrea un diccionario a partir de los grupos: para cada ciudad, una lista de los nombres. El orden de las claves en unDictionaryno está garantizado, así que las ciudades se ordenan explícitamente:
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]));}Granada: DiegoMadrid: María, ElenaValencia: Jorge, AnaToDictionary(clave, valor)crea un diccionario con un valor por clave, y lanza una excepción si una clave se repite:
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: MadridEjercicio
Informes con LINQ
System.Linq ya está incluido.1. Ventas por categoría. Una clase Sale con un producto, una categoría, una cantidad y un precio unitario (decimal), y una propiedad Revenue (cantidad por precio). Agrupa por categoría con GroupBy, suma cada grupo con Sum, imprime las categorías en orden alfabético y los ingresos totales. Las ventas: café en grano, bebidas, 12 a 18,50 €; té, bebidas, 30 a 4,20 €; chocolate, dulces, 40 a 2,80 €; galletas, dulces, 25 a 3,10 €; miel, comida, 8 a 12 €; otra vez café en grano, 5 a 18,50 €.
Bebidas: 440.50 €Comida: 96.00 €Dulces: 189.50 €Total: 726.00 €2. Los 3 mejores productos. Con las mismas ventas, encuentra los tres productos con más ingresos. El café está en dos ventas, así que primero agrupa por producto y suma, y luego ordena los grupos por su suma. Pista: GroupBy(s => s.Product), luego OrderByDescending(g => g.Sum(s => s.Revenue)) y Take(3): cada grupo tiene una Key (el producto) y sus ventas.
Café en grano: 314.50 €Té: 126.00 €Chocolate: 112.00 €3. Nota media. Lee una línea de notas separadas por espacios. Imprime la media, la más alta y cuántas son excelentes (5,50 o más). Pista: Console.ReadLine().Split(' ').Select(double.Parse).
4.50 5.75 6.00 3.00 5.50Media: 4.95Máxima: 6.00Excelentes: 34. Las palabras de un texto. Lee una línea de texto e imprime sus palabras sin repetidos, en orden alfabético, en minúsculas y separadas por comas. Convierte en un espacio cada carácter que no sea una letra: text.Select(c => char.IsLetter(c) ? c : ' '), y new string(...) a partir de esos caracteres. Después divide con Split(' ', StringSplitOptions.RemoveEmptyEntries): las partes vacías se eliminan.
Café con leche, café sin azúcar. ¡Té con miel!azúcar, café, con, leche, miel, sin, téMás tareas
- La palabra más larga. Con
MaxBy(w => w.Length), encuentra la palabra más larga de una frase. ¿Qué devolverá el programa para una línea vacía? - Media por grupo. Estudiantes con un nombre, un grupo y una nota: la nota media de cada grupo con
GroupByyAverage. - Pedidos grandes y pequeños. Con
Any, comprueba si hay un pedido de más de 1000 €, y conAll, si todos son de al menos 10 €.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Listas · Dictionary y HashSet · Clases anidadas · Lambdas y delegados
- Continúa en: record, switch y patrones · Tests con xUnit · Bases de datos · Reflexión y atributos
- Ejercicios sobre el tema: C# moderno