Dos herramientas que distinguen a C# de casi cualquier otro lenguaje. LINQ permite filtrar, ordenar, agrupar y sumar colecciones con consultas cortas y legibles, en lugar de bucles con variables auxiliares. async/await permite esperar a la red, al disco o a un temporizador sin bloquear el programa, escribiendo el código casi como si fuera secuencial. Las dos se apoyan en lo de la lección 6: genéricos, lambdas e IEnumerable<T>.
LINQ: consultas sobre colecciones
LINQ (Language Integrated Query) es un conjunto de métodos de extensión sobre IEnumerable<T>: cualquier array, lista o secuencia los tiene. Cada uno recibe una lambda, como las de la lección 4, y devuelve otra secuencia, así que se encadenan. Usaremos esta lista de productos en toda la primera parte:
List<Producto> productos =
[
new("Teclado", "Periféricos", 49.90m, 12),
new("Ratón", "Periféricos", 19.90m, 0),
new("Monitor", "Pantallas", 189.00m, 4),
new("Portátil", "Equipos", 899.00m, 2),
new("Webcam", "Periféricos", 59.00m, 7),
new("Tableta", "Equipos", 329.00m, 0),
];
record Producto(string Nombre, string Categoria, decimal Precio, int Stock);var disponibles = productos
.Where(p => p.Stock > 0)
.OrderBy(p => p.Precio)
.Select(p => $"{p.Nombre} ({p.Precio} USD)");
foreach (var linea in disponibles)
{
Console.WriteLine(linea);
}salidaTeclado (49.90 USD) Webcam (59.00 USD) Monitor (189.00 USD) Portátil (899.00 USD)
Se lee de arriba abajo: de los productos, quédate con los que tienen stock, ordénalos por precio y conviértelos en un texto. Where filtra, OrderBy ordena (y OrderByDescending al revés, con ThenBy para desempatar) y Select transforma cada elemento en otra cosa. Fíjate en que decimal conserva los decimales con que se escribió el número: 49.90m se imprime como 49.90, no 49.9.
Contar, sumar y buscar
Otros métodos no devuelven una secuencia, sino un único valor:
Console.WriteLine(productos.Count(p => p.Stock == 0)); Console.WriteLine(productos.Sum(p => p.Precio * p.Stock)); Console.WriteLine(productos.Max(p => p.Precio)); Console.WriteLine(productos.Average(p => p.Stock)); Console.WriteLine(productos.Any(p => p.Categoria == "Audio")); Console.WriteLine(productos.First(p => p.Precio > 100).Nombre); Console.WriteLine(productos.FirstOrDefault(p => p.Precio > 1000)?.Nombre ?? "ninguno");
salida2 3565.80 899.00 4.166666666666667 False Monitor ninguno
First lanza una excepción si ningún elemento cumple la condición; FirstOrDefault devuelve null (o el valor por omisión del tipo), y por eso se combina tan bien con ?. y ?? de la lección 2. Lo mismo pasa con Single y SingleOrDefault, que además exigen que haya uno solo.
Agrupar con GroupBy
var porCategoria = productos
.GroupBy(p => p.Categoria)
.Select(g => new
{
Categoria = g.Key,
Cantidad = g.Count(),
Valor = g.Sum(p => p.Precio * p.Stock),
})
.OrderByDescending(x => x.Valor);
foreach (var g in porCategoria)
{
Console.WriteLine($"{g.Categoria,-12} cantidad: {g.Cantidad} valor: {g.Valor,8:F2} USD");
}salidaEquipos cantidad: 2 valor: 1798.00 USD Periféricos cantidad: 3 valor: 1011.80 USD Pantallas cantidad: 1 valor: 756.00 USD
GroupBy reparte los elementos en grupos según una clave; cada grupo tiene su Key y se puede recorrer o resumir como cualquier secuencia. El new { ... } sin nombre de clase crea un tipo anónimo: un objeto de usar y tirar con las propiedades que pongas, muy práctico dentro de una consulta. En la interpolación, ,-12 rellena hasta 12 caracteres alineando a la izquierda y ,8:F2 alinea a la derecha en 8 con dos decimales: así salen las columnas.
La otra sintaxis
LINQ también tiene una sintaxis parecida a SQL. El compilador la traduce a las mismas llamadas, así que es cuestión de gusto; la de métodos es la más usada y la única que tiene todos los operadores:
var caros = from p in productos
where p.Precio > 100
orderby p.Precio descending
select p.Nombre;
Console.WriteLine(string.Join(", ", caros));salidaPortátil, Tableta, Monitor
Si sabes SQL, el curso de SQL te resultará familiar: Where, GroupBy y OrderBy son las mismas ideas. Y para ordenar y buscar en listas grandes hay más trucos en el ejemplo ordenar y buscar en C#.
La ejecución diferida
Una consulta LINQ no se ejecuta cuando la escribes, sino cuando la recorres, igual que los iteradores con yield de la lección anterior. Y se vuelve a ejecutar cada vez que la recorres:
var numeros = new List<int> { 1, 2, 3 };
var dobles = numeros.Select(n =>
{
Console.WriteLine($" calculando {n}");
return n * 2;
});
numeros.Add(4); // la consulta aún no se ha ejecutado: verá el 4
Console.WriteLine("Primera vuelta:");
Console.WriteLine(string.Join(", ", dobles));
Console.WriteLine("Segunda vuelta:");
Console.WriteLine(dobles.Sum());
var fijos = dobles.ToList(); // se ejecuta una vez más y se guarda el resultado
numeros.Add(5);
Console.WriteLine($"fijos tiene {fijos.Count} elementos");salidaPrimera vuelta: calculando 1 calculando 2 calculando 3 calculando 4 2, 4, 6, 8 Segunda vuelta: calculando 1 calculando 2 calculando 3 calculando 4 20 calculando 1 calculando 2 calculando 3 calculando 4 fijos tiene 4 elementos
Tres lecciones en una salida. La consulta vio el 4, añadido después de escribirla. La lambda se ejecutó una vez por número en cada recorrido, tres veces en total contando el ToList(), lo que con un cálculo caro o una consulta a una base de datos sale muy caro. Y ToList() (o ToArray(), ToDictionary()…) ejecuta la consulta en ese momento y guarda el resultado, que ya no cambia. Regla práctica: si vas a recorrer una consulta más de una vez, guárdala con ToList(). Este comportamiento está entre las rarezas de C#.
async y await
Muchas operaciones pasan la mayor parte del tiempo esperando: la respuesta de un servidor, un archivo del disco, una consulta a la base de datos. Un método async puede ceder el control mientras espera, sin bloquear el hilo, y continuar donde lo dejó cuando llega el resultado. Se escribe así:
Console.WriteLine("Pidiendo el precio...");
decimal precio = await ObtenerPrecioAsync("teclado");
Console.WriteLine($"Precio: {precio} USD");
static async Task<decimal> ObtenerPrecioAsync(string producto)
{
await Task.Delay(200); // simula una petición por la red
return producto.Length * 10m;
}salidaPidiendo el precio... Precio: 70 USD
Las piezas: el método se marca con async y devuelve un Task<decimal>, una «promesa» de que habrá un decimal más adelante (o Task a secas si no devuelve nada). Dentro, await espera a otra tarea sin bloquear. Quien lo llama también usa await para obtener el valor. Por convención, estos métodos terminan en Async. Las instrucciones de nivel superior admiten await directamente; en un proyecto con Main, este tendría que ser static async Task Main.
El error que te ahorra: olvidar el await
int palabras = ContarPalabrasAsync("hola mundo");
Console.WriteLine(palabras);
static async Task<int> ContarPalabrasAsync(string texto)
{
await Task.Delay(10);
return texto.Split(' ').Length;
}dotnet buildsin_await.cs(1,16): error CS0029: No se puede convertir implícitamente el tipo 'System.Threading.Tasks.Task<int>' en 'int'
Un método asíncrono no devuelve el número, sino la tarea que lo producirá. Sin el await, el compilador ve que intentas guardar un Task<int> en un int y lo detiene. En JavaScript, el mismo olvido no da error: la variable se queda con una Promise y el programa escribe [object Promise] o, peor, sigue adelante con datos que aún no han llegado.
Varias tareas a la vez con Task.WhenAll
La gran ventaja aparece al esperar varias cosas. Con un await detrás de otro, cada espera empieza cuando acaba la anterior. Si lanzas las tareas primero y luego esperas a todas con Task.WhenAll, las esperas se solapan:
using System.Diagnostics;
var reloj = Stopwatch.StartNew();
int a = await DescargarAsync("a.txt");
int b = await DescargarAsync("b.txt");
int c = await DescargarAsync("c.txt");
Console.WriteLine($"Una tras otra: {a + b + c} bytes en unos {Redondear(reloj)} ms");
reloj.Restart();
int[] tamanos = await Task.WhenAll(DescargarAsync("a.txt"), DescargarAsync("b.txt"), DescargarAsync("c.txt"));
Console.WriteLine($"A la vez: {tamanos.Sum()} bytes en unos {Redondear(reloj)} ms");
Console.WriteLine(string.Join(", ", tamanos));
static async Task<int> DescargarAsync(string archivo)
{
await Task.Delay(300); // la «descarga»
return archivo.Length * 100;
}
static long Redondear(Stopwatch r) => r.ElapsedMilliseconds / 100 * 100;salidaUna tras otra: 1500 bytes en unos 900 ms A la vez: 1500 bytes en unos 300 ms 500, 500, 500
Task.WhenAll devuelve los resultados en el mismo orden en que le pasaste las tareas, aunque terminen en otro. Su pariente Task.WhenAny espera solo a la primera que acabe. Los tiempos del programa están truncados a centenas de milisegundos (Redondear) para que la salida sea estable de una ejecución a otra. En el ejemplo tareas en paralelo hay más: Parallel.ForEachAsync y cómo limitar cuántas tareas corren a la vez, y en peticiones HTTP, descargas de verdad con HttpClient.
Cancelar con CancellationToken
Una operación larga debería poder cancelarse: el usuario se cansa, se acaba el tiempo de espera. En .NET se hace pasando un CancellationToken, que casi todos los métodos asíncronos aceptan como último parámetro:
using var cancelacion = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
try
{
await TareaLargaAsync(cancelacion.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Cancelada: tardaba demasiado");
}
static async Task TareaLargaAsync(CancellationToken token)
{
for (int paso = 1; paso <= 5; paso++)
{
await Task.Delay(200, token);
Console.WriteLine($"Paso {paso} terminado");
}
}salidaPaso 1 terminado Cancelada: tardaba demasiado
El CancellationTokenSource se cancela solo a los 300 ms; también se puede cancelar a mano con Cancel(). Task.Delay recibe el token y, al cancelarse, lanza una OperationCanceledException que interrumpe el bucle en mitad del paso 2. El using libera el temporizador al terminar, como viste en la lección 4.
Secuencias asíncronas: await foreach
Si combinas un iterador con yield y async, obtienes un IAsyncEnumerable<T>: una secuencia cuyos elementos llegan poco a poco, como las lecturas de un sensor o las páginas de una API. Se recorre con await foreach:
await foreach (var temperatura in LeerSensorAsync(3))
{
Console.WriteLine($"Lectura: {temperatura} °C");
}
static async IAsyncEnumerable<double> LeerSensorAsync(int lecturas)
{
for (int i = 0; i < lecturas; i++)
{
await Task.Delay(100);
yield return 20.5 + i * 0.5;
}
}salidaLectura: 20.5 °C Lectura: 21 °C Lectura: 21.5 °C
async void. Un método asíncrono debe devolver Task o Task<T>. Con void, quien lo llama no puede esperarlo ni capturar sus excepciones, que acaban tumbando el programa. La única excepción son los manejadores de eventos de las interfaces gráficas. Está explicado en las rarezas.Resumen
| Para | Escribe | Recuerda |
|---|---|---|
| Filtrar | .Where(p => p.Stock > 0) | devuelve otra secuencia |
| Transformar | .Select(p => p.Nombre) | |
| Ordenar | .OrderBy(...), .ThenBy(...) | OrderByDescending al revés |
| Agrupar | .GroupBy(p => p.Categoria) | cada grupo tiene su Key |
| Un solo valor | Count, Sum, Max, Any, First | FirstOrDefault no lanza |
| Fijar el resultado | .ToList() | si no, se recalcula en cada recorrido |
| Un método asíncrono | async Task<T> NombreAsync() | nunca async void |
| Esperar el resultado | await NombreAsync() | sin await tienes un Task |
| Varias a la vez | await Task.WhenAll(t1, t2, t3) | resultados en el orden de las tareas |
| Poder cancelar | CancellationToken | OperationCanceledException |
| Una secuencia que llega poco a poco | IAsyncEnumerable<T> y await foreach |
Retos
Reto 1. Con la lista de productos, escribe los nombres de los periféricos que tienen stock, en mayúsculas y por orden alfabético, separados por comas.
Ver la solución
var nombres = productos
.Where(p => p.Categoria == "Periféricos" && p.Stock > 0)
.Select(p => p.Nombre.ToUpper())
.Order();
Console.WriteLine(string.Join(", ", nombres));salidaTECLADO, WEBCAM
Order(), sin lambda, ordena los propios elementos (existe desde .NET 7). Con OrderBy(n => n) el resultado es el mismo.
Reto 2. Calcula el precio medio de cada categoría y escríbelo con dos decimales, de la categoría más cara a la más barata.
Ver la solución
var medias = productos
.GroupBy(p => p.Categoria)
.Select(g => (Categoria: g.Key, Media: g.Average(p => p.Precio)))
.OrderByDescending(x => x.Media);
foreach (var (categoria, media) in medias)
{
Console.WriteLine($"{categoria}: {media:F2} USD");
}salidaEquipos: 614.00 USD Pantallas: 189.00 USD Periféricos: 42.93 USD
En lugar de un tipo anónimo, aquí la consulta devuelve una tupla con nombres, que se puede descomponer directamente en el foreach.
Reto 3. Tres servidores responden con retrasos de 300, 100 y 200 ms. Usa Task.WhenAny para quedarte con la respuesta del más rápido.
Ver la solución
Task<string> respuesta = await Task.WhenAny(
ResponderAsync("servidor A", 300),
ResponderAsync("servidor B", 100),
ResponderAsync("servidor C", 200));
Console.WriteLine($"Ganó {await respuesta}");
static async Task<string> ResponderAsync(string nombre, int retraso)
{
await Task.Delay(retraso);
return nombre;
}salidaGanó servidor B
Task.WhenAny devuelve la tarea que terminó primero, no su resultado: por eso hay un segundo await para sacarlo (que ya no espera nada, porque la tarea está terminada). Las otras dos siguen en marcha hasta acabar; para pararlas haría falta un CancellationToken.
Todo el código de esta lección se compiló con el .NET SDK 10.0.403 (C# 14) y se ejecutó con .NET 10.0.14 en un contenedor limpio antes de publicarla; los mensajes del compilador y las salidas están copiados de esa ejecución. ¿Algo no te funciona? Revisa que tengas .NET 10: dotnet --version.
