Данные из прошедших заездов
До сих пор мы работали с гонкой в реальном времени: машины ехали, потоки соревновались за тоннель, события попадали в журнал. Теперь заезды позади, и у нас есть результаты, с которыми нужно работать как с данными. Это удобный момент познакомиться с LINQ.
Аббревиатура LINQ расшифровывается как Language Integrated Query — «запросы, встроенные в язык». Речь идёт не об отдельном инструменте, а о возможностях самого C# и стандартных библиотеках .NET: операторы вроде Where доступны прямо в коде, без обращения к базе данных и без сторонних пакетов. Когда эти операторы применяют к обычным объектам в памяти, говорят LINQ to Objects. Именно этим мы и займёмся.
Начнём с модели. Пусть каждая запись о результате заезда содержит имя участника, команду, набранные очки и признак того, финишировал ли он. Заведём для этого класс RaceEntry:
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;
}
}
Это класс с конструктором и свойствами. Обратите внимание на форму записи public string Name { get; }: это свойства, а не поля. Свойство выглядит как поле при обращении, но за ним стоит код чтения, который компилятор создаёт автоматически. Мы уже разбирали свойства в блоке ООП, так что подробно останавливаться не будем. Значения задаются в конструкторе, а изменить их снаружи нельзя: здесь только get.
Список записей мы будем хранить в List<RaceEntry> — это новая для нас модель данных, хотя сам тип List<T> знаком по прошлым урокам. В нём будут четыре участника: Молния с 120 очками, Комета с 80, Вихрь с 150 и Заря с 100.
Теперь конкретная задача: подготовить отчёт и отобрать участников, набравших больше ста очков. Кто в него попадёт? Ответ — Молния и Вихрь. Причина простая: у Молнии 120 очков, у Вихря 150, и оба значения больше ста. Комета не проходит, потому что у неё 80. А вот с Зарёй стоит разобраться отдельно: у неё ровно 100 очков, и всё зависит от того, строгое условие или нестрогое. Если требовать больше ста — Заря остаётся за бортом. Если хотя бы сто — попадает. Такие пограничные случаи часто становятся источником путаницы, поэтому обращайте на них внимание.
Прежде чем что-то менять, вспомним, как такая задача решалась бы без LINQ. Мы бы прошли по списку циклом foreach, для каждой записи проверили условие Points > 100, и подходящие имена вывели:
foreach (RaceEntry entry in races)
{
if (entry.Points > 100)
{
Console.WriteLine(entry.Name);
}
}
Решение рабочее и понятное. Оно выполняет свою задачу полностью и не требует замены только потому, что появился LINQ. В чём же тогда смысл нового инструмента?
Смысл в разделении двух вещей, которые в этом цикле слиты вместе. Первая — правило отбора: «взять записи с очками больше ста». Вторая — что мы делаем с отобранными данными: печатаем имя, собираем в список, сохраняем в файл, что угодно. В цикле правило и обработка находятся в одном месте, и, если потребуется другая обработка — скажем, вывести не имена, а очки, — придётся написать новый цикл или изменить существующий. Можно, конечно, вынести условие в отдельный метод и переиспользовать его. LINQ просто делает это разделение естественной частью языка.
Where: правило вместо цикла
Оператор Where принимает условие и пропускает дальше только те элементы, которые ему удовлетворяют. Запишем нашу задачу через Where:
IEnumerable<RaceEntry> selected = races.Where(entry => entry.Points > 100);
Разберём по частям. Выражение entry => entry.Points > 100 — лямбда, то есть маленькая функция без имени. Лямбды мы уже использовали для потоков и задач, поэтому синтаксис знаком. Для Where такая лямбда должна возвращать bool: она отвечает на вопрос, подходит ли элемент. Параметр entry — это имя, под которым внутри лямбды будет доступен очередной элемент. Символ => читается как «ведёт к»: слева — параметр, справа — результат.
Результат запроса имеет тип IEnumerable<RaceEntry>. Это интерфейс, который обещает перебор: по нему можно пройти циклом foreach. Гарантия здесь скромная — «элементы можно перечислять по одному». Это может быть список, массив, генератор; конкретный тип не важен, потому что мы всё равно собираемся только перебирать.
Проверим, что получится, если пройти по результату:
foreach (RaceEntry entry in selected)
{
Console.WriteLine($"{entry.Name}: {entry.Points}");
}
Вывод будет таким:
Молния: 120
Вихрь: 150
Пройдём по всем четырём записям мысленно и посмотрим, где условие срабатывает. Первая — Молния, 120 очков. 120 > 100 истинно, элемент проходит. Вторая — Комета, 80. Ложь, элемент отбрасывается. Третья — Вихрь, 150. Истинно, проходит. Четвёртая — Заря, 100. 100 > 100 — ложь, потому что строгое неравенство, элемент не проходит. В итоге два элемента из четырёх.
Теперь про запуск. Обратите внимание на важное отличие: строка с Where — это объявление переменной, и она не выводит ничего. Если запустить программу с одной этой строкой, на экране не появится ни одной надписи. Отбор случится не в момент объявления, а во время перебора в foreach. А сам foreach без единой подходящей записи тоже не ошибка: если бы условие не прошла ни одна запись, цикл просто не выполнил бы тело ни разу, и программа спокойно пошла бы дальше. Пустой результат — это нормальный результат.
Ещё одно: исходный список после отбора не меняется. В races остаются все четыре записи, независимо от того, что мы выбрали для отчёта. foreach только читает элементы по очереди: он их не переставляет и не удаляет.
Отложенное выполнение
У Where есть особенность, которую важно понять, потому что она объясняет многое в поведении LINQ. Разберём её на опыте. Создадим запрос, а затем, до перебора, добавим в исходный список пятую запись:
IEnumerable<RaceEntry> selected = races.Where(entry => entry.Points > 100);
races.Add(new RaceEntry("Стрела", "Север", 200, true));
foreach (RaceEntry entry in selected)
{
Console.WriteLine($"{entry.Name}: {entry.Points}");
}
Может показаться, что в selected уже зафиксирован состав из четырёх записей, и Стрела туда не попадёт. Но это не так. Выполнение запроса происходит отложенно: при вызове Where элементы не проверяются. Проверка начинается только тогда, когда мы проходим по результату в foreach. К этому моменту Стрела уже лежит в списке, её 200 очков больше ста, и она попадает в вывод вместе с Молнией и Вихрем.
Здесь стоит уточнить, что именно делает Where при вызове. Он не перебирает элементы, но это не значит, что он вообще ничего не делает. Он запоминает, из какого источника брать данные и какое условие применять, — а ещё сразу проверяет, что аргументы не равны null, и выбрасывает исключение, если это так. А вот сами элементы он действительно проверяет только при перечислении. Отложенным выполнением это называется именно в этом смысле: проверка данных откладывается, а подготовка запроса происходит сразу.
Такое поведение свойственно не всем операторам LINQ одинаково, и обобщать его на всё было бы неверно. Некоторые операторы выполняются немедленно — например, ToList перечисляет источник сразу, чтобы наполнить новый список. Мы познакомимся с ними постепенно. Пока запомним главное: Where проверяет элементы при перечислении.
Если же нужен снимок состава на текущий момент, применяют ToList — дописывают его после Where:
List<RaceEntry> snapshot = races.Where(entry => entry.Points > 100).ToList();
Здесь фильтр остаётся на месте, а .ToList() добавляется в конец цепочки. ToList немедленно проходит по последовательности и создаёт новый список. Если после этого добавить Стрелу в races, snapshot не изменится: в нём останутся только записи, которые подходили на момент вызова.
Важная оговорка. ToList создаёт новый список элементов, но не копирует сами объекты. Это те же ссылки на записи: если бы у RaceEntry были изменяемые свойства, изменение через одну ссылку было бы видно и в снимке. В нашей модели свойства только для чтения, так что изменить запись нельзя в принципе. Но если бы мы сделали модель изменяемой, разница между «новый список» и «новые объекты» стала бы заметной.
И ещё: новый список занимает память. LINQ избавляет от необходимости писать цикл и делает код выразительнее, но не отменяет расходов на хранение данных. Говорить, что «теперь коллекций нет», было бы неправдой — в этом примере новый список создаётся вызовом ToList.
Соберём всё в один файл:
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)
};
IEnumerable<RaceEntry> selected =
races.Where(entry => entry.Points > 100);
foreach (RaceEntry entry in selected)
{
Console.WriteLine($"{entry.Name}: {entry.Points}");
}
}
}
Разберём три директивы using. System позволяет записать Console без полного имени System.Console. System.Collections.Generic даёт List<T> и IEnumerable<T>. А System.Linq открывает доступ к операторам LINQ: в наших примерах мы указываем его явно. В современных проектах .NET это пространство имён может быть подключено автоматически через implicit usings, поэтому отсутствие строки не всегда вызывает ошибку.
Как запускать. Сначала выполните код как есть: в выводе должны появиться Молния и Вихрь. Затем добавьте блок с races.Add(...) перед foreach и посмотрите, что в выводе стало три записи. После этого верните код к исходным четырём записям, создайте снимок через .ToList() — тоже до добавления — и попробуйте добавить Стрелу: снимок не изменится. Эти три опыта показывают разницу между отложенным запросом и немедленным списком лучше любых слов.
В следующем уроке мы напишем тот же самый запрос двумя формами записи: через синтаксис from, where, select и через цепочку методов — и убедимся, что это две записи одного и того же запроса, а не два разных.
LINQ — это запросы, встроенные в язык C#: возможности самого языка вместе со стандартными библиотеками, а не сторонний пакет. Оператор Where принимает лямбду, возвращающую bool, и пропускает дальше только те элементы, для которых условие истинно. В нашем примере прошли Молния и Вихрь; Комета не прошла по значению, а Заря — из-за строгого неравенства, потому что её сто очков не больше ста. Результат имеет тип IEnumerable<RaceEntry> и обещает перебор.
Отбор через Where не заменяет цикл foreach и не отменяет его: он отделяет правило отбора от того, что мы делаем с результатом. Объявление запроса ничего не выводит и не перебирает данные: сами элементы проверяются во время перечисления, хотя источник и условие запоминаются сразу. Поэтому добавленная запись попадает в результат, если она появилась до перебора. Пустой результат — не ошибка, а нормальный случай: цикл просто не выполнит тело ни разу, а исходный список останется неизменным.
ToList дописывается после Where и создаёт снимок состава на момент вызова: последующие добавления в него не попадают. Это новый список с теми же ссылками на объекты, и он занимает память. В следующем уроке мы рассмотрим две формы записи одного и того же запроса — через from, where, select и через цепочку методов.