Две записи одного правила
В прошлом уроке мы отбирали записи с помощью Where и получили Молнию и Вихря. Запись выглядела так: races.Where(entry => entry.Points > 100). Это метод, вызванный у списка, с лямбдой внутри. Такой стиль называют синтаксисом методов: всё выражается вызовами методов и лямбда-выражениями.
Теперь представьте, что вы открыли чужой код и увидели там нечто иное:
var querySyntax = from entry in races
where entry.Points > 100
select entry;
Это тоже C#, и это тоже отбор по тому же условию. Такая запись называется синтаксисом запросов. Слова from, where, select — не методы и не строки: это ключевые слова языка, специально добавленные в C# для работы с последовательностями. Обратите внимание: здесь нет кавычек и нет ничего похожего на SQL-строку, которую кто-то передаёт в базу данных. Это обычный код, который компилятор понимает напрямую.
Вопрос, который возникает сразу: это два разных механизма или одно и то же? Ответ — одно и то же. Синтаксис запросов и синтаксис методов выражают одну операцию, и компилятор переводит первую форму во вторую. Никакого отдельного «движка запросов» здесь нет: from ... where ... select — это удобная запись для тех же вызовов Where и Select, которые вы уже знаете или скоро узнаете.
Разберём обе формы на одном и том же списке. Соберём полный пример:
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> methodQuery =
races.Where(entry => entry.Points > 100);
IEnumerable<RaceEntry> querySyntax =
from entry in races
where entry.Points > 100
select entry;
Console.WriteLine("Синтаксис методов:");
foreach (RaceEntry entry in methodQuery)
{
Console.WriteLine($"{entry.Name}: {entry.Points}");
}
Console.WriteLine("Синтаксис запросов:");
foreach (RaceEntry entry in querySyntax)
{
Console.WriteLine($"{entry.Name}: {entry.Points}");
}
}
}
Запустите: обе версии выведут одно и то же — Молнию и Вихря. Это не совпадение и не случайное равенство результатов. Это буквально один и тот же запрос, записанный двумя способами.
Как читается запрос
Разберём синтаксис запросов по частям, потому что читать его придётся часто, а писать — по желанию.
from entry in races — начало запроса. Здесь races — источник, то есть последовательность, из которой берутся элементы. А entry — переменная диапазона: имя, под которым очередной элемент доступен дальше внутри запроса. Это не объявление новой переменной в обычном смысле, и вне запроса entry не существует. Если попробовать обратиться к entry после закрывающей точки с запятой, компилятор сообщит об ошибке — переменная диапазона живёт только внутри запроса.
where entry.Points > 100 — условие отбора. Здесь entry уже известна: это очередной элемент из races. Выражение после where должно давать bool, как и лямбда в Where. Синтаксис запросов не ослабляет требований: если написать where entry.Points, компилятор откажется, потому что int — не bool.
select entry — что отдать в результат. Здесь мы говорим: для каждого прошедшего элемента вернуть сам этот элемент. Не его имя, не очки, не копию, а именно объект RaceEntry. Слово select не выводит ничего в консоль: оно определяет, что попадёт в результирующую последовательность. Вывод по-прежнему делается циклом foreach снаружи.
Объявление переменной с запросом заканчивается точкой с запятой, как и другие объявления переменных. В этом примере создание запроса не перебирает данные: как и в прошлом уроке, перебор произойдёт только в foreach. Отложенное выполнение здесь работает так же. И ещё: select в конце обязателен, если вы не используете group. Мы не будем сейчас трогать group — это тема далёкая, — но упомянуть стоит, чтобы вопрос «почему в конце всегда select» не остался без ответа.
Теперь про соответствие форм. Строка from entry in races where entry.Points > 100 select entry компилятором превращается в вызовы Where и Select. Для нашей формы select entry, когда возвращается сам элемент, компилятор может и не вызывать отдельный Select: он видит, что отображение ничего не меняет, и обходится только фильтром. Утверждать, что каждое слово запроса — всегда отдельный метод, было бы неверно. Важно другое: результат тот же, потому что операция та же.
Посмотрим внимательнее на строку с запросом и на то, что в ней происходит с типами. В примере выше было написано IEnumerable<RaceEntry> querySyntax = .... Мы указали тип явно. Но в чужом коде чаще встречается var:
var querySyntax =
from entry in races
where entry.Points > 100
select entry;
Слово var не означает «какой-то неизвестный тип» и уж точно не означает dynamic. Это не отдельный тип данных и не «переменная без типа». var — это указание компилятору: выведи тип сам из правой части присваивания. Компилятор смотрит на выражение справа, определяет его тип и подставляет его в объявление. После этого переменная имеет ровно тот же тип, что был бы написан явно. Разницы в поведении программы между var querySyntax и IEnumerable<RaceEntry> querySyntax нет: это одна и та же переменная одного и того же типа, только записанная короче. Проверить это можно, наведя курсор на var в Visual Studio: подсказка покажет фактический тип.
Теперь откуда компилятор вообще берёт тип. В строке from entry in races источником служит races, а это List<RaceEntry>. Компилятор видит, что List<RaceEntry> реализует перебор элементов типа RaceEntry, и понимает: переменная диапазона entry имеет тип RaceEntry. Дальше entry.Points — это уже свойство int, и условие entry.Points > 100 даёт bool, что и требуется для where. Наконец, select entry возвращает сам объект, то есть RaceEntry, а весь запрос — последовательность таких объектов. Поэтому querySyntax получает тип IEnumerable<RaceEntry>.
Здесь стоит развеять две возможные ошибки в понимании. Первая: querySyntax — это не объект RaceEntry и не одна запись. Запрос описывает последовательность, а не отдельный элемент. Вторая: присваивание не создаёт копию всего списка. Переменная хранит описание запроса — источник и условие, — а не новый список с продублированными записями. Именно поэтому запрос остаётся отложенным: данные не перебираются в момент присваивания.
Теперь про имена. В запросе участвует переменная диапазона entry. Это не имя поля, не имя свойства и не переменная из внешнего кода. Это новое имя, которое существует только внутри запроса. Мы могли бы назвать её иначе — например, e:
var querySyntax =
from e in races
where e.Points > 100
select e;
Результат тот же. Важно только одно: имя должно быть согласовано во всех частях запроса. Если объявить from e in races, то и в where, и в select нужно писать e, а не entry. Компилятор не догадается, что это одно и то же: для него entry — незнакомое имя, и он сообщит об ошибке. То же самое касается метода Where с лямбдой: параметр entry в races.Where(entry => entry.Points > 100) тоже можно переименовать в e, и это не изменит смысла. Область видимости переменной диапазона заканчивается вместе с запросом: за пределами выражения её нет, и попытка обратиться к entry после точки с запятой приведёт к ошибке компиляции.
Отдельно уточним два момента, с которыми легко попасть впросак. Первый — про using System.Linq. В новых версиях .NET он может подключаться автоматически через так называемые implicit usings: набор часто нужных пространств имён добавляется в проект по умолчанию. Это значит, что отсутствие явной строки using System.Linq в файле не обязательно приведёт к ошибке. В наших примерах мы всё равно пишем его явно — так виднее, откуда берутся операторы, и код не зависит от настроек проекта.
Второй момент — как запрос выглядит в виде отдельной строки. Если написать просто:
querySyntax;
программа не соберётся: компилятор выдаст ошибку CS0201, потому что выражение, у которого нет присваивания, вызова, инкремента или другого действия, не является допустимой инструкцией. Строка с одним именем переменной ничего не делает и не может стоять сама по себе. А вот присваивание допустимо:
var copy = querySyntax;
Здесь создаётся вторая переменная с тем же описанием запроса. Никакого вывода в консоль при этом не происходит: запрос по-прежнему не выполняется до перебора. И это подводит нас к тому, как убедиться, что две формы записи — действительно одно и то же. Измените порог сразу в обеих: и в races.Where(...), и в where ... > ... внутри запроса. Запустите программу и сравните выводы — они совпадут, потому что компилятор переводит синтаксис запросов в те же вызовы методов. Один запуск не доказывает, что формы работают с одинаковой скоростью: для этого нужны измерения, а не один прогон. И сама форма записи не выбирает новый поток и не влияет на параллельность: и методы, и запрос выполняются в том потоке, который дошёл до перебора.
Проверим, как ведут себя обе формы на пограничных значениях. Изменим порог в обоих запросах на > 130. Что попадёт в результат? Пройдём по записям: Молния 120 — не больше 130, отпадает. Комета 80 — тоже. Вихрь 150 — проходит. Заря 100 — не проходит. В выводе останется один Вихрь. Обе формы дадут одно и то же. Теперь поднимем порог до > 200: ни одна запись не подходит, и обе последовательности окажутся пустыми. Цикл foreach не выполнит тело ни разу, и это не ошибка — просто пустой отчёт.
Скобки, списки и типичные ошибки
Иногда результат запроса нужен не для перебора, а как готовый список. Тогда применяют ToList, и здесь есть тонкость со скобками:
List<RaceEntry> snapshot = (from entry in races
where entry.Points > 100
select entry).ToList();
Запрос целиком заключается в круглые скобки, и только после них ставится .ToList(). Если скобки опустить, компилятор попытается применить ToList к чему-то другому и не соберёт программу. Проще всего запомнить: сначала закончите запрос, затем заключайте его в скобки и вызывайте метод. С синтаксисом методов такой проблемы нет: races.Where(...).ToList() читается слева направо без дополнительных скобок.
Теперь вы можете прочитать чужой код с from ... where ... select и понять, что это знакомый отбор: источник, условие, что вернуть. Синтаксис методов и синтаксис запросов — две формы одной операции; выбор между ними — вопрос удобства и привычки. В следующем уроке мы рассмотрим оператор Where подробнее: несколько условий, составные проверки и то, как ведёт себя отбор с методами внутри лямбды.
Синтаксис методов и синтаксис запросов — две формы записи одной операции. races.Where(entry => entry.Points > 100) и from entry in races where entry.Points > 100 select entry дают одинаковый результат, потому что компилятор переводит вторую форму в вызовы стандартных методов LINQ. Это не SQL и не строка: from, where, select — ключевые слова языка, а не текст, передаваемый куда-то вовне.
В запросе from задаёт источник и переменную диапазона, where проверяет условие типа bool, select определяет, что попадёт в результат. Переменная диапазона существует только внутри запроса; за его пределами её нет. Сам запрос ничего не выводит: перебор происходит в foreach, а для получения списка нужно заключить запрос в скобки и вызвать .ToList().
Пространство имён System.Linq должно быть доступно и при записи через from: мы подключаем его явно, хотя настройки проекта могут добавлять его автоматически. Изменение порога в обеих формах даёт одинаковый результат: > 130 оставляет одного Вихря, > 200 даёт пустую последовательность, и это не ошибка. В следующем уроке разберём Where подробнее.