Andriy Shyrokoryadov

.Net developer, data scientist

Выражения LINQ - вопрос №25 на собеседование C# / .NET

Текст к видео "Выражения LINQ" на канале YouTube

В практике программирования мы часто работаем с существующим кодом. Редко кому удается попасть в проект, над которым работа только начинается и есть возможность запланировать всё работу с самого начала. А даже если мы работаем в проекте с самого начала его существования, то скорей всего на каком то этапе проекта настанет момент, когда будут вноситься изменения, которые кардинально повлияют на функциональность и код. Можно смело утверждать, что независимо от того, работаем ли мы в проекте с самого начала или получили в наследство существующий код, изменения, большие и малые, будут частью нашей работы. Другими словами, изменения будут самой постоянной частью нашей работы.

В зависимости от кода, с которым вам придется работать, вы будете попадать в различные ситуации, которые могут ограничивать возможности имплементации изменений, а с другой стороны, наоборот, если код был написан с умом, давать большую свободу действий. Как правило вы чаще будете в ситуации, когда ваши возможности ограничены. И именно таким ситуациям мы посвятим часть этого видео.

Предположим, приложение с которым мы работаем пользуется определенной библиотекой. В этой библиотеке наш код использует некий класс, пусть он называется класс «А». Непосредственного доступа к коду класса «А» у нас нет. Поступило задание добавить в данный класс некий новый метод, который расширяет функциональность данного класса. Давайте подумаем какие возможности у нас есть.

  1. мы можем попробовать наследовать данный класс и в классе-наследнике сделать имплементацию метода, который необходим нашим пользователям;
  2. мы можем использовать композицию и шаблон проектирования «Декоратор», чтобы добавить интересующий нас метод.

К сожалению, ни один из предложенных методов не является идеальным.

В первом случае наследование конкретного, не абстрактного класса, не считается хорошей практикой. К тому же в коде появится новый класс, который мы будем должны обслуживать. А если класс «А» обозначен ключевым словом sealed, то наследование вообще будет невозможно. О ключевых словах при наследовании у меня снято отдельное видео на канале – ссылка будет в правом верхнем углу.

Второе решение лучше, чем первое, потому что если у нас есть выбор использовать наследование или композицию, то выбор всегда должен падать на композицию. Однако в случае имплементации одного-единственного метода, использование композиции и шаблона «Декоратор» может быть избыточным. После подготовки серии видео с вопросами на собеседование «C#», я планирую выпустить серию видео, посвященную шаблонам проектирования, поэтому если Вы еще не подписались, рекомендую подписаться на канал, чтобы не пропустить обновления.

Даже если бы у нас была гипотетическая, третья возможность – изменить код класса «А», то такие изменения могли бы, теоретически, что-нибудь испортить и нарушить обратную совместимость кода библиотеки с классом «А». Следовательно такая возможность нам также не подходит.

Казалось бы, в этой точке все наши возможности исчерпаны, однако в языке программирования C# можно использовать такую функциональность, как методы расширения или Extension Methods. Если коротко, то методы расширения позволяют нам добавить функциональность к типам, значимым и ссылочным, не изменяя код этих типов, не прибегая к наследованию и композиции, не использую шаблоны проектирования. Это все звучит, как не плохой вариант, который не требует много времени и усилий. Давайте рассмотрим его поподробней.

Сразу стоит оговорится новый класс всё-таки будет создан. В этом классе будут содержаться наши методы расширения. Однако в отличие от большинства классов в нашей системе, данный класс будет статическим, то есть у нас не будет объектов данного класса, а следовательно данный класс будет чуть легче обслуживать. О статических классах у меня снято отдельное видео на канале – ссылка будет в правом верхнем углу. Давайте рассмотрим методы расширения на конкретном примере – добавим метод, который конвертирует текстовую строку string на коллекцию типа char.

namespace XYZ
{
    static class StringExtensions
    {
        public static IEnumerable<char> ToCharEnumerable(this String @string)
        {
            var result = new List<char>();
            for (int i = 0; i < @string.Length; i++)
                result.Add(@string[i]);
            return result;
        }
    }
}

Наш метод расширения называется ToCharEnumerable. Так как этот метод является методом статического класса, то он обозначен ключевым слово static. Также обратите внимание, что первым аргументом метода является аргумент с типом, который мы расширяем с ключевым словом this. Конечно, мы можем написать метод расширения, который принимает дополнительные аргументы – это не является ограничением. Важно отметить, что в отличии от примеров, которые я приводил в моих предыдущих видео, в этом примере я также указал пространство имён - namespace XYZ. Это важно, так как без явного указания пространства имён ваш метод расширения будет недоступен на типе, который мы «расширяем». То есть без явного указания using XYZ; данный код работать не будет:

var some = "Hello, World!";
var someAsEnumerable = some.ToCharEnumerable();

Собственно, это и всё что можно сказать о методах расширения – для чего и как они используются, однако C# это очень богатый язык программирования и у него есть еще пара особенностей, которые могут нам помочь при имплементации изменений в нашем коде.

Создавая новый или изменяя существующий код, мы используем классы, чтобы придать некую форму нашим данным, а также добавить некоторую функциональность работы с данными путем добавления в код класса различных методов, событий и специализированных конструкторов. О классах на моем канале снято хорошее видео – ссылка будет в правом верхнем углу.

Иногда может возникнуть ситуация, когда у нас есть определенный набор данных, которым необходимо придать определенную форму, например в виде класса. Однако из специфики логики следует, что данный, новый класс использовался бы только в одном месте в коде. То есть, по сути, мы должны будет создать класс только для удобства использования в одном месте в коде. А создание класса – это не такое уже и просто задание. Давайте подумаем, что необходимо сделать чтобы создать класс:

  • определить публичные и приватные поля и свойства;
  • переопределить метод ToString() – для удобства логирования;
  • переопределить методы GetHashCode() / Equals() – для сравнения объектов на основании значений их свойств, а не ссылок на определенное место в памяти;

Всё это необходимо сделать для создания класса, который будет использован только в одно месте в коде. Это похоже на не очень оптимальное использование нашего рабочего времени, а также нашего времени в будущем, когда нам будет необходимо обслуживать данный класс. К счастью, в языке C# для такой ситуации есть решение. Представьте себе возможность создания класса, только на основании данных, которые мы можем разместить в объекте данного класса. Такая возможность уже есть, и она называется создание анонимных типов.

Предположим, у нас есть 3 переменные.

     double min = 0.5;
     double max = 1.44;
     string description = "Some description";

На основании логики приложения мы хотели бы объединить эти данные в класс, но данный класс будет использован только здесь и сейчас. То есть давайте создадим сейчас анонимный тип:

var anonymous = new { Min = min, Max = max, Description = description };

Переменная anonymous имеет анонимный тип и поэтому мы должны использовать здесь ключевое слово var. Компилятор во время компиляции автоматически создаст класс для переменной anonymous. О разнице между ключевыми словами var и dynamic на моем канале будет снято видео – не забывайте подписываться на канал, чтобы не пропустить обновления.

Как использовать анонимный тип – очень просто, как обычный тип. В этом плане объекты анонимных типов не представляют ничего особенного. В нашем конкретном случае это будет объект с тремя свойствами:

  Console.WriteLine(anonymous.Min); // выводит на экран 0.5
  Console.WriteLine(anonymous.Max); // выводит на экран 1.44  
  Console.WriteLine(anonymous.Description); // выводит на экран Some description

Интересно, что представляет из себя анонимный тип. Что происходит «под капотом», когда мы в коде создаем анонимный тип. Прежде всего в момент создания анонимного типа мы создаем класс с приватными полями и публичными свойствами только для чтения. Приватные поля содержат наши данные, а публичные свойства служат для доступа к этим полям.

Как и любой другой тип в языке C# анонимный тип наследует тип System.Object, то есть в нашем анонимном типе доступны стандартные 4 метода: ToString(), GetHashCode(), Equals(), GetType(). Рассмотрим каждый из этих методов анонимного типа:

  • ToString() выведет на экран следующую строку: { Min = 0,5, Max = 1,44, Description = Some description } – как видите метод ToString() выводит на экран строку, которая содержит названия свойств и их значения без дополнительного переопределения данного метода;
  • GetHashCode() – данный метод рассчитывает хэш код на основании названий свойств и их значений, то есть можно сделать вывод, что для двух анонимных типов с одинаковыми свойствами и одинаковыми значениями свойств будут возвращены одинаковые хэш коды.
  • Equals() – данный метод вернет значение True для двух анонимных типов с одинаковыми свойствами и одинаковыми значениями. Метод Equals анонимного типа основан на значениях и названиях свойств, однако оператор равенства «==» будет сравнивать ссылки и если объекты анонимного типа хранятся под разными адресами в памяти результат сравнения будет false, несмотря на равенство значений и названий свойств.
  • GetType() вернёт некую строку похожую на «f__AnonymousType0 3[System.Double,System.Double,System.String]». Стоит сказать, что у нас не будет доступа к названию анонимного типа и название которое мы получили при помощи метода GetType() автоматически сгенерировано компилятором. В любом случае название анонимного типа не влияет на нашу работу и как оно выглядит не должно нас волновать. Важно только сказать, что если бы создали еще один анонимный тип используя следующую строчку кода
var anonymous2 = new { Min = 2.0, Max = 10.5, Description = description };

то компилятор не создал бы новый анонимный тип а использовал существующий тип, так как тип переменной anonymous2 содержит тот же набор свойств (названия и типы), как и созданная ранее переменная anonymous.

Важно отметить, что в нашем примере мы использовали анонимный тип со свойствами типа double и string. Однако свойства могут быть другими ссылочными типами (типами классов) или даже другими анонимными типами – это не является ограничением. С другой стороны, ограничениями анонимного типа является отсутствие поддержки для событий, своих собственных методов, переопределений операторов. Также анонимные типы нельзя наследовать и в анонимном типе всегда определен конструктор без аргументов по умолчанию.

Прежде чем мы приступим к рассмотрению основной темы данного урока – выражения LINQ, мы должны рассмотреть еще одну конструкцию языка C#, которая часто используется при работе с выражениями LINQ.

Давайте вместе подумаем – из чего состоит декларация метода. Декларация метода содержит несколько обязательных элементов:

  • возвращаемый тип;
  • название метода;
  • перечень аргументов;

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

Аргументы для обработки => Операции над аргументами

В зависимости от ситуации аргументы могут указываться с типом либо без. Если тип аргумента не удается определить неявно, то компилятор сообщит об ошибке и тип аргумента необходимо будет указать явно.

Операции над аргументами — это тело метода. И как каждое тело метода, оно может быть как однострочное, так и многострочное. Ниже я приведу ниже несколько примеров выражений лямбда.

Выражение лямбда, однострочное с неявным типом аргумента:

i => i > 0;

Здесь у нас есть некое число i в качестве аргумента, в теле метода мы проверяем является значение i больше нуля или нет. В зависимости от этого условия мы возвращаем значение true и false.

Далее пример выражения лямбда с явным указанием типа аргумента:

(int i) => i > 0;

Этот пример ничем не отличается от предыдущего за исключением того, что мы указали явно тип аргумента выражения лямбда.

Данное выражение лямбда не имеет аргументов:

() => Console.WriteLine(Hellow, World!);

Ниже пример выражения лямбда с несколькими аргументами:

(x, y) => x + y;

Как уже было сказано в выражениях лямбда могут использоваться тело метода, состоящее из любого числа строчек. В этом случае следует помнить об использовании ключевого слова return, если есть необходимость возврата результата операции.

(int x, int y) => {
var result = x + y;
 Console.WriteLine($Result is {result});
 return result;
}

Давайте подведем промежуточный итог – мы узнали 3 новые конструкции:

  • методы расширения;
  • анонимные типы;
  • выражения лямбда.

Всё это нам поможет лучше понять выражения LINQ.

Очень часто при разработке приложений мы работаем с массивом данных в виде коллекций. Источниками таких данных могут быть базы данных, файлы, например файлы XML, а также коллекции в памяти компьютера, к которым обращается наше приложение. Типов источников данных на самом деле больше. Представим ситуацию, что для каждого типа нам необходимо разработать стандартные методы модификации данных (добавить, обновить, удалить, выбрать), методы фильтрования по определенному условию, а также базовые статистические методы, такие как максимум, минимум, средняя величина и количество элементов в коллекции. Если разрабатывать для каждого источника данных разрабатывать эти методы, то наш код может получиться громоздким и, возможно, сложным для обслуживания. К счастью, в среде программирования .Net есть пространство имён System.Linq, которое содержит выражения LINQ. Что из себя представляет LINQ? LINQ – это сокращение с английского языка «language integrated query». То есть «язык интегрированных запросов» - можно сказать, что это некий язык, который интегрирован в язык C# и который позволяет получать и модифицировать данные из различных источников стандартным образом независимо от физического характера источника данных. Технически все выражения LINQ является методами расширения, которые мы рассмотрели ранее. Возникает резонный вопрос, функциональность каких типов мы расширяем? Ответ на этот вопрос, следующий: всех типов, которые имплементируют интерфейс IEnumerable, то есть все типы, которые можно перечислять: массивы, списки, словари, различные коллекции. Если вы создадите список

var list = new List<int>();

и проверите какие методы расширения доступны для этого списка, то скорей всего вы увидите выражения LINQ например Aggregate, All, Any и так далее по алфавиту.

Если мы на секундочку подумаем какая логика и мотивация была у создателя выражений LINQ в корпорации Microsoft, то скорей всего мы придём к следующей логической цепочке:

  1. возьми коллекцию из источника данных (база данных, файл)
  2. приведи коллекцию из источника данных к интерфейсу IEnumerable
  3. напиши методы расширения для работы с коллекциями, имплементирующими интерфейс IEnumerable
  4. используй методы расширения из пункта 3 для коллекций из пункта 1 независимо от типа источника данных

Действительно, если мы возьмем несколько примеров, то обратим внимание, что возвращаемые коллекции, как правило являются коллекциями IEnumerable. Это касается баз данных при использовании Entity Framework или при чтении файлов – можно читать файлы построчно, и каждая строчка может быть элементов коллекции IEnumerable. То есть независимо от типа источника данных, мы получаем коллекцию IEnumerable с которой работаем посредством методов LINQ. То есть, по сути, нам не важен тип источника данных, а важно знание методов LINQ и то, что коллекция имплементирует метод IEnumerable. В этом является главное преимущество LINQ.

Я упомянул ранее Entity Framework. Это библиотека ORM – object-relational mapping, то есть объектно-реляционное преобразование: преобразование данных в базе данных в объекты классов C# и в обратном направлении: из объектов классов C# в строчки в таблицах баз данных. Возможно, я сниму видео на канале об этом фреймворке, так что не забудьте подписаться и нажать колокольчик, чтобы быть в курсе. В контексте Entity Framework следует помнить, что данная библиотека может возвращать данные, которые имплементируют интерфейс IEnumerable или IQueryable. На собеседованиях часто можно слышать вопрос какая разница между этими интерфейсами. Разница следующая:

  • если вы ожидаете результат вашего запроса как IQueryable, то логика вашего запроса будет выполнена в базе данных (LINQ-to-SQL) и вы получите уже готовый результат из базы.
  • если вы ожидаете результат вашего запроса как IEnumerable, то логика вашего запроса будет выполнена в коде (LINQ-to-objects): сначала вы получите все данные из базы, а потом отфильтруете нужные вам данные в коде и получите готовый результат.

По факту разница сводится к тому, где вы хотели бы выполнить логику вашего запроса – в базе (IQueryable) или в коде (IEnumerable). Вы можете явно это указать используя методы LINQ AsQueryable() и AsEnumerable().

Раз уж мы начали разговор о методах LINQ то давайте познакомимся с некоторыми из них поближе. Как правило методы LINQ имеют пару или несколько перегрузок и некоторые из перегрузок могут принимать делегаты с определенной сигнатурой. В качестве методов мы можем передавать готовые методы, которые имеет соответствующую сигнатуру, но практика показала, что лучше всего для методов – аргументов подходят выражения – лямбда с которыми мы уже знакомы. Например, чтобы получить из коллекции целых чисел четные числа мы можем использовать следующее выражение LINQ с методом в форме выражения - лямбда:

var list = new List<int>(new[] { 5,4,2,3,1});
var even = list.Where(i => i % 2 == 0); // 4, 2

Методы LINQ можно соединять между собой, например мы можем сортировать предыдущее выражение:

var orderedEven = list.Where(i => i % 2 == 0).OrderBy(i => i); // 2, 4

Обратите внимание – мы должны были передать в качестве аргумента метод, который выбирает значение, по которому сортировать. Так как у нас просто коллекция целых чисел, то мы берем число и сразу же его возвращаем. Затем мы могли бы выбрать только первый элемент данной коллекции:

var firstEven = list.Where(i => i % 2 == 0).OrderBy(i => i).First(); // 2

Согласитесь, что данные выражения немного напоминают SQL, однако механизмы, которые работают «под капотом» этих выражений – разные и не стоит их путать или сравнивать.
Синтаксис выражения LINQ показанный выше называется функциональным. Существует синтаксис, который называется синтаксисом запросов, который еще более похож на запросы SQL, однако как уже было сказано – между LINQ и SQL нет ничего общего. Ниже пример синтаксиса запроса LINQ:

var orderedEven = from i in list where i % 2 == 0 orderby i select i; // 2, 4

Этот запрос LINQ аналогичен следующему выражению LINQ:

var orderedEven = list.Where(i => i % 2 == 0).OrderBy(i => i); // 2, 4

Следует также помнить, что методы LINQ выполняются отложенным выполнением, то есть выражение orderedEven содержит выражение LINQ, а не результат его выполнения. В выражении orderedEven будет результат выражения, если мы попытаемся его материализовать, например перебирая значения этой коллекции в цикле foreach или преобразуя коллекцию IEnumerable в список или массив методами ToList() или ToArray(). Это важно помнить так как на собеседовании могут написать выражение LINQ, приписать его переменной и спросить – что находится в данной переменной.

Ранее мы познакомились с анонимными типами, которые очень хорошо сотрудничают с выражениями LINQ. Довольно часто во время создания выражения LINQ есть необходимость поместить несколько переменных в определенную схему. Как уже было сказано ранее – создавать для этой схемы класс было бы не разумным использованием наших ресурсов, однако всегда можно использовать анонимный тип. Предположим, у нас есть класс, который содержит статистические данные имя данных, минимум, максимум и среднее значение. У нас есть коллекция объектов этого класса и мы хотели бы выделить в отдельный тип только имя данных и соответствующее значение максимума. Следующее выражение LINQ поможет выполнить нам поставленную задачу:

class Statistic
    {
        public string Name { get; set; }
        public double Avg { get; set; }
        public double Min { get; set; }
        public double Max { get; set; }
    }
	
var list = new List<Statistic>(new[] {
                new Statistic(){
                    Name="Test A",
                    Avg = 23.4,
                    Min = 11.3,
                    Max = 25.6
                },
                new Statistic(){
                    Name="Test B",
                    Avg = 22.4,
                    Min = 9.3,
                    Max = 27.6
                }});

var maxData = list.Select(s => new {s.Name, s.Max });

После материализации последнего выражения LINQ, в переменной maxData будет коллекция из 2х объектов анонимного типа, которые будут содержать 2 свойства: Name и Max. Коллекция состоит из 2 объектов, потому что исходная коллекция в переменной list содержит 2 элемента. В качестве аргумента метода расширения Select мы передали выражение лямбда которое принимает аргумент s. Аргумент s имеет неявный тип Statistic и у нас есть доступ ко всем его свойствам. Для нашего анонимного типа мы используем только 2 свойства класса Statistic: Name и Max.