Прошлые части статьи:
Часть 1
Часть 2

Предисловие

У меня были несколько иные планы на часть 3, но я понял, что читатель узнает много основ C# и теоретического материала, но реальной практики не так много. Поэтому мой изначальный план на эту статью пришлось изменить. Вектор значительно сместился на более простые, но крайне важные вещи, вроде работы с файлами, процессами и т.д., а уже в них раскрою многое из того, что хотел изначально. Ну и, чтобы не запутывать читателя, я решил сделать новую нумерацию глав. Надеюсь, вам понравится!

Раздел 1. Работаем с файловой системой

До этого наши примеры ограничивались вводом и выводом в консоль, но это было только начало! В пространстве имен System.IO (напоминаю, что IO - это аббревиатура Input Output - ввод вывод) находятся различные классы, позволяющие работать с файлами, папками и прочим содержимым файловых систем операционной системы.

Глава 1. Файлы

Статический класс File содержит методы для работы с файлами, как несложно догадаться.

File.ReadAllText - позволяет прочесть любой текстовый файл, путь к которому вы укажете в качестве аргумента. Результатом будет строка.

Предлагаю создать такой файл для экспериментов или же взять уже существующий. Я создал на рабочем столе текстовый файл с названием "Пример" и заполнил его случайным текстом.

💡В Windows 10+ если зажать Shift и нажать правой кнопкой мыши по файлу, то можно увидеть пункт меню "Копировать как путь". Этот способ удобен тем, что полный путь уже будет в двойных кавычках, поэтому его сразу можно использовать как строку в коде, добавив в начало собаку (@). Но можно просто экранировать обратные слеши в путях, чтобы строка не пыталась считать последовательности вроде \n и \t.

string text = File.ReadAllText(@"C:\Users\User\OneDrive\Desktop\Пример.txt");
Console.WriteLine("Содержимое файла: " + text);
Console.ReadLine(); // Оставьте, если консоль сразу закрывается! Дальше напоминать не буду

А иногда хочется прочитать файл построчно, в таком случае у нас тоже есть нужный метод - ReadAllLines, который вернет массив строк, где каждый элемент представляет строку файла.

string[] lines = File.ReadAllLines(@"C:\Users\User\OneDrive\Desktop\Пример.txt");

Console.WriteLine("Содержимое файла: ");

for (int i = 0; i < lines.Length; i++)
{
   Console.WriteLine(lines[i]);
}

В моем файле было аж три строки, что я и увидел в консоли.

💡Если кодировка файла отличается от кодировки консоли, вы можете увидеть "кракозябры" вместо читаемого текста. Именно поэтому вторым аргументом можно передать System.Text.Encoding (кодировку). Кстати, эта проблема ничем не отличается от обычной истории с сохранением текстовых файлов в блокноте не в той кодировке. Используйте кодировки юникода Encoding.UTF8 или Encoding.Unicode при возникновении проблем.

File.ReadAllBytes позволяет получить все содержимое файла в байтах, поэтому и возвращает он не строку или массив строк, а массив байт. Но пока он нам не интересен.

А что если попытаться передать в метод путь к файлу, которого нет? Будет исключение. Конечно, можно обернуть код в try-catch, но тут очень кстати приходится метод Exists.

File.Exists проверяет наличие файла по указанному пути. Если файл есть - вернет истину, нет - ложь. Очень многих ошибок можно избежать такой простой проверкой.

if (!File.Exists(@"C:\Users\User\OneDrive\Desktop\Пример.txt"))
{
   Console.WriteLine("Файл не найден");
   return; // завершаем работу
}

File.Create позволяет создать файл. Результатом этого метода будет FileStream, но потоки мы рассмотрим немного позже, однако, вы ничего не теряете, потому что способов создать файл в C# уйма!

File.WriteAllText позволяет выполнить запись в текстовый файл. Именно его мы и будем использовать для создания нового файла с нужным нам содержимым. Первым аргументом является путь к нужному файлу, а вторым - текст, который будет записан.

Напишем метод, который будет создавать файл с текстом, который мы введём в консоль:

string text = Console.ReadLine();
File.WriteAllText("temp.txt", text);

💡Обратите внимание на то, что путь к файлу не является абсолютным путем, поэтому искать этот файл нужно будет возле исполняемого файла нашей программы. В моем случае это путь к проекту + \bin\Debug\net10.0. Где Debug - название конфигурации, а net10.0 - целевая среда сборки.

File.WriteAllLines позволяет выполнить запись строк в текстовый файл.

File.WriteAllBytes позволяет выполнить запись байтов в файл.

У всех Write-методов есть Append альтернативы, которые нужны для добавления данных в файл, а не перезаписи.

Помимо этих методов вы можете найти и другие, многие из которых вы можете понимать интуитивно по их названию, например: Copy - копирует файл в нужную папку, Move - перемещает в нужную директорию, Delete - удаляет, GetCreationTime - позволяет узнать дату создания файла. Это все потому, что все эти действия вы и так делаете, но мышкой или клавиатурой.

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

Глава 2. Папки

Для работы с папками в стандартной библиотеке есть класс Directory, очень похожий на класс File из прошлой главы.

Directory.Exists позволяет узнать, существует ли папка. Думаю, после File это уже не кажется сложным. Давайте проверим наличие папки UserData по относительному пути:

if (!Directory.Exists("UserData"))
{
   Console.WriteLine("Такой папки нет!");
}

Ожидаемо, такой папки там не было, поэтому вы увидели сообщение.

А вот чтобы создать папку нужно воспользоваться методом Create. Дополним пример, чтобы папка создавалась, если её не было:

if (!Directory.Exists("UserData"))
{
    Directory.CreateDirectory("UserData");
}
else
{
    Console.WriteLine("Папка UserData уже создана!");
}

Можете проверить результат.

Чтобы получить файлы, которые есть в папке, нужно использовать метод GetFiles, который принимает путь к папке и возвращает пути файлов. Для примера возьмем какую-нибудь папку, в которой уже точно есть файлы. Я возьму стандартную папку "Изображения" (у меня путь получился C:\Users\User\OneDrive\Изображения).Выведем ее содержимое:

foreach (var file in Directory.GetFiles(@"C:\Users\User\OneDrive\Изображения"))
{
   Console.WriteLine(file);
}

В консоль выведутся полные пути к этим файлам.

Так же легко сделать, чтобы поиск выполнялся только по файлам определенного типа, используя достаточно привычную для Windows систем паттерн поиска:

"*.*" - любой файл с любым расширением

"*.txt" - любой файл с расширением txt

Например, ищем только png файлы:

foreach (var file in Directory.GetFiles(@"C:\Users\User\OneDrive\Изображения", "*.png"))
{
   Console.WriteLine(file);
}

Но если в папке есть другие папки, они не будут участвовать в поиске файлов. К счастью, есть удобная перегрузка, принимающая перечисление SearchOption:

SearchOption.TopDirectoryOnly - игнорировать вложенные папки.

SearchOption.AllDirectories - искать во вложенных папках.

Во втором случае наш супер поиск сможет найти нужные файлы даже если структура папок имеет много вложений!

foreach (var file in Directory.GetFiles(@"C:\Users\User\OneDrive\Изображения", "*.png", SearchOption.AllDirectories))
{
   Console.WriteLine(file);
}

Для папок так же существуют методы Move, Delete, GetCreationTime, как и для файлов.

✍️Задание: помня о наличии метода File.Move, попробуйте самостоятельно переместить файлы из одной папки в другую.

Глава 3. Пути

До этого момента я сознательно игнорировал очень важную вещь при работе с файлами - коварство работы с путями. Я уже говорил, что пути бывают относительные и абсолютные, из-за чего тоже нередко случаются ошибки, но пути еще могут различаться в Windows и Linux, в общем, проблем здесь целый вагон…

Статический класс Path отвечает за работу с путями. И я вам искренне рекомендую пользоваться им всегда, когда вы будете работать с путями!

Вернемся к нашей папке UserData из прошлой главы, в которой мы хотим хранить некие настройки. Как правильно получить путь для будущего файла настроек?

string settingsPath = @"UserData\settings.txt";

Плохо, потому что неуниверсально, а именно привязано к Windows.

И тут нам помогает Path.Combine:

string settingsPath = Path.Combine("UserData", "settings.txt");

Хорошо, потому что будет работать везде. Можно ли сделать этот код лучше? Да, можно! Например, сделать путь абсолютным, чтобы гарантировать, что он будет одинаков вне зависимости от того, где вызван.

string settingsPath = Path.GetFullPath(Path.Combine("UserData", "settings.txt"));

Да, GetFullPath превращает любой путь в абсолютный.

Теперь вернемся к нашей идеи настроек. Пусть в текстовом файле хранятся только имя игрока и его очки. 

player: имяИгрока
score: очкиИгрока

В начале мы проверим наличие файла с настройками, если его нет - предложим ввести имя и дадим установим результат в 0. Если же такой файл есть, мы возьмем с него эти значения.

string player;
int score;

string settingsPath = Path.GetFullPath(Path.Combine("UserData", "settings.txt"));

if (File.Exists(settingsPath))
{
   string[] settingsLine = File.ReadAllLines(settingsPath);
   player = settingsLine[0].Replace("player: ", "").Trim();
   score = int.Parse(settingsLine[1].Replace("score: ", "").Trim());
}
else
{
   score = 0;
   Console.Write("Введи свое имя: ");
   player = Console.ReadLine();
   File.WriteAllLines(
      settingsPath,
      ["player: " + player, "score: " + score]
   );
}

Console.WriteLine("Привет, " + player + "! Твои очки: " + score);

Напоминаю, что конструкция в квадратных скобках - это новый синтаксис массивов, доступный в C# 12+. Вы легко можете упростить этот фрагмент, чтобы код работал даже в более старых версиях:

   string[] playerData = new string[] { "player: " + player,  "score: " + score };
   File.WriteAllLines(
      settingsPath,
      playerData
   );

Это очень простой и даже наивный пример, потому что в нем нет никаких проверок на корректность данных, но он хорошо отражает суть. Вы можете сами отредактировать данные в файле настроек и проверить вывод в консоли. Примерно так, если упрощать, работают сохранения в играх.

А что еще умеет Path?

Path.GetTempPath - возвращает путь к папке Temp (хранилище временных файлов).

Path.Exists - умеет проверить существование пути. Ему не важно, находится ли по этому пути файл или папка.

Path.GetExtension - возвращает расширение файла (вместе с точкой).

Path.GetDirectoryName - возвращает имя папки, указанной в пути.

✍️Задание: попробуйте создать файл во временном хранилище (Temp), запишите в него какие-нибудь данные и проверьте наличие этого файла по его пути в проводнике

Глава 4. Окружение

Статический класс Environment не принадлежит пространству имен System.IO, но содержит очень много важных для разработчика данных о том, где запущена программа, поэтому мы обязаны его рассмотреть в рамках первой части этого гайда.

До этого мы вручную вводили нужные папки, вроде Изображений, чтобы потренироваться, но было бы неплохо использовать универсальные способы для этого, не так ли?

Метод Environment.GetFolderPath принимает элемент перечисления Environment.SpecialFolder и возвращает соответствующий путь:

ApplicationData - данные приложений

Cookies - куки браузера

Desktop - рабочий стол

Favorites - избранное

InternetCache - кеш бразуера

ProgramFiles  - системная папка Program Files

MyMusic - музыка

MyPictures - изображения

MyVideos - видео

Startup - автозапуск

И это только часть, поверьте!

💡В Linux по возможности будут подобраны аналогичные папки, например, для CommonApplicationData это /usr/share и т.д.

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

string desktop = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
Console.WriteLine("Путь к рабочему столу: " + desktop);

Примерно таким образом, зная путь к рабочему столу игры и программы создают там свои ярлыки. А зная путь к автозапуску, туда добавляются ярлыки программ вроде Stream.

Интересного в классе Environment очень много, но приведу лишь несколько свойств и методов:

Свойство Enviroment.GetLogicalDrives возвращает массив логических дисков (C:\, D:\ и прочие).

Свойство Environment.UserName позволяет узнать имя текущего пользователя.

Свойство Environment.CurrentDirectory позволяет узнать путь, из которого запущена программа.

Глава 5. Комфортная работа с файлами и папками

Подход с парой статических классов File и Directory не всегда удобен. К счастью, у них есть объектно-ориентированная альтернатива - классы FileInfo и DirectoryInfo, при этом методы похожи на то, что было в их статических аналогах.

Конструктор DirectoryInfo требует указания пути к папке. Для примера создадим DirectoryInfo, который будет предоставлять информацию о папке Документы.

var docInfo = new DirectoryInfo(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments));

Чтобы вывести данные о всех файлах можно использовать метод GetFiles, только он будет возвращать не массив строк-путей, как в классе Directory, а массив экземпляров FileInfo, содержащих данные об этих файлах. Выведем свойство FullName полученных файлов, чтобы увидеть их полный путь с именем:

var docInfo = new DirectoryInfo(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments));

foreach (var item in docInfo.GetFiles())
{
   Console.WriteLine("Файл " + item.FullName);
}

Через метод GetDirectories можно получить еще и о папках:

var docInfo = new DirectoryInfo(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments));

foreach (var item in docInfo.GetFiles())
{
   Console.WriteLine("Файл " + item.FullName);
}
foreach (var item in docInfo.GetDirectories())
{
   Console.WriteLine("Папка " + item.FullName);
}

Во втором случае возвращается массив DirectoryInfo. Но пример можно сделать изящнее, за счет полиморфизма и сопоставления шаблонов, о которым мы говорили в прошлом гайде. А все дело в том, что есть метод GetFileSystemInfos, который возвращает массив экземпляров базового и для FileInfo, и для DirectoryInfo класс.

var docInfo = new DirectoryInfo(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments));

foreach (var fsInfo in docInfo.GetFileSystemInfos())
{
   if (fsInfo is FileInfo fileInfo)
   {
      Console.WriteLine("Файл " + fileInfo.FullName);
   }
   else if (fsInfo is DirectoryInfo dirInfo)
   {
      Console.WriteLine("Папка " + dirInfo.FullName);
   }
}

Так мы выполнили поиск файлов и папок всего одним методом!

В общем, основная мысль проста: FileInfo и DirectoryInfo позволяют работать с объектами, а не путями, что более наглядно для разработчика. Хотя по смыслу это все одно и то же.

Задание: поэкспериментируйте самостоятельно, выполняя аналогичные операции с FileInfo и DirectoryInfo, какие мы выполняли в File и Directory.

Раздел 2. Работаем в потоке

Потоки (streams) - более абстрактное и универсальное представление данных, чем просто файлы. Читать или записывать поток можно частями, что часто является необходимым, как в случае с большими файлами, а еще их можно передавать в другие части программы. Многие классы стандартной библиотеки C# как раз принимают или предоставляют качестве результата выполнения потоки. 

Представьте сессию игры в Dota 2 или мультиплеер в WarCraft 3, да и в любой игре по большому счету. Клиент читает поток данных с сервера и отображает для игрока соответствующую картинку на игровом поле. Не забывает клиент и сам отправлять поток данных о вводе пользователя - различные клики мышью, нажатия клавиш и т.д. Вот самое простое и очень поверхностное описание того, что из себя представляют потоки.

💡Кстати, слово поток имеет еще одно значение. В данном случае мы говорим о потоке как о потоке данных (stream, file stream и т.д.). Но есть еще и потоки в контексте многопоточного программирования, или потоки выполнения - threads, но их мы затрагивать не будем.

Глава 6. Немного об освобождении ресурсов

Потоки, процессы (о них позже) и некоторые другие сущности из мира операционных систем являются гостями в безопасной среде C# (CLR) и требуют освобождения своих ресурсов явным образом. Если этого не сделать, то они так и будут висеть в памяти. К счастью, у нас очень удобный оператор using, который сам освободит ресурс.

Раньше разработчики писали так

using (var ресурс = new Тип())
{
   // в этом блоке работаем с ресурсом
}
// и сразу освобождаем его при выходе из блока

В новых версиях C# стало еще проще

using var ресурс = new Тип();
// ресурс будет освобождён автоматически

Локальная переменная, объявленная через using, удаляется в конце области видимости, в которой она объявлена.

💡С точки зрения языка блоки using превращаются в конструкции try-catch-finally, где вызывается специальный метод Dispose, в котором обычно содержится логика освобождения ресурсов. Пока просто имейте это в виду, сам интерфейс IDisposable, предоставляющий метод Dispose, будет рассмотрен в другом руководстве.

Вот с этим знанием и переходим к потокам!

Глава 7. StreamReader

System.IO.StreamReader позволяет, как и следует из названия, читать потоки. Одно из самых популярных его применений - читать файлы.

Например, прочитать файл построчно совсем просто:

using var reader = new StreamReader("путь_к_файлу.txt");

while (!reader.EndOfStream)
{
   Console.WriteLine(reader.ReadLine());
}

При этом мы не пытаемся сохранить весь файл в памяти, как в случае с File.ReadAllLines, а смещаем указатель потока на одну строчку после каждого чтения, а свойством EndOfStream проверяем, не достигли ли мы конца файла.

💡Кстати, вторым аргументом конструктора можно передать Encoding, если нужно читать файл в конкретной кодировке.

Ничто не мешает прочитать файл целиком, а не построчно: 

using var reader = new StreamReader("путь_к_файлу.txt");
Console.WriteLine(reader.ReadToEnd());

Ну и StreamReader не имел бы в названии слово Stream, если бы годился только для чтения файлов. Вы можете передать в конструктор Stream вместо пути к файлу. Но про это будет позже!

Глава 8. StreamWriter

System.IO.StreamWriter выполняет противоположную, относительно StreamReader, роль - запись в поток.

Попробуем записать в файл то, что введем в консоль:

using var writer = new StreamWriter("путь_к_файлу.txt");
writer.WriteLine(Console.ReadLine()); 

По умолчанию файл будет перезаписываться при выполнении любых методов записи, но конструктор StreamWriter содержит специальную перегрузку с аргументом append, отвечающий за то, нужно ли добавлять текст к существующему (если истина) или перезаписывать (если ложь). Повторим тот же пример, но не будем перезаписывать файл:

using var writer = new StreamWriter("путь_к_файлу.txt", true);
writer.WriteLine(Console.ReadLine()); 

💡StreamWriter по умолчанию буферизует запись, а не выполняет ее сразу. С одной стороны это оптимально для ресурсов системы (нагрузка на диск ниже), но с другой - при аварийном завершении работы запись может просто не успеть создаться. В некоторых случаях такое поведение может быть нежелательно. Поэтому мы можем вызывать метод Flush вручную, чтобы вызывать принудительную запись из буфера.

foreach (var str in ["player", "enemy", "NPC", "ward"])
{
   writer.WriteLine(str);
   writer.Flush();
}

Или просто установить свойство AutoFlush в true.

Помимо записи текстовых данных методы Write и WriteLine имеют множество перегрузок для записи булевых, числовых и символьных данных. Обратите внимание, что в конечном итоге они будут записаны в своем строковом представлении. Например, булево значение true имеет строковое представление "True".

Глава 9. FileStream и BinaryWriter

А как насчет записи не строковых, а бинарных данных? Я уже упоминал, что метод File.Create возвращает FileStream, который является реализацией класса Stream, а значит мы можем передавать его в StreamReader, StreamWriter и другие похожие классы. Правда, до этого момента такой нужды не было, но тут мы плавно переходим к BinaryWriter.

Конструктор BinaryWriter принимает поток, а не путь к файлу. Попробуем использовать File.Create для создания файлового потока, в который мы запишем какие-нибудь случайные байты.

using var fs = File.Create("test.bin");
using var writer = new BinaryWriter(fs);
byte[] bytes = [1, 0, 255, 0, 1];
writer.Write(bytes);
writer.Flush();

При попытке открыть файл в блокноте, ожидаемо получается абракадабра.

В блокноте
В блокноте

Но стоит открыть файл в хекс-редакторе (я выбрал ImHex), сразу становится видно наши байты.

В ImHex
В ImHex

💡Hex-редаторы показывают байты в более удобной шестнадцатеричной (hexadecimal) системе счисления, чтобы байты умещались в два символа, вместо трех. Поэтому числу 255 соответствует FF.

Помимо массива байт можно передавать булевые, числовые, строковые данные, но они будут представлены именно своим байтовым, а не строковым представлением.

Многие файлы представляют из себя именно бинарные, а не текстовые данные. Это архивы, всевозможные кеши, дампы памяти и многое-многое другое. По своей природе они должны занимать меньше места и быть понятными программе, а не человеку, поэтому не все можно хранить в текстовом виде.

Глава 10. Пишем свой логгер

Какой программист не писал своих логгеров? Если вы не писали - сейчас самое время. И пусть опытные программисты всегда будут говорить, мол, незачем строить свои велосипеды, если все придумано за нас, мы все же будем воспринимать это как хороший практический урок, чтобы хо��ь немного получше разобраться, как оно все работает в больших и серьезных программах.

А что это вообще такое? Многие программы и даже игры пишут свои логи. Часто если гуглишь какую-то проблему, вроде "не запускается Steam" и т.д., обязательно среди вариантов будет что-то вроде "нужно посмотреть логи в такой-то папке". Так вот, логи - это такая сводка о состоянии программы, в самом простом смысле это текст, в котором есть дата и сообщение.

Допущения:

  • Работаем только с текущей датой

  • Храним логи только как текстовые файлы

  • Будем использовать только два уровня логирования - debug и error

  • Не думаем о потокобезопасности

Уровень Debug будет использоваться для "ручных" сообщений, когда все в порядке, а Error - для ошибок. В настоящих логгерах обычно еще уровни Trace, Info, Warning, Critical, но сейчас это может скорее запутать.

Пишем класс:

class Logger
{
   private readonly StreamWriter _writer;

   public Logger()
   {
      string dateStr = DateTime.Now.ToString("dd-MM-yy");
      Directory.CreateDirectory("Logs");
      writer = new StreamWriter(Path.Combine("Logs", $"log{dateStr}.txt"));
   }

   public void LogDebug(string message)
   {
      string nowStr = DateTime.Now.ToString("HH:mm:ss dd.MM.yyyy");
      _writer.WriteLine($"{nowStr} DEBUG {message}");
      _writer.Flush();
   }

   public void LogError(Exception ex, string message)
   {
      string nowStr = DateTime.Now.ToString("HH:mm:ss dd.MM.yyyy");
      _writer.WriteLine($"{nowStr} ERROR {message} {ex.Message}");
      _writer.Flush();
   }

   public void Close()
   {
      _writer?.Flush();
      _writer?.Dispose();
   }
}

Метод Close может показаться странным, но это самый простой способ освободить ресурсы StreamWriter, который, как вы помните, нужно освобождать. А т.к. using в полях использовать нельзя, то и делать это нам придется вручную. Оператор ?. лишь гарантирует, что мы не обратимся к переменной, которая уже равна null. 

🤠В будущем мы обязательно рассмотрим более правильный способ освобождения объектов через Dispose.

Как его использовать? Для этого напишем простую программу чтения файлов в консоли, закрепив еще и прошлые части в этой главе.

Примерный алгоритм: 

пользователь вводит путь к файлу или команду "выход"

если введена пустая строка

возвращаемся к первой команде

если введено "выход"

завершаем работу

иначе

пытаемся прочитать

если неудачно

выводим ошибку

возвращаемся к первой команде

Приведу весь код класса Program (напоминаю, что это стандартное название при создании проекта, но он может называться как угодно):

internal class Program
{
    private static Logger _logger = new Logger();

    static void Main()
    {
        _logger.LogDebug("Программа запущена");

        // работаем в бесконечном цикле
        while (true)
        {
            Console.WriteLine("Введите путь к файлу или \"выход\", чтобы закрыть программу");

            string? input = Console.ReadLine();
            // проверка на пустой вво��
            if (string.IsNullOrEmpty(input))
            {
                Console.WriteLine("Некорректная команда");
                _logger.LogDebug("Некорректная команда");
                continue;
            }
            // ввели "выход" - завершаем работу
            if (input.ToLowerInvariant() == "выход")
            {
                break;
            }
            try
            {
                _logger.LogDebug("Начинается чтение по пути: " + input);

                using var reader = new StreamReader(input);
                while (!reader.EndOfStream)
                {
                    Console.WriteLine(reader.ReadLine());
                }
              
                _logger.LogDebug("Чтение завершено");
            }
            catch (Exception ex)
            {
                Console.WriteLine("Ошибка при попытке чтения файла");
                _logger.LogError(ex, "Ошибка при попытке чтения файла");
            }
        }

        _logger.LogDebug("Завершение работы");
        _logger.Close();
    }
}

Объясняю все моменты, хотя в целом вы их уже знаете:

  1. Поле _logger статическое, чтобы статический метод Main мог его увидеть.

  2. try-catch позволяет перехватить исключение, нам это нужно, чтобы работа программы не завершалась, а лишь выводилось сообщение об ошибке в лог, и дальше все снова по кругу.

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

Попробуйте вводить пути к текстовым файлам или просто случайные символы, а потом посмотрите на полученный лог в папке Logs. Поздравляю, теперь вы знаете, как пишутся логи!

Раздел 3. Процессы и немного магии

И в Windows, и в Linux есть программы, позволяющие просматривать активные процессы - Диспетчер задач и Системный монитор соответственно. Думаю, вы имели с ними дело, но кратко напомню, что процессы - это, проще говоря, запущенные программы. Иногда одна программа может иметь много процессов, как, например, браузеры, где каждый процесс часто отвечает за отдельную вкладку. 

Глава 11. Первое знакомство с процессами

Класс System.Diagnostics.Process представляет процесс и позволяет работать с ними примерно так же, как это делают системные приложения. 

Для примера выведем все запущенные процессы через статический метод GetProcesses, возвращающий массив процессов.

foreach (var proc in Process.GetProcesses())
{
   Console.WriteLine(proc.ProcessName);
}

В результате вы увидите имена процессов в консоли!

Собственно, свойство ProcessName возвращает имя процесса. Но есть и другие важные свойства:

Id - идентификатор процесса.
BasePriority - приоритет, с которым был запущен процесс.
MainWindowTitle - текст заголовка главного окна процесса (может быть пуст!).
MainWindowHandle - дескриптор главного окна процесса (может быть пуст!).
WorkingSet64 - количество байт физическое памяти процесса.

💡Есть и другие, но не факт, что к ним получится получить доступ из-за защиты. Советую ставить эксперименты внутри блока try-catch, чтобы не получить исключение!

Дополним наш пример, чтобы почувствовать себя создателем диспетчера задач:

foreach (var proc in Process.GetProcesses())
{
   Console.WriteLine($"#{proc.Id} {proc.ProcessName} {proc.MainWindowTitle} {proc.MainWindowHandle} {proc.WorkingSet64} {proc.BasePriority}");
}

Глава 12. Запуск процессов, аргументы

А теперь научимся создавать и запускать собственные процессы. Вам было интересно, как другие программы делают это? Например, Steam запускает игры, когда вы выбираете их в библиотеке и нажимаете "Играть". Для примера мы будет запускать простейшие программы, вроде калькулятора Windows, но можете использовать любой другой.

Есть несколько способов запустить процесс.

Запуск через статический метод Start:

Process.Start("calc");

При запуске кода будет запущен калькулятор.

🐧Linux юзерам советую вызывать xcalc или любой другой процесс, который точно есть в вашем дистрибутиве.

💡Данный метод возвращает экземпляр процесса, который был запущен. К тому же, процессы являются ресурсами, требующими освобождения, поэтому в будущем будем использовать оператор using.

И через создание экземпляра класса Process:

using var proc = new Process();
proc.StartInfo = new ProcessStartInfo("calc");
proc.Start();

💡И в случае статического метода Start и в свойстве ProcessStartInfo можно передать имя процесса и (при желании) аргумент процесса. Зачем нужен аргумент? Хорошим примером является блокнот в Windows. Когда вы нажимаете на текстовый файл, запускается процесс блокнота, а его аргументом является путь к текстовому файлу. Так блокнот узнает, что ему нужно открыть конкретный файл.

Предлагаю закрепить эту информацию и запустить стандартный блокнот с нужным текстовым файлом:

using var proc = new Process();
proc.StartInfo = new ProcessStartInfo("notepad", @"C:\Users\User\OneDrive\Desktop\Пример.txt");
proc.Start();

Иногда нужно дождаться закрытия запущенного процесса. Для этого подходит метод WaitForExit. Дополним наш пример:

using var proc = new Process();
proc.StartInfo = new ProcessStartInfo("notepad", @"C:\Users\User\OneDrive\Desktop\Пример.txt");
proc.Start();
proc.WaitForExit();
Console.WriteLine("Программа была закрыта");

Интересно, что через процесс можно открывать и веб страницы, если использовать URL в качестве имени процесса. В таком случае будет открыт браузер по умолчанию.

Глава 13. Аргументы командной строки

Наша программа на C# точно так же может получать аргументы запуска. Для этого даже не нужно делать ничего особенного, достаточно лишь оставить аргумент метода Main в том виде, как его создает за нас IDE:

static void Main(string[] args)
{

}

Вспомнили? Так вот, массив args как раз и позволяет получить аргументы запуска программы. По умолчанию он пуст, но если попробовать, например, перетащить на исполняемый файл нашей программы какой-то другой файл, то программа запустится с путём к этому файлу в виде аргумента.

Чтобы проверить наличие аргументов, достаточно убедиться, что массив args имеет положительную длину.

static void Main(string[] args)
{
   if (args.Length < 1) return;

   Console.WriteLine("Первый аргумент: " + args[0]);
}

А как передаются несколько аргументов? Такого эффекта можно добиться, если разделять аргументы пробелом. В таком случае аргументы, имеющие в себе пробел, нужно брать в кавычки.

💡Кстати, подобным образом многие игры, вроде WarCraft 3, Dota 2, CS и т.д. позволяют задавать аргументы, которые определяют, будет ли игра запущена в оконном режиме, с OpenGL или DirectX и т.д.

Альтернативный способ получить аргументы запуска в любом месте программы - метод Environment.GetCommandLineArgs().

Глава 14. Что такое ExitCode?

Среди свойств экземпляра класса Process можно найти и ExitCode, показывающий с каким кодом завершился процесс. Но что это за такой код завершения?

Ответ на этот вопрос тоже потребует взглянуть на нашу программу с необычной стороны.

В C# мы привычно описываем точку входа как static void Main, однако, это не единственный вариант ее описания. Нам доступен ещё и вариант, который появился задолго до C#, и используется в языках C/C++ - целочисленная точка входа. Перепишем наш метод Main:

static int Main()
{
   Console.WriteLine("Hello World");
   return 0;
}

Наличие типа возвращаемого значения у Main обязывает возвращать какое-то значение. Так уж исторически сложилось, что при нормальных условиях программа должна возвращать 0. Да, это и есть тот самый ExitCode

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

Типичное сообщение о проблеме в работе программы. Скриншот из Интернета
Типичное сообщение о проблеме в работе программы. Скриншот из Интернета

Иногда в этом действительно есть смысл, и некоторые программы могут указывать кодом на конкретную ошибку.

Кстати, мы можем симулировать такое поведение даже в void Main, вернув нужное число через вызов Environment.Exit с нужным кодом выхода. Но помните, что вызов этого метода приводит к завершению работы программы.

Environment.Exit(123); // Завершим работу с ExitCode 123

Вызов метода без аргументов равносилен возврату нуля и тому же, что происходит, если завершается void Main.

Раздел 4. Делегаты и события

Глава 14. Делегаты

Сразу хочется спросить: что они делегируют? Все просто - работу с методами. Метод в C# нельзя просто так взять и передать куда-то, его можно просто вызвать. А делегат описывает сигнатуру метода, после чего переменной этого делегата можно присвоить любой метод подходящий по сигнатуре.

Например, мы можем сделать делегат Print, принимающий строку и ничего не возвращающий, для этого добавим в тело класса нашей программы строчку:

delegate void Print(string text);

Это очень похоже на описание метода, но без тела и со словом delegate в начале. Теперь в методе Main используем этот делегат, сохранив в него Console.WriteLine и сразу же попробуем вывести через него текст:

Print consolePrint = Console.WriteLine;
consolePrint("Hello, World!");

Почему это работает? Console.WriteLine имеет подходящую по сигнатуре перегрузку, принимающую string, поэтому именно она будет вызвана из переменной делегата.

Другой интересной возможностью является передача делегата в другие методы. Модифицируем наш пример, чтобы Print делегат вызывался из другого метода:

delegate void Print(string text);

static void Main()
{
   Print consolePrint = Console.WriteLine;
   SumAndPrint(5, 10, consolePrint);
}

static void SumAndPrint(int a, int b, Print print)
{
   print($"{a} + {b} = {a + b}");
}

Таким образом можно добиться огромной гибкости, например, вместо Console.WriteLine можно передать делегатом запись в лог или еще какое-нибудь действие. Например, реализуем метод выводящий цветной текст, используя свойство ForegroundColor консоли. Сначала мы сохраним текущий цвет, потом поменяем его на зеленый, ведем нужную строку, вернем цвет текста обратно.

static void PrintLineColored(string text)
{
   var oldColor = Console.ForegroundColor;
   Console.ForegroundColor = ConsoleColor.Green;
   Console.WriteLine(text);
   Console.ForegroundColor = oldColor;
}

Полный пример с использованием этого метода из делегата:

delegate void Print(string text);

static void Main()
{
   Print consolePrint = Console.WriteLine;
   Print coloredConsolePrint = PrintLineColored;
   SumAndPrint(5, 10, consolePrint);
   SumAndPrint(78, 13, coloredConsolePrint);
}

static void PrintLineColored(string text)
{
   var oldColor = Console.ForegroundColor;
   Console.ForegroundColor = ConsoleColor.Green;
   Console.WriteLine(text);
   Console.ForegroundColor = oldColor;
}

static void SumAndPrint(int a, int b, Print print)
{
   print($"{a} + {b} = {a + b}");
}

Метод PrintLineColored совпадает по сигнатуре с делегатом Print, поэтому его можно использовать в этом делегате.

Глава 15. События и их обработка

В мире консольных приложений события встречаются нечасто, но, тем не менее, это мощный и удобный механизм языка C#. Сначала рассмотрим пример, который уже предоставляет стандартная библиотека языка - класс ObservableCollection (из System.Collections.ObjectModel). Это коллекция примечательна тем, что "уведомляет" о своем изменении, например, при добавлении или удалении элементов.

Создадим такую коллекцию, которая будет представлять названия предметов в инвентаре:

var inventory = new ObservableCollection<string>();

Теперь самое интересное: подписка на событие CollectionChanged

💡Любая IDE, например Visual Studio, VS Code (с плагинами) или Rider, умеет упрощать работу с событиями C#. Выбрав событие из списка (обычно имеет значок молнии), нажмите пробел и напишите "+=", после чего вам предложат создать новый метод-подписчик. Иногда достаточно нажать Tab после "+=", чтобы автодополнение сработало само.

Автодополнение в Visual Studio
Автодополнение в Visual Studio

И добавим в метод-подписчик текст об изменении инвентаря:

private static void Inventory_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
   Console.WriteLine("Количество предметов в инвентаре изменилось!");
}

Теперь в Main после подписки добавим наши импровизированные предметы:

inventory.Add("Меч");
inventory.Add("Щит");

Весь код стал таким:

static void Main()
{
   var inventory = new ObservableCollection<string>();
   inventory.CollectionChanged += Inventory_CollectionChanged;
   inventory.Add("Меч");
   inventory.Add("Щит");
}

private static void Inventory_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
   Console.WriteLine("Количество предметов в инвентаре изменилось!");
}

После запуска вы увидите в консоли два сообщения об изменении инвентаря. Поздравляю, это ваша первая работа с событиями!

Теперь обо всем по порядку. Оператор "+=" означает подписку на событие. Обработчик события должен соответствовать делегату, связанному с событием CollectionChanged. Судя по методу обработчику этот делегат выглядит примерно так:

delegate void CollectionChangedDelegate(object? sender, NotifyCollectionChangedEventArgs e);

Первый аргумент, а именно object sender - это классический для событий C# аргумент-отправитель. По нему всегда можно понять, какой именно объект изменился. В нашем случае там лежит коллекция inventory. А сделано это для того, чтобы можно было одним методом обрабатывать события разных объектов. Круто!

Второй аргумент NotifyCollectionChangedEventArgs e - содержит уже дополнительную информацию о событии. В случае класса ObservableCollection, это информация о самом изменении: свойство Action позволяет узнать, был ли добавлен элемент, или же он был удален, а может произошла очистка коллекции. Свойство NewItems содержит коллекцию новых элементов, если было добавление. Свойство OldItems - старых, если было добавление.

Модифицируем код, чтобы у нас было два инвентаря, допустим, у героя Паладина и у героя Горного короля. Но чтобы их было видно в методе-обработчике, нужно сделать их статическими полями класса:

static ObservableCollection<string> _paladinInventory = new ObservableCollection<string>();
static ObservableCollection<string> _mountainKingInventory = new ObservableCollection<string>();

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

private static void Inventory_CollectionChanged(object ? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
    string inventoryName = sender == _paladinInventory ? "Инвентарь Паладина" : "Инвентарь Горного короля";
    string message;
    if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
    {
        message = "добавлено " + e.NewItems[0];
    }
    else
    {
        message = "удалено " + e.OldItems[0];
    }
    Console.WriteLine($ "{inventoryName}: {message}");
}

Теперь изменим метод Main, чтобы добавлять и удалять предметы обоих героев:

static void Main()
{
    paladinInventory.CollectionChanged += InventoryCollectionChanged;
    mountainKingInventory.CollectionChanged += InventoryCollectionChanged;
  
    // добавляем предметы
    _paladinInventory.Add("Зелье лечения");
    _mountainKingInventory.Add("Зелье маны");
    _paladinInventory.Add("Молот света");
    _mountainKingInventory.Add("Топор силы");
    // удаляем предметы
    _paladinInventory.Remove("Зелье лечения");
    _mountainKingInventory.Remove("Топор силы");
}

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

Глава 16. Кидаем собственные события

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

Пишем класс:

class Unit
{
    public delegate void UnitDamagedDelegate(Unit unit, int damage);
  
    public event UnitDamagedDelegate ? OnUnitDamaged;
  
    // Жизнь юнита, по умолчанию 100
    public int Life { get; private set; } = 100;
  
    public void TakeDamage(int damage)
    {
        this.Life -= damage;
        if (this.Life < 0) this.Life = 0;
        // Вызываем событие
        OnUnitDamaged?.Invoke(this, damage);
    }
}

Метод делегата Invoke принимает ровно те же аргументы, какие получит обработчик события, поэтому мы передаем туда текущего юнита (в sender) и полученный урон (в damage). 

💡Null-оператор (?.) нужен для того, чтобы Invoke не вызывался, если подписчиков события нет. Иначе можно получить NullReferenceException.

Попробуйте создать юнита и обработчик события.

static void Main()
{
    var unit = new Unit();
    unit.OnUnitDamaged += Unit_OnUnitDamaged;
}

private static void Unit_OnUnitDamaged(Unit unit, int damage)
{
    Console.WriteLine($ "Юнит получил {damage} ед. урона");
}

А теперь после подписки нанесите ему урон, чтобы увидеть сообщения в консоли.

Задание. Добавьте юниту строковое свойство Name, которое нужно задать в конструкторе. Выводите в событии получения урона имя юнита, получившего урон.

Глава 17. Анонимные методы

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

Action - используется для методов, которые ничего не возвращают (то есть типа void).
Func - для методов, которые что-то возвращают.
Predicate - для методов, которые возвращают булево значение.

Эти делегаты активно используются в стандартной библиотеке C#.

Следующий синтаксис называется лямбда-выражением:

(аргументы) => тело;

Напишем аналог нашего делегата Print из прошлых глав:

var print = (string text) => Console.WriteLine(text);
print("Привет!");

Да, вот так просто. Но если посмотреть, какой тип имеет print, то окажется, что это Action<string>.

Если анонимный метод должен возвращать какое-то значение, но помещается в одну строку, то return можно не писать, например:

var input = () => Console.ReadLine();
input();

Подразумевается, что Console.ReadLine() возвращается из метода. Input будет иметь тип Func<string>.

Можно и вовсе записать большое выражение в анонимном методе, но тогда придется использовать фигурные скобки.

var printColored = (string text) =>
{
    var oldColor = Console.ForegroundColor;
    Console.ForegroundColor = ConsoleColor.Green;
    Console.WriteLine(text);
    Console.ForegroundColor = oldColor;
};
printColored("Успешный вызов");

Угадаете тип printColored? Да, это Action<string>.

В следующей части анонимные методы нам очень сильно пригодятся!

Часть 5. LINQ

Скрытый текст

Тема объективно большая и не самая простая. Я дам вам старт, но для успешного понимания LINQ потребуется время и много практики!

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

Название LINQ - это аббревиатура от Language Integrated Query, то есть интегрированные в язык запросы. Это отражает суть: LINQ включает в себя методы по фильтрации, преобразованию, сортировке и т.д. Другой интересный момент, это то, что слово linq созвучно английскому link - "цепь", что отражает принцип работы с LINQ - цепочки связанных методов.

Глава 18. Базовые операции и преобразования

Подключаем пространство имен System.Linq, потому что все методы находятся именно там! Сейчас мы будем работать с небольшим массивом чисел от 1 до 5 включительно. Для наглядности, прямо из цикла будет вызывать методы LINQ.

Я бы выделил следующие базовые методы:

Select - преобразование элементов коллекции. 

Каждый элемент коллекции может быть преобразован в новый элемент.

[1, 2, 3, 4, 5].Select(x => x * 5).ToArray(); // [5, 10, 15, 20, 25]

💡Обратите внимание, что для одного аргумента можно не писать (x), а просто использовать x. И, да, название переменной в анонимном методе роли не играет.

Where - фильтрация коллекции.

Позволяет оставить в коллекции только те элементы, что удовлетворяют условие.

[1, 2, 3, 4, 5].Where(x => x % 2 == 0).ToArray(); // [2, 4]

OrderBy - сортировка по возрастанию.

[1, 2, 3, 4, 5].OrderBy().ToArray(); // [1, 2, 3, 4, 5] то есть то же самое

OrderByDescending - сортировка по убыванию

[1, 2, 3, 4, 5].OrderByDescending().ToArray(); // [5, 4, 3, 2, 1]

💡Но это с числами так очевидно, на самом деле в методы сортировки можно передавать фильтр, по которому они будут сортироваться. Об этом дальше.

Все примеры мы завершаем методом ToArray, чтобы в итоге получить массив.

Рассмотрим более интересный пример. Создадим класс предмета с базовой информацией: названием, ценой, уровнем.

class Item
{
    public string Title { get; set; }
    public int Price { get; set; }
    public int Level { get; set; }
  
    public Item(string title, int price, int level)
    {
        this.Title = title;
        this.Price = price;
        this.Level = level;
    }
}

Заполним список предметами для примера:

var items = new List < Item > ();
items.Add(new Item("Посох", 25, 2));
items.Add(new Item("Деревянный меч", 5, 1));
items.Add(new Item("Кинжал", 25, 2));
items.Add(new Item("Короткий меч", 25, 2));
items.Add(new Item("Топор", 50, 3));
items.Add(new Item("Молот", 100, 4));
items.Add(new Item("Длинный меч", 50, 3));

Попробуем отсортировать по цене от большего к меньшему и вывести:

var sortedItems = items.OrderByDescending(x => x.Price).ToArray();
foreach(var item in sortedItems)
{
    Console.WriteLine(item.Title);
}

А то же самое, но убрав предметы 1 уровня?

var sortedItems = items.Where(x => x.Level > 1).OrderByDescending(x => x.Price).ToArray();
foreach(var item in sortedItems)
{
    Console.WriteLine(item.Title);
}

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

GroupBy - группировка по ключу.

SelectMany - преобразование подколлекции в исходную коллекцию

Distinct - удаление повторяющихся элементов

ToList \ ToArray \ ToDictionary и некоторые другие - финальное преобразование коллекций

Глава 19. Получаем элементы коллекций

В LINQ есть множество удобных методов для поиска нужных значений в коллекции. Рассмотрим очень простой пример, когда у нас есть массив данных о пинге (задержке сети) в миллисекундах:

int[] pingsMs = [12, 79, 36, 100, 250, 1000, 90, 70, 65, 32, 14];

Методы First и Last позволяют получить первые и последние элементы последовательности:

Console.WriteLine("Первое значение: " + pingsMs.First());
Console.WriteLine("Последнее значение: " + pingsMs.Last());

В чем разница с обращением по индексу, например, [0] и [^1] - для начала и конца? Действительно, для массива разницы не будет, однако, не все последовательности позволяют так просто обратиться по индексу. Но куда важнее, что в First и Last можно передать фильтры, тогда будут найдены не просто первый и последний элементы, а именно первый и последний, удовлетворяющие условию фильтра! Например, мы не хотим рассматривать значения до 50 включительно:

Console.WriteLine("Первое значение: " + pingsMs.First(x => x > 50));
Console.WriteLine("Последнее значение: " + pingsMs.Last(x => x > 50));

Если же задать фильтр, которому не удовлетворяет ни один элемент, мы получим исключение.

Console.WriteLine("Первое большое значение: " + pingsMs.First(x => x > 5000));

У нас нет элементов больше 5000, поэтому нечего возвращать. В таком случае нужно использовать более безопасные варианты First и Last - FirstOrDefault и LastOrDefault соответственно

Console.WriteLine("Первое большое значение (если есть): " + pingsMs.FirstOrDefault(x => x > 5000));.

Для int таким значением является 0, но метод особенно критичен для ссылочных типов, чтобы сделать проверку на null.

💡Метод Single и его аналог SingleOrDefault - похожи на First, но требуется найти единственный элемент в коллекции, а не просто первый. Однако, будет получено исключение, если такой элемент не один в коллекции. В большинстве случае проще использовать FirstOrDefault.

Мы можем получить первые N элементов из коллекции через метод Take:

var arr = pingsMs.Take(5).ToArray(); // [12, 79, 36, 100, 250]

Если нужно сначала пропустить несколько элементов, чтобы взять 5 элементов, пропустив первые 2, то нужно использовать метод Skip:

var arr = pingsMs.Skip(2).Take(5).ToArray(); // [6, 100, 250, 1000, 90]

Если мы хотим выводить для пользователя некоторую информацию: минимальное значение, среднее значение и максимальное значение. В простом случае мы бы прошлись циклом и сами бы вычислили необходимые значения. Но LINQ уже предоставляет нужные методы: Min, Average и Max.

Console.WriteLine("Минимальное значение: " + pingsMs.Min());
Console.WriteLine("Среднее значение: " + pingsMs.Average());
Console.WriteLine("Максимальное значение: " + pingsMs.Max());

Обратите внимание, что во втором случае типом значения будет double. Так что, если вам не нужно видеть много цифр после запятой можно сделать форматирование. например, такое: 

// 2 цифры после запятой
Console.WriteLine("Среднее значение: " + pingsMs.Average().ToString("F2"));

Или же просто привести к int.

Какие еще методы существуют?

Sum - возвращает сумму всех элементов последовательности.

All - возвращает true, если все элементы удовлетворяют условию фильтра.

Any - возвращает true, если хотя бы один элемент удовлетворяет условию фильтра.

Глава 20. Как работает LINQ?

Для новичков синтаксис LINQ может немного взрывать мозг, но мы разберемся, что и как происходит на самом деле.

Магия LINQ достигается несколькими составляющими:

  1. IEnumerable<T> интерфейс

  2. Методы расширений

  3. Ленивые вычисления

Интерфейс IEnumerable, позволяющий помимо прочего перебирать коллекции через цикл foreach, является главным участником для LINQ методов. Т.к. и массивы, и списки, и словари, и многие-многие другие коллекции реализуют IEnumerable, для них всех доступен LINQ.

Проиллюстрирую наследование IEnumerable через класс, представляющий коллекцию названий серверов: 

class ServerCollection: IEnumerable<string>
{
    private readonly string[] Servers = ["Альфа", "Браво", "Чарли"];
  
    public IEnumerator<string> GetEnumerator()
    {
        return ((IEnumerable <string>)this.Servers).GetEnumerator();
    }
  
    IEnumerator IEnumerable.GetEnumerator()
    {
        return this.Servers.GetEnumerator();
    }
}

💡Интерфейс требует явного определения метода GetEnumerator для типизированного и нетипизированного случая.

Попробуйте пройтись по нему циклом и ожидаемо получите вывод названий всех трех серверов.

var servers = new ServerCollection();
foreach(var item in servers)
{
    Console.WriteLine(item);
}

Методы расширений (extension methods) обеспечивают этот знаменитый вид "цепочки вызовов" и позволяют применить их даже в тех коллекциях, где изначально никакого LINQ и не планировалось. Если обычные методы пишутся напрямую в типе, в котором они используются, то методы расширений пишутся в другом месте, но вызываются так, будто они родные для этого типа. Достаточно лишь подключить пространство имен System.Linq, чтобы методы стали предлагаться вашей средой разработки.

Классы расширений должны быть статическими классами. А методы расширений примечательны тем, что первым аргументом является тот объект, на котором вызывается метод расширения, но со словом this перед ним.

Попробуем написать такой класс расширений, предоставляющий самую простую реализацию Where и Select:

static class MyLinqEx
{
    public static IEnumerable<T> MyWhere<T>(this IEnumerable<T> source, Func<T, bool> filter)
    {
        var list = new List<T>();
        foreach(var item in source)
        {
            // вызываем filter с элементом и смотрим результат
            if (filter(item))
            {
                // если true - добавляем
                list.Add(item);
            }
        }
        return list;
    }
  
    public static IEnumerable<T> MySelect<T>(this IEnumerable<T> source, Func<T, T> map)
    {
        var list = new List<T>();
        foreach(var item in source)
        {
            // вызываем преобразование с элементом и кладем его в лист
            list.Add(map(item));
        }
        return list;
    }
}

💡Именно делегаты Action и Func позволяют так просто передавать анонимные методы прямо в месте использования LINQ методов!

Наконец, ленивые вычисления. Суть в том, что операции выполняются только тогда, когда мы пытаемся получить результат. Это методы вроде ToArray и ToList, а так же перебор циклом. До этого момента всего лишь строится древо выражений, но реальные действия по преобразованию коллекций не происходят. В этот раз обойдусь без примера, потому что он сильно выходит за рамки этого гайда.

Глава 21. Альтернативный синтаксис LINQ

Для общего развития вам следует знать, что помимо методов расширений LINQ доступен еще и в формате SQL-подобного языка, что выглядит удивительно, если ранее не работать с ним.

Синтаксис выборки можно описать так:

from имяПеременной in коллекция
select имяПеременной;

При этом where, orderby так же доступны в виде ключевых слов LINQ.

Для примера создадим класс игрока с именем, рейтингом и страной.

class Player
{
    public string Name { get; set; }
    public int Rating { get; set; }
    public string Country { get; set; }

    public Player(string name, int rating, string country)
    {
        Name = name;
        Rating = rating;
        Country = country;
    }
}

Заполним список некоторыми данными для теста:

var players = new List<Player> ();
players.Add(new Player("Sanya95", 3000, "RU"));
players.Add(new Player("IDDQD", 4000, "KZ"));
players.Add(new Player("Oleg25", 4000, "RU"));
players.Add(new Player("Gabe123", 2000, "US"));

А теперь попробуем сделать топ имён игроков по рейтингу:

var topPlayers = from p in players
                 orderby p.Rating descending
                 select p.Name;

Результатом будет IEnumerable<string>.

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

var players2 = new List<Player>();
players2.Add(new Player("Samurai79", 7000, "JP"));
players2.Add(new Player("Oleg25", 2500, "RU"));
players2.Add(new Player("ZXC", 5000, "CH"));

Теперь сделаем запрос для поиска общий игроков из двух списков.

var crossPlayers = from p1 in players
                   from p2 in players2
                   where p1.Name == p2.Name
                   select p1.Name;

💡На самом деле такие проверки обычно проверяют уникальный идентификатор игрока, но в нашем упрощенном примере мы работаем с именем.

А можно и объединить оба подхода вместе! 

var crossPlayers = (from p1 in players
                    from p2 in players2
                    where p1.Name == p2.Name
                    select p1.Name).ToArray();

Да, просто взяв выражение в скобки, мы можем произвести над ним действия, как если было это был любой другой IEnumerable.

Заключение

Итого, за эту часть мы рассмотрели работу с файловой системой, процессами, делегатами, событиями и LINQ.

В следующей (и уже финальной) части мы рассмотрим асинхронность, взаимодействие с сетью, работу с JSON, записи и другие важные возможности современного C#.