Две записи одного правила

В прошлом уроке мы отбирали записи с помощью 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 подробнее.

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

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

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

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