Два условия в одной задаче
В прошлых уроках мы отбирали записи по одному признаку: сначала по очкам, потом сравнивали две формы записи. Теперь задача усложняется. Нужно подготовить отчёт о финишировавших участниках, набравших не менее ста очков. И отдельно — список тех, кто выступает за команду «Север».
На первый взгляд это две разные задачи. Но решать их удобнее одной операцией 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. Ошибки предиката проявляются при переборе, а не при объявлении запроса.