Lambdas y delegados
En resumen: Una lambda es una función corta sin nombre que pasas como valor; en C# su tipo es un delegado como Func, Action o Predicate.
Problema
Una clase entera para una comparación
Hay que ordenar los pedidos por total. Ya sabes cómo desde el módulo «Colecciones»: Sort con un objeto de una clase que implementa IComparer<Order>.
class Order{ public string Customer { get; } public double Total { get; }
public Order(string customer, double total) { Customer = customer; Total = total; }}class ByTotal : IComparer<Order>{ public int Compare(Order a, Order b) { return a.Total.CompareTo(b.Total); }}
var orders = new List<Order>{ new Order("María", 75.50), new Order("Jorge", 12.90), new Order("Elena", 40.00)};
orders.Sort(new ByTotal());
foreach (Order o in orders){ Console.WriteLine($"{o.Customer}: {o.Total:F2} €");}Jorge: 12.90 €Elena: 40.00 €María: 75.50 €Funciona. Pero para una comparación escribes una clase entera: class ByTotal : IComparer<Order>, public int Compare, llaves, y luego new ByTotal(). Solo una línea dice algo: a.Total.CompareTo(b.Total). Y la clase queda lejos del lugar donde la usas. Tú quieres pasar solo la regla.
La idea
Una lambda: solo la regla
El mismo orden, en una línea:
orders.Sort((a, b) => a.Total.CompareTo(b.Total));(a, b) => a.Total.CompareTo(b.Total) se llama expresión lambda, o lambda para abreviar. A la izquierda de => están los parámetros, a la derecha está el cuerpo. Es el método Compare sin nombre, sin tipos y sin return.
Una lambda se puede escribir de tres formas:
- Un solo parámetro puede ir sin paréntesis:
o => o.Total > 50. Sin parámetros es() => .... - Los tipos de los parámetros normalmente se omiten, porque el compilador los conoce por
Sort. También puedes escribirlos:(Order a, Order b) => .... - Un cuerpo de varias líneas va entre
{ }, y entoncesreturnes obligatorio.
Comparison<Order> byTotal = (a, b) => a.Total.CompareTo(b.Total);
Comparison<Order> byTotalLong = (Order a, Order b) =>{ double diff = a.Total - b.Total; return diff < 0 ? -1 : diff > 0 ? 1 : 0;};
orders.Sort(byTotalLong);foreach (Order o in orders){ Console.WriteLine($"{o.Customer}: {o.Total:F2} €");}Jorge: 12.90 €Elena: 40.00 €María: 75.50 €Comparison<Order>. Qué es eso lo verás en la siguiente tarjeta.La idea
Un delegado: el tipo de la regla
¿Cómo sabe C# que (a, b) => ... es una comparación de dos pedidos? Por el tipo: Sort tiene una versión que recibe un Comparison<Order>. Eso es un delegado: un tipo que describe un método, sus parámetros y su resultado. Comparison<Order> es «un método que recibe dos pedidos y devuelve un int». La lambda se convierte en el cuerpo de ese método.
Puedes hacer tu propio delegado. Se escribe como un método, con la palabra delegate y sin cuerpo:
delegate double PriceRule(double price);PriceRule student = price => price * 0.8;PriceRule minusFive = price => price - 5;
Console.WriteLine(student(50));Console.WriteLine(minusFive(50));4045student es un objeto de tipo PriceRule. Lo llamas como un método, con paréntesis: student(50). Dos reglas de precio, y ni una sola clase. C# imprime 40, no 40.0: el número es entero.PriceRule student = price => price * 0.8;PriceRule minusFive = price => price - 5;
Console.WriteLine(student(50));Console.WriteLine(minusFive(50));Paso a paso, en palabras
- La lambda es un objeto de tipo PriceRule, y student apunta a él. Su cuerpo todavía no se ejecuta.
- Una segunda regla, un segundo objeto. No se escribe ni una sola clase.
- student(50) pasa 50 a la lambda: price es 50, y price * 0.8 da 40.
- minusFive(50): el mismo número, otra regla, 45.
Más precisamente, para curiosos
- En Java una lambda va donde se espera una interfaz con un método (una interfaz funcional). En C# una lambda no puede ir a una interfaz, aunque tenga un único método:
interface IPriceRule{ double Apply(double price);}
IPriceRule student = price => price * 0.8;error CS1660: Cannot convert lambda expression to type 'IPriceRule' because it is not a delegate typePor eso en C# una regla que se pasa como lambda es un delegado, no una interfaz.
student(50)es la forma corta destudent.Invoke(50). YGetType()muestra que el objeto de verdad es del tipo del delegado:
PriceRule student = price => price * 0.8;Console.WriteLine(student.GetType().Name);Console.WriteLine(student.Invoke(50));PriceRule40La idea
Los incorporados: Predicate, Func, Action
No hace falta escribir un delegado para cada regla. System tiene delegados incorporados para los casos más comunes:
Predicate<T>: recibe unT, devuelve unbool. Es una condición: «¿el pedido es grande?».Func<T, TResult>: recibe unT, devuelve unTResult. El último tipo entre< >siempre es el resultado. Convierte una cosa en otra: un pedido en una etiqueta.Action<T>: recibe unT, no devuelve nada. Hace algo con él: lo imprime, lo guarda.Func<TResult>: no recibe nada, devuelve unTResult. Te da un objeto nuevo cuando se lo pides.
Todos se llaman igual, con paréntesis, como un método.
Predicate<Order> big = o => o.Total >= 50;Func<Order, string> label = o => o.Customer.ToUpper();Action<string> print = text => Console.WriteLine("-> " + text);Func<Order> guest = () => new Order("Invitado", 0);
var order = new Order("María", 75.50);Console.WriteLine(big(order));print(label(order));Console.WriteLine(guest().Customer);True-> MARÍAInvitadoEn Java las reglas son objetos con métodos que las combinan: las condiciones se unen con and, or y negate («no»), y las funciones se encadenan una tras otra con andThen. Los delegados de C# no tienen esos métodos. La nueva regla es una nueva lambda que llama a las anteriores: o => big(o) && maria(o). Dos funciones una tras otra también se combinan con una lambda, que devuelve un método:
static Func<double, double> Then(Func<double, double> f, Func<double, double> g){ return x => g(f(x));}
Func<double, double> withVat = price => price * 1.2;Func<double, double> rounded = price => Math.Round(price, 2);
Func<double, double> finalPrice = Then(withVat, rounded);Console.WriteLine(finalPrice(10.555));12.67Then recibe dos delegados y devuelve un tercero: llama al primero y pasa su resultado al segundo.
Más precisamente, para curiosos
- Hay versiones con más parámetros:
Func<T1, T2, TResult>recibe dos y devuelve un resultado,Action<T1, T2>recibe dos y no devuelve nada. Llegan hasta 16 parámetros. Predicate<Order>yFunc<Order, bool>describen lo mismo, pero son tipos distintos. Una variable de uno no se puede pasar donde se espera el otro.- Los delegados de acciones se pueden sumar con
+=. Entonces una sola llamada los llama a todos en orden:
Action<string> notify = text => Console.WriteLine("Correo: " + text);notify += text => Console.WriteLine("SMS: " + text);notify("tu pedido ya salió");Correo: tu pedido ya salióSMS: tu pedido ya salióPruébalo
Condiciones combinadas
¿Qué imprimirá el programa?
Predicate<Order> big = o => o.Total >= 50;Predicate<Order> maria = o => o.Customer == "María";Predicate<Order> bigAndMaria = o => big(o) && maria(o);Predicate<Order> bigOrMaria = o => big(o) || maria(o);Predicate<Order> notBig = o => !big(o);
var small = new Order("María", 30);var large = new Order("Jorge", 80);
Console.WriteLine(bigAndMaria(small));Console.WriteLine(bigOrMaria(small));Console.WriteLine(notBig(large));bigAndMaria da False, y bigOrMaria da True, porque es de María. El pedido de Jorge es de 80 €, es grande, y ! invierte la respuesta: False.Míralo
Las listas reciben lambdas
List<T> tiene métodos que reciben una regla en lugar de un bucle:var cart = new List<string> { "pan", "", "miel", " ", "azúcar" };int removed = cart.RemoveAll(item => string.IsNullOrWhiteSpace(item));cart.ForEach(item => Console.WriteLine(item));Console.WriteLine($"Eliminados: {removed}");
Console.WriteLine(cart.Find(item => item.StartsWith('m')));Console.WriteLine(cart.Exists(item => item.Length > 5));List<int> lengths = cart.ConvertAll(item => item.Length);Console.WriteLine(string.Join(", ", lengths));panmielazúcarEliminados: 2mielTrue3, 4, 6RemoveAll(condición)elimina todos los elementos para los que la condición es verdadera, y devuelve cuántos eliminó. La condición es unPredicate.ForEach(acción)hace la acción con cada elemento. Es unAction.Find(condición)devuelve el primer elemento para el que la condición es verdadera, onullsi no hay ninguno.Existsdice si hay al menos uno.ConvertAll(conversión)crea una lista nueva con los resultados. Aquí las palabras se convierten en una lista de sus longitudes.
Dictionary no tiene métodos así. El computeIfAbsent y el forEach de Java no están, y byCity.ForEach(...) no compila:
error CS1061: 'Dictionary<string, List<string>>' does not contain a definition for 'ForEach' and no accessible extension method 'ForEach' accepting a first argument of type 'Dictionary<string, List<string>>' could be found (are you missing a using directive or an assembly reference?)Un diccionario se recorre con foreach, y agrupar en una línea viene en la próxima lección.
La idea
Un método por su nombre, sin paréntesis
s => Console.WriteLine(s). Entonces pasa el propio método, por su nombre y sin paréntesis:var names = new List<string> { "maría", "jorge" };names.ForEach(Console.WriteLine);
Func<string, int> parse = int.Parse;var vips = new List<string> { "maría" };Predicate<string> isVip = vips.Contains;Func<string, string> upper = s => s.ToUpper();Func<List<string>> newList = () => new List<string>();
Console.WriteLine(upper("elena"));Console.WriteLine(parse("42") + 1);Console.WriteLine(isVip("jorge"));List<string> list = newList();list.Add("café");Console.WriteLine(string.Join(", ", list));maríajorgeELENA43FalsecaféEl nombre de un método sin paréntesis se llama grupo de métodos (method group): el grupo son todos los métodos con ese nombre. El compilador elige el que encaja con el delegado. Console.WriteLine tiene muchas versiones, y ForEach quiere un Action<string>, así que toma WriteLine(string). Java tiene una notación aparte para esto, la referencia a método con ::. Hay cuatro tipos, pero en C# solo dos funcionan por nombre:
- un método de un objeto concreto:
vips.Containsesname => vips.Contains(name); - un método estático:
int.Parseess => int.Parse(s), yConsole.WriteLineess => Console.WriteLine(s); - un método del parámetro: no hay forma corta. El
String::toUpperCasede Java en C# es una lambda,s => s.ToUpper(); - un constructor: tampoco hay forma corta.
ArrayList::newen C# es() => new List<string>().
Si la lambda hace algo más, por ejemplo s => s.Trim().ToUpper(), déjala como lambda.
Míralo
Ordenar por varios campos
El delegado Comparison<T> no tiene métodos como comparing y thenComparing de Java. La regla para varios campos es una sola lambda: si la comparación por el primer campo no es 0, decide ella; si no, compara el siguiente.
Los estudiantes se ordenan por nota, de la más alta a la más baja, y por nombre cuando las notas son iguales:
var students = new List<Student>{ new Student("Juan", 5.50), new Student("Ana", 6.00), new Student("Pedro", 5.50), new Student("Bruno", 4.75)};students.Sort((a, b) =>{ int byGrade = b.Grade.CompareTo(a.Grade); return byGrade != 0 ? byGrade : string.Compare(a.Name, b.Name);});Ana 6Juan 5.5Pedro 5.5Bruno 4.75b.Grade.CompareTo(a.Grade) pone primero la nota más alta. Cuando las notas son iguales, byGrade es 0 y string.Compare(a.Name, b.Name) compara los nombres alfabéticamente. Se lee de arriba abajo: «por nota al revés, y si son iguales, por nombre».
Más precisamente, para curiosos
- Sin un tipo a la izquierda, el compilador no sabe qué delegado es la lambda, porque no sabe qué son
ayb:
var byTotal = (a, b) => a.Total.CompareTo(b.Total);error CS8917: The delegate type could not be inferred.La solución es el tipo del delegado a la izquierda:
Comparison<Order> byTotal = (a, b) => b.Total.CompareTo(a.Total);orders.Sort(byTotal);- Con los tipos de los parámetros,
varcompila, pero daFunc<Order, Order, int>. Describe lo mismo queComparison<Order>, pero es otro tipo, ySortno lo acepta:
var byTotal = (Order a, Order b) => a.Total.CompareTo(b.Total);orders.Sort(byTotal);error CS1503: Argument 1: cannot convert from 'System.Func<Order, Order, int>' to 'System.Collections.Generic.IComparer<Order>?'El compilador prueba la versión de Sort con IComparer, por eso aparece en el mensaje.
- Un orden que se lee como
comparingythenComparingde Java está en LINQ de C#:OrderByDescending(s => s.Grade).ThenBy(s => s.Name). Lo verás en la próxima lección.
Pruébalo
¿Qué invierte intercambiar a y b?
byGradeThenName compara por nota y luego por nombre, y Sort recibe una lambda que la llama con a y b intercambiados.¿Qué imprimirá el programa?
var students = new List<Student>{ new Student("Juan", 5.50), new Student("Ana", 6.00), new Student("Pedro", 5.50), new Student("Bruno", 4.75)};
Comparison<Student> byGradeThenName = (a, b) =>{ int byGrade = a.Grade.CompareTo(b.Grade); return byGrade != 0 ? byGrade : string.Compare(a.Name, b.Name);};
students.Sort((a, b) => byGradeThenName(b, a));students.ForEach(s => Console.WriteLine($"{s.Name} {s.Grade}"));a y b invierte toda la comparación, y esta es «por nota, luego por nombre». Así que se invierten las dos: las notas van de la más alta a la más baja, y Pedro va antes que Juan. Si quieres invertir solo las notas, intercambias a y b solo en la comparación de las notas, como en la tarjeta anterior.Pruébalo
Encuentra el error
1. 12.00 € y así sucesivamente. Se detiene con un error en el primer precio. ¿En qué línea está el error?Elige la línea con el error.
La lambda lee i, pero no en el momento en que se escribe:
ArgumentOutOfRangeException: Index was out of range. Must be non-negative and less than the size of the collection. (Parameter 'index')La lambda ve la propia variable i, no una copia de su valor. Una lambda así se llama clausura (closure): «encierra» las variables de fuera que usa. El bucle for tiene una única i para todas las vueltas. Las lambdas se llaman solo después del bucle, y para entonces i ya vale 3. prices[3] no existe, y el programa se detiene.
La solución es una copia en el cuerpo del bucle: int n = i; y n en lugar de i en la lambda. Cada vuelta tiene su propio n, así que cada lambda recuerda el suyo:
var prices = new List<double> { 12, 75, 40 };var lines = new List<Action>();for (int i = 0; i < prices.Count; i++){ int n = i; lines.Add(() => Console.WriteLine($"{n + 1}. {prices[n]:F2} €"));}lines.ForEach(line => line());1. 12.00 €2. 75.00 €3. 40.00 €foreach no tiene este problema: en él cada vuelta tiene su propia variable.
Más precisamente, para curiosos
Aquí C# es distinto de Java. Allí una lambda recibe una copia del valor y no puede cambiar la variable local en absoluto. En C# la lambda también puede cambiarla: la suma de Java aquí compila e imprime 127:
var prices = new List<double> { 12, 75, 40 };double total = 0;prices.ForEach(p => total += p);Console.WriteLine(total);127Y como ve la propia variable, la lambda también ve un cambio hecho después de ella:
double rate = 0.2;Func<double, double> discount = price => price * (1 - rate);Console.WriteLine(discount(100));
rate = 0.5;Console.WriteLine(discount(100));8050Si la regla tiene que recordar un valor, pásalo como parámetro. Para una suma, eso sí, hay una forma más clara, Sum, en la próxima lección.
Ejercicio
Reglas como valores
1. Ordenar por varios campos. Una clase Product con nombre, categoría y precio. Ordena los productos por categoría, dentro de una categoría del más caro al más barato, y por nombre cuando el precio es igual, con un solo Sort y una lambda. Los productos son: ratón, accesorios, 25 €; portátil, computadoras, 1299 €; teclado, accesorios, 49,90 €; tableta, computadoras, 399 €; auriculares, accesorios, 49,90 €.
Accesorios | Auriculares | 49.90 €Accesorios | Teclado | 49.90 €Accesorios | Ratón | 25.00 €Computadoras | Portátil | 1299.00 €Computadoras | Tableta | 399.00 €2. Filtrar pedidos. Añade una ciudad a Order. Escribe un método Select(List<Order> orders, Predicate<Order> rule) que devuelva una lista nueva solo con los pedidos para los que la regla es verdadera. Haz dos reglas, madrid y big (al menos 50 €), e imprime los pedidos para los que se cumplen las dos, los de fuera de Madrid y los «de Madrid o de al menos 100 €». Cada una de las tres reglas es una lambda que llama a madrid y big. Los pedidos: María, Madrid, 75,50 €; Jorge, Valencia, 12,90 €; Elena, Madrid, 40 €; Diego, Granada, 120 €.
Madrid, desde 50 €: María, Madrid: 75.50 €Fuera de Madrid: Jorge, Valencia: 12.90 € Diego, Granada: 120.00 €Envío gratis (Madrid o desde 100 €): María, Madrid: 75.50 € Elena, Madrid: 40.00 € Diego, Granada: 120.00 €Func<double, double>: un descuento de estudiante (−10 %), un descuento de Black Friday (−20 €) y «no menos de cero». Encadénalos con el método Then(f, g) de la lección en dos órdenes: primero el de estudiante y primero el del viernes. Pruébalo con 120 € y con 15 €. ¿Importa el orden?120.00 € -> estudiante y luego viernes: 88.00 €; viernes y luego estudiante: 90.00 €15.00 € -> estudiante y luego viernes: 0.00 €; viernes y luego estudiante: 0.00 €Más tareas
- Limpiar una lista. De una lista de correos, elimina los vacíos y los que no tienen
@con un soloRemoveAll, y luego imprime el resto conForEach(Console.WriteLine). - Estudiantes por clase. Una lista de estudiantes y sus clases (
"10A","10B"). Para cada clase, imprime los nombres conFindAlly una lambda que compara la clase, yConvertAllque toma el nombre. - Tu propio delegado. Haz
delegate bool Validator(string value);y tres validadores como lambdas: texto no vacío, solo dígitos (char.IsDigitpara cada carácter), longitud de hasta 10. Pruébalos con algunas cadenas.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Excepciones · Listas · Dictionary y HashSet · Clases anidadas
- Continúa en: LINQ · record, switch y patrones · Patrones de diseño · Tests con xUnit
- Ejercicios sobre el tema: C# moderno