CodeGym /Cursos /C# SELF /Expresiones lambda y delegados para comparar

Expresiones lambda y delegados para comparar

C# SELF
Nivel 30 , Lección 2
Disponible

1. Introducción

Imagina que tienes una lista de estudiantes y necesitas ordenarlos una vez por edad, luego por apellido, después por nota media, pero solo para los que aprobaron todos los exámenes. Si para cada una de estas comparaciones "de una sola vez" escribes una clase nueva que implemente IComparer<T>, tu proyecto se va a convertir rápidamente en un vertedero de pequeñas clases comparadoras. Es incómodo: el código se vuelve voluminoso y difícil de leer.

Para estas situaciones, C# ofrece una solución más elegante: la posibilidad de pasar la lógica de comparación directamente al método Sort sin crear una clase aparte.

Para esto vamos a necesitar delegados y sus hermanos compactos – expresiones lambda.

Delegados – nuestros ayudantes flexibles

Antes de pasar a las lambdas, vamos a aclarar qué es un delegado. En palabras simples, un delegado es un tipo que representa una referencia a un método. Suena un poco metafísico, ¿no? Piensa en ello así:

Imagina que tienes una lista de tareas, y algunas de esas tareas son "instrucciones" o "recetas". Un delegado es como una variable especial que puede guardar una referencia a esa "receta" (método). Y luego, cuando necesitas ejecutar esa tarea, simplemente llamas a la variable-delegado y ella "invoca" el método al que apunta.

En C#, los delegados se usan para crear callbacks (llamar a un método más tarde, normalmente como reacción a un evento), manejar eventos (reaccionar a acciones, como pulsar un botón) y, por supuesto, para pasar métodos como argumentos a otros métodos (para que un método pueda llamar a otro método), que es justo lo que necesitamos ahora para ordenar.

El método List<T>.Sort() tiene varias sobrecargas (versiones), y una de ellas acepta un delegado especial llamado Comparison<T>.

2. Delegado Comparison<T>

¿Qué es Comparison<T>?

Comparison<T> es un delegado incorporado en .NET, diseñado específicamente para comparar dos objetos del mismo tipo T. Su "receta" es así: recibe dos objetos de tipo T (llamémoslos x y y) y devuelve un número entero (int):

  • Número negativo (por ejemplo, -1), si x es "menor" que y.
  • Cero (0), si x es "igual" a y.
  • Número positivo (por ejemplo, 1), si x es "mayor" que y.

Estas son justo las reglas que usan IComparable.CompareTo y IComparer.Compare. O sea, la lógica es la misma, solo que ahora podemos pasarla como una "variable-método" y no como una clase aparte.

Vamos a verlo con un ejemplo. Volvamos a nuestros estudiantes. Supón que tienes una clase Student:

public class Student
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public int Age { get; set; }
    public double AverageGrade { get; set; }

    public Student(string firstName, string lastName, int age, double averageGrade)
    {
        FirstName = firstName;
        LastName = lastName;
        Age = age;
        AverageGrade = averageGrade;
    }

    public void PrintInfo()
    {
        Console.WriteLine($"Estudiante: {FirstName} {LastName}, Edad: {Age}, Nota: {AverageGrade:F2}");
    }
}

Ahora, para ordenar la lista de estudiantes por edad usando un delegado, podemos escribir un método estático aparte que cumpla la firma de Comparison<Student>:

public class Program
{
    // Método que cumple la firma del delegado Comparison<Student>
    // Va a comparar dos estudiantes por su edad
    public static int CompareStudentsByAge(Student student1, Student student2)
    {
        // Usamos el método incorporado CompareTo para números,
        // que devuelve -1, 0 o 1 según la comparación.
        return student1.Age.CompareTo(student2.Age);
    }

    public static void Main(string[] args)
    {
        List<Student> students = new List<Student>
        {
            new Student("Iván", "Petrov", 20, 4.5),
            new Student("María", "Sidorova", 22, 4.8),
            new Student("Alexéi", "Ivanov", 19, 3.9),
            new Student("Elena", "Kozlova", 20, 4.2) // Dos estudiantes con la misma edad
        };

        Console.WriteLine("--- Lista de estudiantes antes de ordenar ---");        
        foreach (var s in students)
            s.PrintInfo();

        Console.WriteLine("--- Ordenando estudiantes por edad (usando delegado) ---");
        students.Sort(CompareStudentsByAge); //pasamos el método CompareStudentsByAge como parámetro

        foreach (var s in students)
            s.PrintInfo();
    }
}

Analizando el código:

  1. Creamos un método estático CompareStudentsByAge que recibe dos estudiantes y devuelve un int, siguiendo el contrato de Comparison<Student>.
  2. En Main creamos la lista de estudiantes.
  3. Cuando llamamos a students.Sort(CompareStudentsByAge);, ¡no estamos llamando al método CompareStudentsByAge() de inmediato! Solo pasamos una referencia a ese método. List<T>.Sort() luego llamará a nuestro método CompareStudentsByAge tantas veces como necesite para ordenar, pasándole diferentes pares de estudiantes. Es como si dieras a alguien una dirección de entrega, en vez de mandar todo el camión de golpe.

Este enfoque es mucho más cómodo que crear una clase comparadora aparte para cada ordenación pequeña. ¡Pero podemos ir aún más lejos!

3. ¡Llegan las Expresiones Lambda!

Incluso tener que escribir un método aparte como CompareStudentsByAge puede parecer demasiado si la lógica de comparación es simple y solo la necesitas una o dos veces. Para estas situaciones, en C# se introdujeron las expresiones lambda (lambda expressions).

¿Qué es una expresión lambda? Básicamente, es un método anónimo o, como me gusta bromear, un "método sin hogar". Es una forma de escribir un trocito de código (método) justo donde lo necesitas, sin declararlo aparte. Es como escribir una instrucción rápida en un post-it y pegarla justo en la tarea, en vez de escribir todo un manual.

El operador principal de las expresiones lambda es => (se lee como "flecha" o "va a"). Separa los parámetros de entrada del cuerpo del método.

Sintaxis básica de una expresión lambda

Supón que tienes un delegado (referencia a un método) y lo pasas a la función Sort():

public static int CompareStudentsByAge(Student student1, Student student2)
{
   return student1.Age.CompareTo(student2.Age);
}

students.Sort(CompareStudentsByAge); //pasamos el método CompareStudentsByAge como parámetro

Hay una forma de escribirlo más corto:

//pasamos un método anónimo como parámetro
students.Sort( (Student student1, Student student2) => student1.Age.CompareTo(student2.Age) );

Aquí, en vez del nombre del método, ponemos sus dos cosas más importantes:

  • parámetros: (Student student1, Student student2)
  • contenido del método: student1.Age.CompareTo(student2.Age)

Esta forma compacta de escribir el método se llama expresión lambda: (parámetros) => expresión

Cómo funciona

El compilador de C# cuando encuentra una expresión lambda en el código, genera un método real a partir de ella.

Supón que tienes este código:

students.Sort( (s1, s2) => s2.AverageGrade.CompareTo(s1.AverageGrade) );

El resultado de la compilación será algo así:

public static int CompareStudents_Lambda123(Student s1, Student s2)
{
   return s2.AverageGrade.CompareTo(s1.AverageGrade);
}

students.Sort( CompareStudents_Lambda123 );

4. Ejemplo de ordenación y expresiones lambda

Vamos a reescribir nuestro ejemplo de estudiantes usando una expresión lambda:

public class Program
{
    public static void Main(string[] args)
    {
        List<Student> students = new List<Student>
        {
            new Student("Iván", "Petrov", 20, 4.5),
            new Student("María", "Sidorova", 22, 4.8),
            new Student("Alexéi", "Ivanov", 19, 3.9),
            new Student("Elena", "Kozlova", 20, 4.2)
        };

        Console.WriteLine("--- Lista de estudiantes antes de ordenar ---");        
        foreach (var s in students)
            s.PrintInfo();

        // Ahora la lógica de comparación está aquí mismo, "en el sitio"
        Console.WriteLine("--- Ordenando estudiantes por edad (usando expresión lambda) ---");
        students.Sort((student1, student2) => student1.Age.CompareTo(student2.Age));

        foreach (var s in students)
            s.PrintInfo();

        // Para ordenar de mayor a menor, simplemente multiplicamos el resultado por -1
        Console.WriteLine("\n--- Ordenando estudiantes por nota media (de mayor a menor) ---");
        // s2.CompareTo(s1) en vez de s1.CompareTo(s2)
        students.Sort((s1, s2) => s2.AverageGrade.CompareTo(s1.AverageGrade));

        foreach (var s in students)
            s.PrintInfo();
    }
}

¿Qué ha pasado aquí?

  1. students.Sort((student1, student2) => student1.Age.CompareTo(student2.Age));
    • student1 y student2 son los parámetros que Sort va a pasar a nuestro método anónimo (igual que x y y en Comparison<T>).
    • => es el operador lambda.
    • student1.Age.CompareTo(student2.Age) es el cuerpo de la expresión lambda. En este caso, es solo una expresión, cuyo resultado es el valor devuelto.
  2. Para ordenar por nota media de mayor a menor simplemente cambiamos el orden de s1 y s2 en CompareTo. Es el truco clásico para invertir el orden de la ordenación.

¿Por qué es cómodo?

  • Compacidad: No necesitas crear métodos o clases aparte para cada lógica de comparación pequeña.
  • Legibilidad: La lógica de comparación está justo al lado de la llamada a Sort(), lo que mejora la comprensión del código, sobre todo en casos simples.
  • Flexibilidad: Puedes cambiar fácilmente las condiciones de ordenación "al vuelo".

5. Delegados y Lambdas – la pareja perfecta

Puede que te preguntes: ¿entonces una expresión lambda es lo mismo que un delegado, o es otra cosa?

En realidad, una expresión lambda es solo azúcar sintáctico (syntax sugar) para crear una instancia de un delegado o un árbol de expresiones (Expression Tree, de eso hablaremos más adelante). Cuando el compilador ve una expresión lambda, él mismo, "bajo el capó", la convierte en una instancia del delegado adecuado. En nuestro caso, como List<T>.Sort() espera un delegado Comparison<T>, el compilador entiende que (student1, student2) => student1.Age.CompareTo(student2.Age) hay que convertirlo justo en un Comparison<Student>.

Así, las expresiones lambda nos permiten escribir código muy conciso, y los delegados son los "contenedores" que llevan ese código y permiten ejecutarlo. ¡Trabajan mano a mano!

¿Cuándo usar cada uno?

  • IComparable<T>: Úsalo cuando tu tipo tiene una forma de ordenación natural, obvia. Por ejemplo, si ordenas productos y la forma principal de ordenarlos es por su código. Esta interfaz define el orden "por defecto".
  • IComparer<T>: Úsalo cuando necesitas una lógica de comparación reutilizable, múltiple, pero no quieres "ensuciar" la clase principal o cuando tienes varias formas distintas de ordenar. Por ejemplo, un IComparer para ordenar productos por precio, otro por nombre, y los usas en distintas partes del programa.
  • Delegados (Comparison<T>) y Expresiones Lambda: Son ideales para ordenaciones de una sola vez, ad-hoc, cuando la lógica de comparación es simple y no necesita una clase reutilizable. Es la forma más común y limpia para la mayoría de tareas de ordenación en C#. También es una forma genial de pasar lógica a otros métodos, como los de filtrado (Find, FindAll) o búsqueda (FindIndex), que vimos antes.
Característica IComparable<T> IComparer<T> Comparison<T> / Expresión lambda
¿Dónde está definido? En la propia clase T En una clase comparadora aparte Puedes ser un método o una expresión anónima
Flexibilidad Orden "natural" fijo Órdenes múltiples y reutilizables Ad-hoc (al vuelo), para una llamada concreta
Burocracia Poca, dentro de la clase Media (clase aparte) Mínima (sobre todo con lambdas)
Ejemplo de uso
someList.Sort()
someList.Sort(new MyComparer())
someList.Sort((x, y) => x.Prop.CompareTo(y.Prop))
Facilidad de lectura Bien para orden natural Depende del nombre del comparador Genial para comparaciones simples y específicas

6. Aplicación práctica y mirada al futuro

Las expresiones lambda no son solo "azúcar sintáctico" para ordenar. Son una herramienta potente que se usa por todas partes en el código moderno de C#. Las vas a ver muy a menudo:

  • En LINQ (Language Integrated Query): Probablemente el uso más masivo de las expresiones lambda. LINQ permite escribir consultas tipo SQL sobre colecciones, y las lambdas se usan para definir condiciones de filtrado, ordenación, proyección de datos. Pronto estudiaremos LINQ y verás cómo las lambdas lo hacen increíblemente potente y cómodo.
  • En el manejo de eventos: Las lambdas permiten describir de forma concisa qué debe pasar ante un evento (por ejemplo, al pulsar un botón en la interfaz de usuario).
  • En programación asíncrona: Para definir tareas que deben ejecutarse en paralelo.
  • En varias APIs de .NET: Muchos métodos en la biblioteca estándar de .NET aceptan delegados (y, por tanto, expresiones lambda) como parámetros para añadir lógica flexible.

Así que, si dominas las expresiones lambda, no solo mejorarás tus habilidades de ordenación, sino que darás un gran paso para entender el código y las librerías modernas de C#. ¡Es una habilidad que se valora en cualquier entrevista y te servirá en todos tus proyectos!

7. Errores típicos y matices

Cuando trabajas con delegados y expresiones lambda para comparar, hay algunos detalles a tener en cuenta:

Resultado de comparación incorrecto: Recuerda que CompareTo o tu lógica de comparación deben devolver un número negativo, cero o positivo. Si por error devuelves otra cosa, la ordenación puede funcionar mal o incluso dar errores. El fallo más común es que los principiantes devuelvan true o false en vez de un int. El método Sort espera un resultado numérico, porque necesita saber no solo si los elementos son iguales, sino cuál es "mayor".

Manejo de valores null: Si los elementos de tu colección pueden ser null, intentar llamar a un método sobre un objeto null (por ejemplo, student1.Age.CompareTo(...) si student1 es null) dará un NullReferenceException. En estos casos, tu lógica de comparación debe manejar explícitamente los valores null. Por regla general, null se considera "menor" que cualquier valor no nulo. Si ambos son null, son iguales. Si uno es null y el otro no, null es "menor".

// Ejemplo de manejo de null en una expresión lambda para comparar
students.Sort((s1, s2) => {
    if (s1 == null && s2 == null) return 0;
    if (s1 == null) return -1; // null es menor que todo
    if (s2 == null) return 1;  // no-null es mayor que null
    return s1.Age.CompareTo(s2.Age); // Comparamos si ambos no son null
});

Por suerte, en proyectos reales muy a menudo las colecciones no contienen null, ¡pero es importante tenerlo en cuenta!

Rendimiento: Aunque las expresiones lambda son muy cómodas, a veces su uso excesivo dentro de bucles muy "calientes" o para colecciones enormes puede afectar un poco al rendimiento comparado con clases IComparer muy optimizadas, que quizá han sido probadas y perfiladas a fondo. Sin embargo, para la mayoría de tareas cotidianas la diferencia es mínima, y la ganancia en legibilidad y simplicidad del código compensa de sobra.

Cadenas de comparación complejas: Como vimos en el ejemplo de ordenar por apellido y luego por nombre, las expresiones lambda permiten anidar varias condiciones. Es mucho más cómodo que escribir diez if en una sola línea. Lo importante es comprobar siempre primero el resultado de la primera comparación (lastNameComparison != 0) y solo después pasar al siguiente nivel de anidación.

Las expresiones lambda y los delegados son conceptos fundamentales en C# que abren la puerta a un estilo de programación más flexible y funcional. Entenderlos y saber usarlos hará tu código mucho más limpio, eficiente y moderno. ¡Sigue experimentando y pronto los usarás sin pensar!

Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION