JavaRush /Курсы /C# SELF /Лямбда-выражения и делегаты для сравнения

Лямбда-выражения и делегаты для сравнения

C# SELF
30 уровень , 2 лекция
Открыта

1. Введение

Представьте, что у вас есть список студентов, и вам нужно отсортировать их один раз по возрасту, потом по фамилии, затем по среднему баллу, но только для тех, кто сдал все экзамены. Если для каждого такого "одноразового" сравнения писать целый новый класс, реализующий IComparer<T>, то ваш проект быстро превратится в свалку маленьких классов-компараторов. Это неудобно: код становится громоздким и нечитаемым.

Для таких ситуаций C# предлагает более изящное решение: возможность передать логику сравнения прямо в метод Sort без создания отдельного класса.

Для этого нам понадобятся делегаты и их компактные братья – лямбда-выражения.

Делегаты – наши гибкие помощники

Прежде чем мы перейдем к лямбдам, давайте разберемся, что такое делегат. Простым языком, делегат – это тип, который представляет собой ссылку на метод. Звучит немного метафизично, да? Думайте об этом так:

Представьте, что у вас есть список дел, и некоторые из этих дел – это "инструкции" или "рецепты". Делегат – это как специальная переменная, которая может хранить ссылку на такой "рецепт" (метод). А потом, когда вам нужно выполнить это дело, вы просто обращаетесь к переменной-делегату, и она "вызывает" тот метод, на который ссылается.

В C# делегаты используются для создания колбэков (вызова метода позже, обычно как реакция на событие), обработки событий (реагирования на действия, например, нажатие кнопки) и, конечно же, для передачи методов в качестве аргументов другим методам (чтобы метод мог вызывать другой метод), что нам сейчас и нужно для сортировки.

Метод List<T>.Sort() имеет несколько перегрузок (версий), и одна из них принимает специальный делегат под названием Comparison<T>.

2. Делегат Comparison<T>

Что такое Comparison<T>?

Comparison<T> – это встроенный в .NET делегат, который специально разработан для сравнения двух объектов одного типа T. Его "рецепт" выглядит так: он принимает на вход два объекта типа T (назовем их x и y) и возвращает целое число (int):

  • Отрицательное число (например, -1), если x "меньше" y.
  • Ноль (0), если x "равен" y.
  • Положительное число (например, 1), если x "больше" y.

Именно по этим правилам работают и IComparable.CompareTo, и IComparer.Compare. То есть, логика та же, только теперь мы можем передать её в виде "переменной-метода", а не отдельного класса.

Давайте посмотрим на примере. Вернемся к нашим студентам. Допустим, у нас есть класс 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($"Студент: {FirstName} {LastName}, Возраст: {Age}, Балл: {AverageGrade:F2}");
    }
}

Теперь, чтобы отсортировать список студентов по возрасту используя делегат, мы можем написать отдельный статический метод, который соответствует сигнатуре Comparison<Student>:

public class Program
{
    // Метод, который соответствует сигнатуре делегата Comparison<Student>
    // Он будет сравнивать двух студентов по их возрасту
    public static int CompareStudentsByAge(Student student1, Student student2)
    {
        // Используем встроенный метод CompareTo для чисел,
        // который возвращает -1, 0 или 1 в зависимости от сравнения.
        return student1.Age.CompareTo(student2.Age);
    }

    public static void Main(string[] args)
    {
        List<Student> students = new List<Student>
        {
            new Student("Иван", "Петров", 20, 4.5),
            new Student("Мария", "Сидорова", 22, 4.8),
            new Student("Алексей", "Иванов", 19, 3.9),
            new Student("Елена", "Козлова", 20, 4.2) // Два студента одного возраста
        };

        Console.WriteLine("--- Список студентов до сортировки ---");        
        foreach (var s in students)
            s.PrintInfo();

        Console.WriteLine("--- Сортируем студентов по возрасту (используя делегат) ---");
        students.Sort(CompareStudentsByAge); //передаем метод CompareStudentsByAge как параметр

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

Разбираем код:

  1. Мы создали статический метод CompareStudentsByAge, который принимает двух студентов и возвращает int, следуя контракту Comparison<Student>.
  2. В Main мы создали список студентов.
  3. Когда мы вызываем students.Sort(CompareStudentsByAge);, мы не вызываем метод CompareStudentsByAge() сразу! Мы просто передаем ссылку на этот метод. List<T>.Sort() затем сам будет вызывать наш метод CompareStudentsByAge столько раз, сколько ему понадобится для сортировки, передавая ему разные пары студентов. Это очень похоже на то, как вы даете кому-то адрес доставки, а не сразу отправляете туда весь грузовик.

Этот подход куда удобнее, чем создание отдельного класса-компаратора для каждой мелкой сортировки. Но можно пойти ещё дальше!

3. Встречайте Лямбда-Выражения

Даже необходимость писать отдельный метод, как CompareStudentsByAge, может показаться избыточной, если логика сравнения простая и нужна всего один или два раза. Для таких ситуаций в C# были введены лямбда-выражения (lambda expressions).

Что такое лямбда-выражение? Это, по сути, анонимный метод или, как я люблю шутить, "бездомный метод". Это способ написать коротенький кусочек кода (метод) прямо там, где он нужен, не объявляя его отдельно. Это как быстро набросать инструкцию на стикере и приклеить её прямо к делу, а не писать целый мануал.

Основной оператор лямбда-выражения — это => (читается как "стрелка" или "переходит в"). Он разделяет входные параметры от тела метода.

Базовый синтаксис лямбда-выражения

Допустим у вас есть делегат (ссылка на метод) и вы передаете его в функцию Sort():

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

students.Sort(CompareStudentsByAge); //передаем метод CompareStudentsByAge как параметр

Есть способ записать его покороче:

//передаём метод анонимный метод как параметр
students.Sort( (Student student1, Student student2) => student1.Age.CompareTo(student2.Age) );

Тут мы вместо имени метода подставляем его две самые важные вещи:

  • параметры: (Student student1, Student student2)
  • содержимое метода: student1.Age.CompareTo(student2.Age)

Такая компактная запись метода и называется лямбда-выражением: (параметры) => выражение

Как это работает

Компилятор C# когда встретит в коде лямбда-выражение, сгенерирует по нему настоящий метод.

Допустим есть у вас такой код:

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

Результат компиляции будет примерно таким:

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

students.Sort( CompareStudents_Lambda123 );

4. Пример сортировки и лямбда-выражения

Давайте перепишем наш пример со студентами, используя лямбда-выражение:

public class Program
{
    public static void Main(string[] args)
    {
        List<Student> students = new List<Student>
        {
            new Student("Иван", "Петров", 20, 4.5),
            new Student("Мария", "Сидорова", 22, 4.8),
            new Student("Алексей", "Иванов", 19, 3.9),
            new Student("Елена", "Козлова", 20, 4.2)
        };

        Console.WriteLine("--- Список студентов до сортировки ---");        
        foreach (var s in students)
            s.PrintInfo();

        // Теперь логика сравнения написана прямо здесь, "на месте"
        Console.WriteLine("--- Сортируем студентов по возрасту (используя лямбда-выражение) ---");
        students.Sort((student1, student2) => student1.Age.CompareTo(student2.Age));

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

        // Чтобы отсортировать по убыванию, просто умножаем результат на -1
        Console.WriteLine("\n--- Сортируем студентов по среднему баллу (по убыванию) ---");
        // s2.CompareTo(s1) вместо s1.CompareTo(s2)
        students.Sort((s1, s2) => s2.AverageGrade.CompareTo(s1.AverageGrade));

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

Что здесь произошло?

  1. students.Sort((student1, student2) => student1.Age.CompareTo(student2.Age));
    • student1 и student2 – это параметры, которые Sort будет передавать нашему анонимному методу (аналогично x и y в Comparison<T>).
    • => – это лямбда-оператор.
    • student1.Age.CompareTo(student2.Age) – это тело лямбда-выражения. В данном случае, это всего лишь одно выражение, результат которого и является возвращаемым значением.
  2. Для сортировки по среднему баллу по убыванию мы просто поменяли местами s1 и s2 в CompareTo. Это классический трюк для инвертирования порядка сортировки.

Почему это удобно?

  • Компактность: Не нужно создавать отдельные методы или классы для каждой небольшой логики сравнения.
  • Читаемость: Логика сравнения находится прямо рядом с вызовом Sort(), что улучшает понимание кода, особенно для простых случаев.
  • Гибкость: Можно легко менять условия сортировки "на лету".

5. Делегаты и Лямбды – идеальная пара

Может возникнуть вопрос: так лямбда-выражение – это то же самое, что делегат, или нечто другое?

На самом деле, лямбда-выражение – это всего лишь синтаксический сахар (syntax sugar) для создания экземпляра делегата или дерева выражений (Expression Tree, об этом позже). Когда компилятор видит лямбда-выражение, он сам, "под капотом", преобразует его в экземпляр подходящего делегата. В нашем случае, поскольку List<T>.Sort() ожидает делегат Comparison<T>, компилятор понимает, что (student1, student2) => student1.Age.CompareTo(student2.Age) нужно превратить именно в Comparison<Student>.

Таким образом, лямбда-выражения позволяют нам писать очень лаконичный код, а делегаты – это те "контейнеры", которые этот код переносят и позволяют его выполнять. Они работают рука об руку!

Когда что использовать?

  • IComparable<T>: Используйте, когда у вашего типа есть естественный, очевидный способ сортировки. Например, если вы сортируете товары, и основной способ сортировки – по их артикулу. Этот интерфейс определяет порядок "по умолчанию".
  • IComparer<T>: Используйте, когда вам нужна многократная, переиспользуемая логика сравнения, но вы не хотите "засорять" основной класс или когда у вас несколько различных способов сортировки. Например, один IComparer для сортировки товаров по цене, другой – по наименованию, и вы используете их в разных частях программы.
  • Делегаты (Comparison<T>) и Лямбда-выражения: Идеально подходят для одноразовых, ad-hoc сортировок, когда логика сравнения проста и не требует отдельного переиспользуемого класса. Это наиболее распространенный и чистый способ для большинства задач сортировки в C#. Также это отличный способ передавать логику в другие методы, например, в методы фильтрации (Find, FindAll) или поиска (FindIndex), которые мы рассматривали ранее.
Особенность IComparable<T> IComparer<T> Comparison<T> / Лямбда-выражение
Где определен? В самом классе T В отдельном классе-компараторе Может быть методом или анонимным выражением
Гибкость Фиксированный "естественный" порядок Многократные, переиспользуемые порядки Ad-hoc (на лету), для конкретного вызова метода
Бойлерплейт Небольшой, внутри класса Средний (отдельный класс) Минимальный (особенно для лямбд)
Пример использования
someList.Sort()
someList.Sort(new MyComparer())
someList.Sort((x, y) => x.Prop.CompareTo(y.Prop))
Удобство для чтения Хорошо для естественного порядка Зависит от названия компаратора Отличное для простых, специфических сравнений

6. Практическое применение и взгляд в будущее

Лямбда-выражения – это не просто "синтаксический сахар" для сортировки. Это мощный инструмент, который используется повсеместно в современном C#-коде. Вы будете встречать их очень часто:

  • В LINQ (Language Integrated Query): Это, пожалуй, самое массовое применение лямбда-выражений. LINQ позволяет писать SQL-подобные запросы к коллекциям, и лямбды используются для определения условий фильтрации, сортировки, проекции данных. Мы скоро будем изучать LINQ, и вы увидите, как лямбды делают его невероятно мощным и удобным.
  • В обработке событий: Лямбды позволяют лаконично описывать, что должно произойти при каком-либо событии (например, нажатии кнопки в пользовательском интерфейсе).
  • В асинхронном программировании: Для определения задач, которые должны выполняться параллельно.
  • В различных API .NET: Многие методы в стандартной библиотеке .NET принимают делегаты (и, соответственно, лямбда-выражения) в качестве параметров для добавления гибкой логики.

Таким образом, освоив лямбда-выражения, вы не только улучшите свои навыки сортировки, но и сделаете огромный шаг к пониманию современного C#-кода и библиотек. Это навык, который будет цениться на любом собеседовании и пригодится в каждом проекте!

7. Типичные ошибки и нюансы

Когда вы работаете с делегатами и лямбда-выражениями для сравнения, есть несколько моментов, на которые стоит обратить внимание:

Неправильный результат сравнения: Помните, что CompareTo или ваша логика сравнения должны возвращать отрицательное число, ноль или положительное число. Если вы случайно вернете что-то другое, сортировка может работать некорректно или даже привести к ошибкам. Самая частая ошибка – это когда новички возвращают true или false вместо int. Метод Sort ожидает именно числовой результат, потому что ему нужна информация не только о том, равны ли элементы, но и о том, какой из них "больше".

Обработка null значений: Если элементы в вашей коллекции могут быть null, то попытка вызвать метод на null объекте (например, student1.Age.CompareTo(...), если student1 равен null) приведет к NullReferenceException. В таких случаях ваша логика сравнения должна явно обрабатывать null значения. По общему правилу, null считается "меньше" любого ненулевого значения. Если оба null, они равны. Если один null, а другой нет, null "меньше".

// Пример обработки null в лямбда-выражении для сравнения
students.Sort((s1, s2) => {
    if (s1 == null && s2 == null) return 0;
    if (s1 == null) return -1; // null меньше всего
    if (s2 == null) return 1;  // не-null больше null
    return s1.Age.CompareTo(s2.Age); // Сравниваем, если оба не null
});

К счастью, в реальных проектах очень часто коллекции не содержат null, но иметь в виду этот момент очень важно!

Производительность: Хотя лямбда-выражения очень удобны, иногда их чрезмерное использование внутри очень "горячих" циклов или для крайне больших коллекций может незначительно повлиять на производительность по сравнению с высокооптимизированными IComparer классами, которые, возможно, были тщательно протестированы и профилированы. Однако для большинства повседневных задач разница будет несущественной, а выгода в читаемости и простоте кода значительно перевешивает.

Сложные цепочки сравнений: Как мы видели в примере с сортировкой по фамилии, затем по имени, лямбда-выражения позволяют вкладывать несколько условий. Это намного удобнее, чем писать десять if в одной строке! Главное — всегда сначала проверять результат первого сравнения (lastNameComparison != 0) и только потом переходить к следующему уровню вложенности.

Лямбда-выражения и делегаты – это фундаментальные концепции в C#, которые открывают двери к более гибкому и функциональному стилю программирования. Их понимание и умение применять сделают ваш код значительно чище, эффективнее и современнее. Продолжайте экспериментировать, и скоро вы будете использовать их на автомате!

2
Задача
C# SELF, 30 уровень, 2 лекция
Недоступна
Сортировка чисел с использованием лямбда-выражения
Сортировка чисел с использованием лямбда-выражения
Комментарии (1)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Дмитрий Уровень 60
16 сентября 2025
В примерах этой лекции нет проверки на null в делегатах и лямбда. Разве она не нужна?