Ir al contenido
wannacode

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:

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("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));
Salida
Ana, Diego, María

Diez 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));
Salida
Ana, Diego, María

Se 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 un Func<Order, bool>.
  • Select(función) convierte cada elemento en otro: crea un string a partir de un Order. Es un Func<Order, string>.
  • Order() ordena según el orden natural, y OrderBy(o => o.Total) según la clave que elijas.
  • ToList() reúne en una nueva List<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, OrderBy y Take devuelven una consulta nueva y todavía no hacen nada. ToList, Count, Sum, First y foreach piden un resultado y ponen en marcha la cinta.
  • ToList() devuelve una List<T> normal que puedes cambiar. ToArray() da un array.
  • Order() es de .NET 7. En código más antiguo verás OrderBy(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));
Salida
Ana, Diego, María

Mí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
  1. Cinco precios. La consulta solo prepara el camino: todavía no ha pasado nada.
  2. 12 no pasa p >= 50 y se queda fuera. Nunca llega a Select.
  3. 75 pasa Where, Select lo convierte en el texto «75 €», ToList() lo reúne.
  4. 40 también se queda fuera. Cada elemento recorre todo el camino antes de que salga el siguiente.
  5. 130 y 58 pasan. La lista prices no cambia.
  6. 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()));
Salida
La consulta está lista, todavía no pasó nada por ella.
Where 12
Where 75
Select 75
Where 40
150
  • 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 75 llega justo después de Where 75: los elementos no esperan a que todos pasen por Where.
  • 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));
Salida
Where 75
Where 130
75, 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 con 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("Comprobaciones: " + checks);
Salida
2
2
Comprobaciones: 8
  • AsParallel() 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);
Salida
Suma de los números pares: 6
Sum() 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} €");
Salida
Pedidos de desde 50 €: 3
Ingresos: 312.40 €
Media: 62.48 €
Mayor: 120.00 €
  • Count(condición) cuenta los elementos para los que la condición es verdadera. El tipo es int.
  • Average y Max no pueden dar una respuesta para una lista vacía: no hay media de la nada. Entonces lanzan una InvalidOperationException. Sum de 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);
}
Salida
0
0
InvalidOperationException: Sequence contains no elements

La 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);
}
Salida
2, 3, 4, 5
Diego, María, Ana
Mesa 1
Mesa 2
Mesa 3
  • Distinct() quita los repetidos. Compara con Equals y GetHashCode.
  • 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> tiene ForEach. Una consulta se recorre con un foreach normal.

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));
Respuesta

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");
Salida
Mayor: Diego
False
no existe ese pedido
  • MaxBy(o => o.Total) da el pedido con el total más grande, no el total en sí. Para una lista vacía de objetos da null.
  • FirstOrDefault(condición) da el primer elemento para el que la condición es verdadera, o null si no hay ninguno.
  • huge?.Customer: si huge es null, toda la expresión es null en lugar de que el programa se detenga.
  • x ?? y da y si x es null, y x en 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");
Salida
Apodo: Mimi
sin apodo
Más precisamente, para curiosos
  • Si se te olvida la comprobación:
Order huge = orders.FirstOrDefault(o => o.Total > 500);
Console.WriteLine(huge.Customer);
Error
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, no FirstOrDefault. 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);
}
Salida
InvalidOperationException: Sequence contains no matching element
  • int, decimal y los demás tipos de valor no tienen null. Entonces FirstOrDefault da 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));
Salida
0
-1
  • En un proyecto nuevo (dotnet new console) las comprobaciones de null están activadas (<Nullable>enable</Nullable>). Allí, un tipo que puede ser null se escribe con ?: Order? huge. Si guardas el resultado de FirstOrDefault en un Order sin ?, el compilador te avisa.
  • Any y All responden True o False: ¿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));
Salida
True
True

Pruébalo

Encuentra el error

El programa cuenta los precios caros y luego los imprime. Entre las dos cosas entra en la lista un precio nuevo, 99, pero el informe tiene que ser sobre los precios del momento en que se contaron. La cantidad es 2, pero salen tres precios. ¿En qué línea está el error?

Elige la línea con el error.

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);
Salida
Granada: cantidad 1, ingresos 120.00 €
Madrid: cantidad 2, ingresos 115.50 €
Valencia: cantidad 2, ingresos 76.90 €
Ana, Diego, Elena, Jorge, María

GroupBy(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
  • ToDictionary crea un diccionario a partir de los grupos: para cada ciudad, una lista de los nombres. El orden de las claves en un Dictionary no 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]));
}
Salida
Granada: Diego
Madrid: María, Elena
Valencia: Jorge, Ana
  • ToDictionary(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);
}
Salida
ArgumentException: An item with the same key has already been added. Key: Madrid

Ejercicio

Informes con LINQ

Resuelve las tareas en tu editor. En un proyecto nuevo, 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 €.

Salida
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.

Salida
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).

Entrada
4.50 5.75 6.00 3.00 5.50
Salida
Media: 4.95
Máxima: 6.00
Excelentes: 3

4. 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.

Entrada
Café con leche, café sin azúcar. ¡Té con miel!
Salida
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 GroupBy y Average.
  • Pedidos grandes y pequeños. Con Any, comprueba si hay un pedido de más de 1000 €, y con All, si todos son de al menos 10 €.

Test

Ponte a prueba

1. ¿Qué le pasa a la lista orders después de orders.Where(o => o.Total > 50).ToList()?
2. ¿Cuál de estos pone en marcha la consulta?
3. orders.FirstOrDefault(o => o.Total > 500) no encuentra ningún pedido. ¿Qué devuelve?
4. ¿Por qué el informe por ciudad tiene OrderBy(g => g.Key) después de GroupBy?
5. IEnumerable<int> q = prices.Where(p => p >= 50); Console.WriteLine(q.Count()); prices.Add(99); Console.WriteLine(q.Count()); ¿Qué pasa?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·