Два условия в одной задаче

В прошлых уроках мы отбирали записи по одному признаку: сначала по очкам, потом сравнивали две формы записи. Теперь задача усложняется. Нужно подготовить отчёт о финишировавших участниках, набравших не менее ста очков. И отдельно — список тех, кто выступает за команду «Север».

На первый взгляд это две разные задачи. Но решать их удобнее одной операцией Where: условие в ней может быть составным. Предикат — так называют функцию, которая принимает элемент и возвращает bool, — может объединять несколько проверок через &&, || и !. Эти логические операторы нам уже знакомы по урокам про условия.

Заведём шесть записей, которые отличаются друг от друга по всем признакам. Так удобнее проверять, что именно отсекает каждое условие:

using System;
using System.Collections.Generic;
using System.Linq;

class RaceEntry
{
    public string Name { get; }
    public string Team { get; }
    public int Points { get; }
    public bool Finished { get; }

    public RaceEntry(string name, string team, int points, bool finished)
    {
        Name = name;
        Team = team;
        Points = points;
        Finished = finished;
    }
}

class Program
{
    static void Main()
    {
        List<RaceEntry> races = new List<RaceEntry>
        {
            new RaceEntry("Молния", "Север", 120, true),
            new RaceEntry("Комета", "Юг", 80, true),
            new RaceEntry("Вихрь", "Север", 150, true),
            new RaceEntry("Заря", "Север", 100, false),
            new RaceEntry("Стрела", "Юг", 100, true),
            new RaceEntry("Буря", "Север", 0, false)
        };

        IEnumerable<RaceEntry> finishedWithPoints =
            races.Where(entry => entry.Finished && entry.Points >= 100);

        Console.WriteLine("Финишировали со 100+ очками:");
        foreach (RaceEntry entry in finishedWithPoints)
        {
            Console.WriteLine($"{entry.Name}: {entry.Points}");
        }

        IEnumerable<RaceEntry> northTeam =
            races.Where(entry => entry.Finished
                                 && entry.Points >= 100
                                 && entry.Team == "Север");

        Console.WriteLine("Из них команда Север:");
        foreach (RaceEntry entry in northTeam)
        {
            Console.WriteLine(entry.Name);
        }
    }
}

Прежде чем запускать, разберём оба запроса вручную. Так будет видно, почему в выводе окажется именно такой состав.

Первый запрос проверяет два условия: Finished истинно и Points не меньше ста. Пройдём по всем шести записям по очереди.

Молния: финишировала, 120 очков. Оба условия истинны, проходит.

Комета: финишировала, 80 очков. Первое условие истинно, второе ложно. Не проходит — очков мало.

Вихрь: финишировал, 150. Оба условия истинны, проходит.

Заря: не финишировала, 100 очков. Первое условие ложно. Второе при этом истинно, но это уже не важно: результат соединения через && ложен, если хотя бы одна часть ложна. Не проходит.

Стрела: финишировала, ровно 100 очков. Оба условия истинны, проходит. Здесь как раз работает нестрогое сравнение >=: если бы стояло строгое >, Стрела бы не прошла.

Буря: не финишировала, 0 очков. Ложь по обоим условиям. Не проходит.

Итого три записи: Молния, Вихрь и Стрела.

Второй запрос добавляет третье условие — команда «Север». Проверим тех же трёх кандидатов. Молния из «Севера», проходит. Вихрь из «Севера», проходит. Стрела из «Юга», отсеивается. Остальные записи уже не прошли по первым двум условиям. В итоге две записи: Молния и Вихрь.

Обратим внимание на то, как соединяются условия. Оператор && вычисляет левую часть, и если она ложна, правая часть вообще не проверяется. Это короткое замыкание, о котором мы говорили в уроке про логические операторы. Здесь оно не просто экономит вычисления — иногда оно спасает от ошибки. Если бы правое условие обращалось к данным, которых при ложном левом просто нет, короткое замыкание предотвратило бы сбой. В нашем примере такой опасности нет, но привычка ставить более «дешёвую» или более ограничивающую проверку слева полезна.

С || ситуация обратная: если левая часть истинна, правая не проверяется. И здесь начинается место, где легко ошибиться со скобками. Предположим, мы хотим отобрать записи, у которых либо много очков, либо участие команды «Север». Написать entry.Points >= 100 || entry.Team == "Север" && entry.Finished — плохая идея. По приоритету && связывает крепче ||, поэтому выражение читается как «много очков или (Север и финишировал)». Если замысел был другой — например, «(много очков или Север) и финишировал», — нужны скобки: (entry.Points >= 100 || entry.Team == "Север") && entry.Finished. Скобки меняют смысл, и без них условие легко превращается в противоположное.

Разберём разницу между двумя записями условия на наших шести участниках. Напомню состав: Молния (Север, 120, финишировала), Комета (Юг, 80, финишировала), Вихрь (Север, 150, финишировал), Заря (Север, 100, не финишировала), Стрела (Юг, 100, финишировала), Буря (Север, 0, не финишировала).

Возьмём условие А:

races.Where(e => e.Points >= 100 || e.Team == "Север" && e.Finished)

Приоритет операторов здесь такой: && связывает крепче ||. Значит, выражение читается как «очков не меньше ста или (команда Север и финишировал)». Пройдём по записям.

Молния: 120 очков — левая часть истинна. По короткому замыканию правая не проверяется вовсе, и запись проходит.

Комета: 80 очков — левая ложна. Тогда вычисляется правая: команда «Юг» — ложь. Всё выражение ложно, Комета не проходит.

Вихрь: 150 очков — левая истинна, правая не проверяется, Вихрь проходит.

Заря: 100 очков — левая истинна (нестрогое сравнение включает границу). Правая часть не проверяется вообще, и запись проходит, хотя Заря не финишировала. Это и есть ключевое отличие от условия Б.

Стрела: 100 очков — левая истинна, проходит.

Буря: 0 очков — левая ложна. Вычисляется правая: команда «Север» — истина, но финишировала Буря или нет? Нет, Finished равно false. Значит, правая часть ложна. Всё выражение ложно, Буря не проходит.

Итого по условию А проходят четыре записи: Молния, Вихрь, Заря, Стрела.

Теперь условие Б:

races.Where(e => (e.Points >= 100 || e.Team == "Север") && e.Finished)

Скобки меняют группировку: сначала вычисляется «очков не меньше ста или команда Север», и только потом результат соединяется через && с признаком финиша.

Молния: в скобках левая часть истинна, скобка истинна. Затем && e.Finished — истина. Проходит.

Комета: в скобках обе части ложны, скобка ложна. Правая часть && не вычисляется по короткому замыканию, запись не проходит.

Вихрь: скобка истинна, финишировал — проходит.

Заря: в скобках левая часть истинна (100 очков), скобка истинна. Но e.Finished равно false, и всё выражение ложно. Заря не проходит. Именно здесь условия А и Б расходятся: для А достаточно было ста очков, для Б этого мало — нужно ещё финишировать.

Стрела: в скобках левая часть истинна, скобка истинна, финишировала — проходит.

Буря: в скобках левая ложна, но правая истинна — команда «Север». Скобка истинна. Однако Буря не финишировала, e.Finished ложно, и всё выражение ложно. Буря не проходит.

Итого по условию Б проходят три записи: Молния, Вихрь, Стрела.

Сравним составы. Разница ровно в Заре: условие А её пропускает, потому что не проверяет финиш при истинной левой части; условие Б её отсекает. Буря не проходит ни там, ни там, но по разным причинам: в А её отсекает ложная правая часть (не финишировала), в Б — ложный признак финиша после истинной скобки. Стрела проходит в обоих случаях через очки: 100 удовлетворяет >= 100.

Обратите внимание на короткое замыкание. Оно срабатывает по-разному в каждой записи, и неверно думать, что правая часть проверяется всегда. У Молнии и Вихря в условии А правая часть вообще не вычислялась. У Кометы в условии Б правая часть && не вычислялась, потому что скобка уже была ложной. Это не оптимизация ради скорости, а правило языка, о котором полезно помнить: порядок условий влияет на то, что именно будет проверено.

Теперь про пустые результаты. Представим два случая: исходный список пуст, и исходный список непуст, но ни одна запись не подходит под условие. В первом случае перебирать нечего: предикат не будет вызван ни разу, потому что элементов нет. Во втором случае предикат вызовется для каждой записи и вернёт false шесть раз. Разница есть — в количестве проверок, — но результат одинаков: пустая последовательность. И это не ошибка. Цикл foreach по пустому результату просто не выполнит своё тело ни разу, программа спокойно пойдёт дальше. Именно поэтому пустой отчёт — нормальный исход, а не повод для исключения.

Есть ещё одна тонкость: последовательные Where и одно составное условие через && дают одинаковый результат, если предикаты чистые — то есть не зависят от порядка применения и не имеют побочных эффектов. races.Where(e => e.Finished).Where(e => e.Points >= 100) и races.Where(e => e.Finished && e.Points >= 100) вернут одно и то же. А вот с || так не получится: цепочка Where соединяет условия через «и», а не через «или». Это полезно помнить, когда разбиваете сложное условие на части для читаемости.

Теперь про объекты. Отобранные записи — это не копии. Where возвращает ссылки на те же самые объекты, что лежат в исходном списке. Если бы свойства RaceEntry были изменяемыми, изменение через отобранную запись отразилось бы и в исходном списке. У нас свойства только для чтения, так что изменить их нельзя в принципе, но важно понимать: LINQ не создаёт дубликаты. Исходный список races после запроса остаётся тем же — все шесть записей на месте.

Лямбда с блоком, методы и захват переменных

Предикат не обязан быть коротким выражением. Если условие разрастается, его можно записать лямбдой с блоком:

IEnumerable<RaceEntry> strong =
    races.Where(entry =>
    {
        bool hasPoints = entry.Points >= 100;
        bool isNorth = entry.Team == "Север";
        return entry.Finished && (hasPoints || isNorth);
    });

Здесь есть важное отличие от короткой формы. Лямбда с блоком обязана явно возвращать bool через return — компилятор не догадается сам. Если return забыть, программа не соберётся. Зато внутри блока можно завести локальные переменные и разбить условие на понятные части, как в примере.

Альтернатива лямбде — обычный метод. Например, добавьте следующий метод внутрь класса Program рядом с Main. Он принимает запись и возвращает bool:

static bool IsEligible(RaceEntry entry)
{
    return entry.Finished && entry.Points >= 100;
}

Обратите внимание на слово static. В нашем примере Main — статический метод, и IsEligible вызывается из него. Статический метод можно вызывать напрямую, без создания объекта Program, а нестатический потребовал бы экземпляра. Мы уже разбирали это в блоке ООП, поэтому коротко: static здесь нужен, чтобы Main мог обратиться к методу без создания объекта. А использовать метод в запросе можно так: races.Where(IsEligible) — без лямбды, потому что метод уже принимает RaceEntry и возвращает bool, то есть подходит по форме.

Отдельная тема — захват переменных в лямбде. Посмотрим:

int minPoints = 100;

IEnumerable<RaceEntry> selected =
    races.Where(entry => entry.Points >= minPoints);

minPoints = 130;

foreach (RaceEntry entry in selected)
{
    Console.WriteLine(entry.Name);
}

Что окажется в выводе? Лямбда захватывает переменную minPoints, но не её значение в момент создания запроса, а саму переменную. Поскольку выполнение запроса отложено до перебора, к моменту foreach в minPoints уже лежит 130. Значит, порог будет 130, и в выводе останется только Вихрь с 150 очками. Молния с 120 очками не пройдёт. Если бы мы хотели зафиксировать порог на момент создания запроса, нужно было либо создать снимок через ToList сразу после запроса, либо скопировать значение в отдельную переменную, которую мы больше не меняем.

Ошибки в предикате, кстати, проявляются не при объявлении запроса, а при переборе. Если внутри лямбды возникнет исключение — например, из-за некорректных данных, — оно выбросится именно в foreach, потому что до этого момента элементы не проверяются. Мы не будем сейчас разбирать примеры с делением и другими арифметическими ошибками, чтобы не уходить в сторону; достаточно запомнить, что диагностировать проблемы в предикате нужно там, где происходит перебор.

Небольшой вопрос на понимание. Пусть в списке есть запись с ровно 100 очками. Условие entry.Points >= 100 её пропустит. А если написать entry.Points > 100? Нет, не пропустит: 100 не больше 100, потому что неравенство строгое. Именно этот случай мы и разбирали на Заре и Стреле: >= включает границу, > её исключает. Выбор зависит от формулировки требования: «не менее ста» — это >=, «больше ста» — это >. Разница в одном символе, а состав отчёта меняется.

Дальше будем разбираться с тем, как получать из отобранных записей нужные для отчёта значения. Но сначала убедитесь, что уверенно читаете и пишете предикаты: в следующих уроках условия станут длиннее.

Where проверяет предикат для каждого элемента последовательности. Предикат — это функция, возвращающая bool для переданного элемента; в нём можно соединять несколько проверок через &&, || и !, как в обычных условиях. В нашем примере первое условие — финишировал и набрал не менее ста очков — прошли Молния, Вихрь и Стрела, а добавление проверки на команду «Север» оставило только Молнию и Вихря. Границы важны: >= включает ровно сто очков, > исключает.

Оператор && работает с коротким замыканием: если левая часть ложна, правая не проверяется. При смешивании с || нужны скобки, иначе приоритет операторов изменит смысл условия. Последовательные Where дают тот же результат, что одно условие через &&, но не через ||. Отобранные записи — это ссылки на те же объекты, а исходный список после запроса не меняется.

Предикат можно записать лямбдой с блоком, но тогда обязателен return с bool. Вместо лямбды подойдёт метод с подходящими параметром и возвращаемым типом; в нашем Program он статический. Захваченная переменная в лямбде читается в момент перебора, поэтому её изменение после создания запроса влияет на результат; для фиксации нужен снимок через ToList. Ошибки предиката проявляются при переборе, а не при объявлении запроса.

Обсуждение урока

0
Комментарии видны всем. Чтобы участвовать в обсуждении, войдите или зарегистрируйтесь.
Модерация сообщества

Пожаловаться на комментарий

Расскажите модераторам, что именно требует внимания.