Ir al contenido
wannacode

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

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} €");
}
Salida
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 entonces return es 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} €");
}
Salida
Jorge: 12.90 €
Elena: 40.00 €
María: 75.50 €
Una lambda es un valor: puedes guardarla en una variable y pasarla más tarde. El tipo de la variable aquí es 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));
Salida
40
45
student 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
  1. La lambda es un objeto de tipo PriceRule, y student apunta a él. Su cuerpo todavía no se ejecuta.
  2. Una segunda regla, un segundo objeto. No se escribe ni una sola clase.
  3. student(50) pasa 50 a la lambda: price es 50, y price * 0.8 da 40.
  4. 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 de compilación
error CS1660: Cannot convert lambda expression to type 'IPriceRule' because it is not a delegate type

Por eso en C# una regla que se pasa como lambda es un delegado, no una interfaz.

  • student(50) es la forma corta de student.Invoke(50). Y GetType() 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));
Salida
PriceRule
40

La 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 un T, devuelve un bool. Es una condición: «¿el pedido es grande?».
  • Func<T, TResult>: recibe un T, devuelve un TResult. El último tipo entre < > siempre es el resultado. Convierte una cosa en otra: un pedido en una etiqueta.
  • Action<T>: recibe un T, no devuelve nada. Hace algo con él: lo imprime, lo guarda.
  • Func<TResult>: no recibe nada, devuelve un TResult. 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);
Salida
True
-> MARÍA
Invitado

En 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));
Salida
12.67

Then 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> y Func<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ó");
Salida
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));
Respuesta

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));
Salida
pan
miel
azúcar
Eliminados: 2
miel
True
3, 4, 6
  • RemoveAll(condición) elimina todos los elementos para los que la condición es verdadera, y devuelve cuántos eliminó. La condición es un Predicate.
  • ForEach(acción) hace la acción con cada elemento. Es un Action.
  • Find(condición) devuelve el primer elemento para el que la condición es verdadera, o null si no hay ninguno. Exists dice 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 de compilación
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

A menudo una lambda solo pasa su parámetro a un método que ya existe: 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));
Salida
maría
jorge
ELENA
43
False
café

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.Contains es name => vips.Contains(name);
  • un método estático: int.Parse es s => int.Parse(s), y Console.WriteLine es s => Console.WriteLine(s);
  • un método del parámetro: no hay forma corta. El String::toUpperCase de Java en C# es una lambda, s => s.ToUpper();
  • un constructor: tampoco hay forma corta. ArrayList::new en 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);
});
Salida
Ana 6
Juan 5.5
Pedro 5.5
Bruno 4.75

b.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 a y b:
var byTotal = (a, b) => a.Total.CompareTo(b.Total);
Error de compilación
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, var compila, pero da Func<Order, Order, int>. Describe lo mismo que Comparison<Order>, pero es otro tipo, y Sort no lo acepta:
var byTotal = (Order a, Order b) => a.Total.CompareTo(b.Total);
orders.Sort(byTotal);
Error de compilación
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 comparing y thenComparing de 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?

Aquí 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}"));
Respuesta

Pruébalo

Encuentra el error

El programa debería imprimir los precios con su número: 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.

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);
Salida
127

Y 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));
Salida
80
50

Si 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

Resuelve las tareas en tu editor.

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

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

Salida
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 €
3. Descuentos uno tras otro. Haz tres descuentos como 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?
Salida
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 solo RemoveAll, y luego imprime el resto con ForEach(Console.WriteLine).
  • Estudiantes por clase. Una lista de estudiantes y sus clases ("10A", "10B"). Para cada clase, imprime los nombres con FindAll y una lambda que compara la clase, y ConvertAll que 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.IsDigit para cada carácter), longitud de hasta 10. Pruébalos con algunas cadenas.

Test

Ponte a prueba

1. ¿Dónde puede ir una expresión lambda?
2. ¿Qué delegado incorporado recibe un pedido y devuelve true o false?
3. ¿Qué puede reemplazar a la lambda s => int.Parse(s)?
4. byGradeThenName compara por nota y luego por nombre. ¿Qué invierte students.Sort((a, b) => byGradeThenName(b, a))?
5. En un bucle for (int i = 0; i < 3; i++) cada lambda imprime i. ¿Qué imprimen las tres cuando se llaman después del bucle?

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