C# 13 con .NET 9 llegó con mejoras que parecen pequeñas pero cambian patrones que usamos hace años. Los params con Span eliminan allocations que ni sabíamos que estábamos haciendo. El Lock de primera clase hace que el threading sea más seguro. Y Task.WhenEach resuelve un patrón asíncrono que antes requería código contorsionado.
params ReadOnlySpan<T>: cero allocations en métodos variádicos
Antes de C# 13, params solo funcionaba con arrays. Cada vez que llamabas a un método con params int[], el compilador creaba un array en el heap aunque los datos pudieran vivir perfectamente en el stack. C# 13 cambia eso.
// ❌ C# 12 y anteriores: params siempre crea un array en el heap
public static int Sumar(params int[] numeros)
{
int total = 0;
foreach (var n in numeros) total += n;
return total;
}
// Cada llamada alloca un int[] en el heap:
int r1 = Sumar(1, 2, 3); // new int[] { 1, 2, 3 }
int r2 = Sumar(10, 20); // new int[] { 10, 20 }
// ✅ C# 13: params con ReadOnlySpan — cero allocations
public static int Sumar(params ReadOnlySpan<int> numeros)
{
int total = 0;
foreach (var n in numeros) total += n;
return total;
}
// El compilador puede poner los valores directamente en el stack
int r1 = Sumar(1, 2, 3); // Sin heap allocation
int r2 = Sumar(10, 20); // Sin heap allocation
// También funciona con otros tipos de colección
public static void LogTodos(params IEnumerable<string> mensajes) { ... }
public static T Primero<T>(params ReadOnlySpan<T> items) => items[0];
System.Threading.Lock: el tipo de primera clase para sincronización
Durante años, el patrón estándar para sincronizar hilos en C# era lock (new object()). Funcionaba, pero tenía un problema: object no comunica la intención. Cualquier referencia podía usarse accidentalmente como lock. System.Threading.Lock resuelve eso con un tipo dedicado que el compilador trata de forma especial.
// ❌ Antes: object genérico como lock
public class Cache
{
private readonly object _lock = new object();
private Dictionary<string, object> _datos = new();
public void Agregar(string clave, object valor)
{
lock (_lock)
{
_datos[clave] = valor;
}
}
public bool TryGet(string clave, out object? valor)
{
lock (_lock)
{
return _datos.TryGetValue(clave, out valor);
}
}
}
// ✅ C# 13 / .NET 9: System.Threading.Lock
public class Cache
{
// Semánticamente claro: esto ES un lock, no cualquier objeto
private readonly Lock _lock = new();
private Dictionary<string, object> _datos = new();
public void Agregar(string clave, object valor)
{
// El compilador emite código optimizado para Lock
// Usa el patrón Dispose internamente (EnterScope)
lock (_lock)
{
_datos[clave] = valor;
}
}
// También disponible: API explícita para casos avanzados
public bool TryAgregar(string clave, object valor)
{
using (_lock.EnterScope())
{
if (_datos.ContainsKey(clave)) return false;
_datos[clave] = valor;
return true;
}
}
}
El compilador emite una advertencia si intentás pasar un Lock como object a otro método — protección contra el antipatrón de usar el mismo lock para múltiples propósitos.
Task.WhenEach: procesar tareas a medida que terminan
Tenía un patrón que repetía en muchos proyectos: lanzar varias tareas en paralelo y procesar cada resultado apenas llegaba, sin esperar a que todas terminen. Antes requería Task.WhenAny en un loop con manejo manual. Task.WhenEach lo vuelve trivial.
// ❌ Antes: patrón verbose con Task.WhenAny
var tareas = new List<Task<Reporte>> {
ObtenerReporteAsync("ventas"),
ObtenerReporteAsync("stock"),
ObtenerReporteAsync("clientes")
};
var pendientes = new HashSet<Task<Reporte>>(tareas);
while (pendientes.Count > 0)
{
var completada = await Task.WhenAny(pendientes);
pendientes.Remove(completada);
var reporte = await completada;
Console.WriteLine($"Reporte listo: {reporte.Nombre}");
}
// ✅ C# 13 / .NET 9: Task.WhenEach — elegante y eficiente
var tareas = new List<Task<Reporte>> {
ObtenerReporteAsync("ventas"),
ObtenerReporteAsync("stock"),
ObtenerReporteAsync("clientes")
};
await foreach (var completada in Task.WhenEach(tareas))
{
var reporte = await completada;
Console.WriteLine($"Reporte listo: {reporte.Nombre}");
// Procesa cada reporte apenas llega, sin bloquear a los demás
}
LINQ: CountBy, AggregateBy e Index
var pedidos = new[] {
new { Cliente = "Ana", Monto = 100m },
new { Cliente = "Juan", Monto = 200m },
new { Cliente = "Ana", Monto = 150m },
new { Cliente = "Juan", Monto = 300m },
};
// CountBy: contar elementos agrupados por clave
var pedidosPorCliente = pedidos.CountBy(p => p.Cliente);
// { "Ana": 2, "Juan": 2 }
// AggregateBy: acumular valores agrupados por clave
var totalPorCliente = pedidos.AggregateBy(
p => p.Cliente,
seed: 0m,
(acum, p) => acum + p.Monto
);
// { "Ana": 250, "Juan": 500 }
// Index(): obtener índice sin usar Select((x, i) => ...)
foreach (var (indice, pedido) in pedidos.Index())
{
Console.WriteLine($"Pedido #{indice}: {pedido.Cliente} - ${pedido.Monto}");
}
Nuevos miembros partial (C# 13)
// C# 13: propiedades y constructores pueden ser partial
// Muy útil con source generators (EF Core, Roslyn generators)
public partial class Entidad
{
// Declaración en el archivo principal
public partial string Nombre { get; set; }
public partial void OnNombreCambiado();
}
public partial class Entidad
{
// Implementación en el archivo generado (o viceversa)
public partial string Nombre
{
get => field;
set { field = value; OnNombreCambiado(); }
}
public partial void OnNombreCambiado()
=> Console.WriteLine($"Nombre cambió a: {Nombre}");
}
← C# 12: Primary Constructors, Collection Expressions y el código que debería haber existido siempre | Serie .NET 8 → .NET 10 | Próximo: La palabra clave field en C# 14: adiós para siempre a los backing fields repetitivos →
Deja una respuesta