Любому приложению, создаваемому с помощью платформы .NET Core, потребуется решать вопросы поддержки и манипулирования набором значений данных в памяти. Значения данных могут поступать из множества местоположений, включая реляционную базу данных, локальный текстовый файл, XML-документ, вызов веб-службы, или через предоставляемый пользователем источник ввода.
В первом выпуске платформы .NET программисты часто применяли классы из пространства имен
System.Collections
для хранения и взаимодействия с элементами данных, используемыми внутри приложения. В версии .NET 2.0 язык программирования C# был расширен поддержкой средства под названием обобщения, и вместе с этим изменением в библиотеках базовых классов появилось новое пространство имен — System.Collections.Generic
.
В настоящей главе представлен обзор разнообразных пространств имен и типов коллекций (обобщенных и необобщенных), находящихся в библиотеках базовых классов .NET Core. Вы увидите, что обобщенные контейнеры часто превосходят свои необобщенные аналоги, поскольку они обычно обеспечивают лучшую безопасность в отношении типов и дают выигрыш в плане производительности. После того, как вы научитесь создавать и манипулировать обобщенными элементами внутри платформы, в оставшемся материале главы будет продемонстрировано создание собственных обобщенных методов и типов. Вы узнаете о роли ограничений (и соответствующего ключевого слова
where
языка С#), которые позволяют строить классы, в высшей степени безопасные в отношении типов.
Несомненно, самым элементарным контейнером, который допускается применять для хранения данных приложения, считается массив. В главе 4 вы узнали, что массив C# позволяет определить набор идентично типизированных элементов (в том числе массив элементов типа
System.Object
, по существу представляющий собой массив данных любых типов) с фиксированным верхним пределом. Кроме того, вспомните из главы 4, что все переменные массивов C# получают много функциональных возможностей от класса System.Array
. В качестве краткого напоминания взгляните на следующий код, который создает массив текстовых данных и манипулирует его содержимым разными способами:
// Создать массив строковых данных.
string[] strArray = {"First", "Second", "Third" };
// Отобразить количество элементов в массиве с помощью свойства Length.
Console.WriteLine("This array has {0} items.", strArray.Length);
Console.WriteLine();
// Отобразить содержимое массива, используя перечислитель.
foreach (string s in strArray)
{
Console.WriteLine("Array Entry: {0}", s);
}
Console.WriteLine();
// Обратить массив и снова вывести его содержимое.
Array.Reverse(strArray);
foreach (string s in strArray)
{
Console.WriteLine("Array Entry: {0}", s);
}
Console.ReadLine();
Хотя базовые массивы могут быть удобными для управления небольшими объемами данных фиксированного размера, есть немало случаев, когда требуются более гибкие структуры данных, такие как динамически расширяющийся и сокращающийся контейнер или контейнер, который может хранить только объекты, удовлетворяющие заданному критерию (например, объекты, производные от специфичного базового класса, или объекты, реализующие определенный интерфейс). Когда вы используете простой массив, всегда помните о том, что он был создан с "фиксированным размером". Если вы создали массив из трех элементов, то вы и получите только три элемента; следовательно, представленный далее код даст в результате исключение времени выполнения (конкретно —
IndexOutOfRangeException
):
// Создать массив строковых данных.
string[] strArray = { "First", "Second", "Third" };
// Попытка добавить новый элемент в конец массива?
// Ошибка во время выполнения!
strArray[3] = "new item?";
...
На заметку! На самом деле изменять размер массива можно с применением обобщенного метода
Resize()
. Однако такое действие приведет к копированию данных в новый объект массива и может оказаться неэффективным.
Чтобы помочь в преодолении ограничений простого массива, библиотеки базовых классов .NET Core поставляются с несколькими пространствами имен, которые содержат классы коллекций. В отличие от простого массива C# классы коллекций построены с возможностью динамического изменения своих размеров на лету по мере вставки либо удаления из них элементов. Более того, многие классы коллекций предлагают улучшенную безопасность в отношении типов и всерьез оптимизированы для обработки содержащихся внутри данных в манере, эффективной с точки зрения затрат памяти. В ходе чтения главы вы быстро заметите, что класс коллекции может принадлежать к одной из двух обширных категорий:
• необобщенные коллекции (в основном находящиеся в пространстве имен
System.Collections
);
• обобщенные коллекции (в основном находящиеся в пространстве имен
System.Collections.Generic
).
Необобщенные коллекции обычно спроектированы для оперирования типами
System.Object
и, следовательно, являются слабо типизированными контейнерами (тем не менее, некоторые необобщенные коллекции работают только со специфическим типом данных наподобие объектов string
). По контрасту обобщенные коллекции являются намного более безопасными в отношении типов, учитывая, что при создании вы должны указывать "вид типа" данных, которые они будут содержать. Как вы увидите, признаком любого обобщенного элемента является наличие "параметра типа", обозначаемого с помощью угловых скобок (например, List
). Детали обобщений (в том числе связанные с ними преимущества) будут исследоваться позже в этой главе. А сейчас давайте ознакомимся с некоторыми ключевыми типами необобщенных коллекций из пространств имен System.Collections
и System.Collections.Specialized
.
С самого первого выпуска платформы .NET программисты часто использовали классы необобщенных коллекций из пространства имен
System.Collecitons
, которое содержит набор классов, предназначенных для управления и организации крупных объемов данных в памяти. В табл. 10.1 документированы распространенные классы коллекций, определенные в этом пространстве имен, а также основные интерфейсы, которые они реализуют.
Интерфейсы, реализованные перечисленными в табл. 10.1 классами коллекций, позволяют проникнуть в суть их общей функциональности. В табл. 10.2 представлено описание общей природы основных интерфейсов, часть из которых кратко обсуждалась в главе 8.
Возможно, вы уже имеете начальный опыт применения (или реализации) некоторых из указанных выше классических структур данных, таких как стеки, очереди или списки. Если это не так, то при рассмотрении обобщенных аналогов таких структур позже в главе будут предоставлены дополнительные сведения об отличиях между ними. А пока что взгляните на пример кода, в котором используется объект
ArrayList
:
// Для доступа к ArrayList потребуется импортировать
// пространство имен System.Collections.
using System.Collections;
ArrayList strArray = new ArrayList();
strArray.AddRange(new string[] { "First", "Second", "Third" });
// Отобразить количество элементов в ArrayList.
System.Console.WriteLine("This collection has {0} items.", strArray.Count);
System.Console.WriteLine();
// Добавить новый элемент и отобразить текущее их количество.
strArray.Add("Fourth!");
System.Console.WriteLine("This collection has {0} items.", strArray.Count);
// Отобразить содержимое.
foreach (string s in strArray)
{
System.Console.WriteLine("Entry: {0}", s);
}
System.Console.WriteLine();
Обратите внимание, что вы можете добавлять (и удалять) элементы на лету, а контейнер автоматически будет соответствующим образом изменять свой размер.
Как вы могли догадаться, помимо свойства
Count
и методов AddRange()
и Add()
класс ArrayList
имеет много полезных членов, которые полностью описаны в документации по .NET Core. К слову, другие классы System.Collections
(Stack
, Queue
и т.д.) тоже подробно документированы в справочной системе .NET Core.
Однако важно отметить, что в большинстве ваших проектов .NET Core классы коллекций из пространства имен
System.Collections
, скорее всего, применяться не будут! В наши дни намного чаще используются их обобщенные аналоги, находящиеся в пространстве имен System.Collections.Generic
. С учетом сказанного остальные необобщенные классы из System.Collections
здесь не обсуждаются (и примеры работы с ними не приводятся).
System.Collections
— не единственное пространство имен .NET Core, которое содержит необобщенные классы коллекций. В пространстве имен System.Collections.Specialized
определено несколько специализированных типов коллекций. В табл. 10.3 описаны наиболее полезные типы в этом конкретном пространстве имен, которые все являются необобщенными.
Кроме указанных конкретных типов классов пространство имен
System.Collections.Specialized
также содержит много дополнительных интерфейсов и абстрактных базовых классов, которые можно применять в качестве стартовых точек для создания специальных классов коллекций. Хотя в ряде ситуаций такие "специализированные" типы могут оказаться именно тем, что требуется в ваших проектах, здесь они рассматриваться не будут. И снова во многих ситуациях вы с высокой вероятностью обнаружите, что пространство имен System.Collections.Generic
предлагает классы с похожей функциональностью, но с добавочными преимуществами.
На заметку! В библиотеках базовых классов .NET Core доступны два дополнительных пространства имен, связанные с коллекциями (
System.Collections.ObjectModel
и System.Collections.Concurrent
). Первое из них будет объясняться позже в главе, когда вы освоите тему обобщений. Пространство имен System.Collections.Concurrent
предоставляет классы коллекций, хорошо подходящие для многопоточной среды (многопоточность обсуждается в главе 15).
Хотя на протяжении многих лет с использованием необобщенных классов коллекций (и интерфейсов) было построено немало успешных приложений .NET и .NET Core, опыт показал, что применение этих типов может привести к возникновению ряда проблем.
Первая проблема заключается в том, что использование классов коллекций
System.Collections
и System.Collections.Specialized
в результате дает код с низкой производительностью, особенно в случае манипулирования числовыми данными (например, типами значений). Как вы вскоре увидите, когда структуры хранятся в любом необобщенном классе коллекции, прототипированном для оперирования с System.Object
, среда CoreCLR должна осуществлять некоторое количество операций перемещения в памяти, что может нанести ущерб скорости выполнения.
Вторая проблема связана с тем, что большинство необобщенных классов коллекций не являются безопасными в отношении типов, т.к. они были созданы для работы с
System.Object
и потому могут содержать в себе вообще все что угодно. Если разработчик нуждался в создании безопасной в отношении типов коллекции (скажем, контейнера, который способен хранить объекты, реализующие только определенный интерфейс), то единственным реальным вариантом было создание нового класса коллекции вручную. Хотя задача не отличалась высокой трудоемкостью, решать ее было несколько утомительно.
Прежде чем вы увидите, как применять обобщения в своих программах, полезно чуть глубже рассмотреть недостатки необобщенных классов коллекций, что поможет лучше понять проблемы, которые был призван решить механизм обобщений. Создайте новый проект консольного приложения по имени
IssuesWithNongenericCollections
, импортируйте пространства имен System
и System.Collections
в начале файла Program.cs
и удалите оставшийся код:
using System;
using System.Collections;
Как уже было указано в главе 4, платформа .NET Core поддерживает две обширные категории данных: типы значений и ссылочные типы. Поскольку в .NET Core определены две основные категории типов, временами возникает необходимость представить переменную одной категории как переменную другой категории. Для этого в C# предлагается простой механизм, называемый упаковкой (boxing), который позволяет хранить данные типа значения внутри ссылочной переменной. Предположим, что в методе по имени
SimpleBoxUnboxOperation()
создана локальная переменная типа int
. Если где-то в приложении понадобится представить такой тип значения как ссылочный тип, то значение придется упаковать:
static void SimpleBoxUnboxOperation()
{
// Создать переменную ValueType (int).
int myInt = 25;
// Упаковать int в ссылку на object.
object boxedInt = myInt;
}
Упаковку можно формально определить как процесс явного присваивания данных типа значения переменной
System.Object
. При упаковке значения среда CoreCLR размещает в куче новый объект и копирует в него величину типа значения (в данном случае 25). В качестве результата возвращается ссылка на вновь размещенный в куче объект.
Противоположная операция также разрешена и называется распаковкой (unboxing). Распаковка представляет собой процесс преобразования значения, хранящегося в объектной ссылке, обратно в соответствующий тип значения в стеке. Синтаксически операция распаковки выглядит как обычная операция приведения, но ее семантика несколько отличается. Среда CoreCLR начинает с проверки того, что полученный тип данных эквивалентен упакованному типу, и если это так, то копирует значение в переменную, находящуюся в стеке. Например, следующие операции распаковки работают успешно при условии, что лежащим в основе типом
boxedInt
действительно является int
:
static void SimpleBoxUnboxOperation()
{
// Создать переменную ValueType (int).
int myInt = 25;
// Упаковать int в ссылку на object.
object boxedInt = myInt;
// Распаковать ссылку обратно в int.
int unboxedInt = (int)boxedInt;
}
Когда компилятор C# встречает синтаксис упаковки/распаковки, он выпускает код CIL, который содержит коды операций
box/unbox
. Если вы просмотрите сборку с помощью утилиты ildasm.exe
, то обнаружите в ней показанный далее код CIL:
.method assembly hidebysig static
void '<$>g__SimpleBoxUnboxOperation|0_0'() cil managed
{
.maxstack 1
.locals init (int32 V_0, object V_1, int32 V_2)
IL_0000: nop
IL_0001: ldc.i4.s 25
IL_0003: stloc.0
IL_0004: ldloc.0
IL_0005: box [System.Runtime]System.Int32
IL_000a: stloc.1
IL_000b: ldloc.1
IL_000c: unbox.any [System.Runtime]System.Int32
IL_0011: stloc.2
IL_0012: ret
} // end of method '$'::'<$>g__SimpleBoxUnboxOperation|0_0'
Помните, что в отличие от обычного приведения распаковка обязана осуществляться только в подходящий тип данных. Попытка распаковать порцию данных в некорректный тип приводит к генерации исключения
InvalidCastException
. Для обеспечения высокой безопасности каждая операция распаковки должна быть помещена внутрь конструкции try/catch
, но такое действие со всеми операциями распаковки в приложении может оказаться достаточно трудоемкой задачей. Ниже показан измененный код, который выдаст ошибку из-за того, что в нем предпринята попытка распаковки упакованного значения int
в тип long
:
static void SimpleBoxUnboxOperation()
{
// Создать переменную ValueType (int).
int myInt = 25;
// Упаковать int в ссылку на object.
object boxedInt = myInt;
// Распаковать в неподходящий тип данных, чтобы
// инициировать исключение времени выполнения.
try
{
long unboxedLong = (long)boxedInt;
}
catch (InvalidCastException ex)
{
Console.WriteLine(ex.Message);
}
}
На первый взгляд упаковка/распаковка может показаться довольно непримечательным средством языка, с которым связан больше академический интерес, нежели практическая ценность. В конце концов, необходимость хранения локального типа значения в локальной переменной
object
будет возникать нечасто. Тем не менее, оказывается, что процесс упаковки/распаковки очень полезен, поскольку позволяет предполагать, что все можно трактовать как System.Object
, а среда CoreCLR самостоятельно позаботится о деталях, касающихся памяти.
Давайте обратимся к практическому применению описанных приемов. Мы будем исследовать класс
System.Collections.ArrayList
и использовать его для хранения порции числовых (расположенных в стеке) данных. Соответствующие члены класса ArrayList
перечислены ниже. Обратите внимание, что они прототипированы для работы с данными типа System.Object
. Теперь рассмотрим методы Add()
, Insert()
и Remove()
, а также индексатор класса:
public class ArrayList : IList, ICloneable
{
...
public virtual int Add(object? value);
public virtual void Insert(int index, object? value);
public virtual void Remove(object? obj);
public virtual object? this[int index] {get; set; }
}
Класс
ArrayList
был построен для оперирования с экземплярами object
, которые представляют данные, находящиеся в куче, поэтому может показаться странным, что следующий код компилируется и выполняется без ошибок:
static void WorkWithArrayList()
{
// Типы значений автоматически упаковываются при передаче
// методу, который требует экземпляр типа object.
ArrayList myInts = new ArrayList();
myInts.Add(10);
myInts.Add(20);
myInts.Add(35);
}
Хотя здесь числовые данные напрямую передаются методам, которые требуют экземпляров типа
object
, исполняющая среда выполняет автоматическую упаковку таких основанных на стеке данных. Когда позже понадобится извлечь элемент из ArrayList
с применением индексатора типа, находящийся в куче объект должен быть распакован в целочисленное значение, расположенное в стеке, посредством операции приведения. Не забывайте, что индексатор ArrayList
возвращает элементы типа System.Object
, а не System.Int32
:
static void WorkWithArrayList()
{
// Типы значений автоматически упаковываются,
// когда передаются члену, принимающему object.
ArrayList myInts = new ArrayList();
myInts.Add(10);
myInts.Add(20);
myInts.Add(35);
// Распаковка происходит, когда объект преобразуется
// обратно в данные, расположенные в стеке.
int i = (int)myInts[0];
// Теперь значение вновь упаковывается, т.к.
// метод WriteLine() требует типа object!
Console.WriteLine("Value of your int: {0}", i);
}
Обратите внимание, что расположенное в стеке значение типа
System.Int32
перед вызовом метода ArrayList.Add()
упаковывается, чтобы оно могло быть передано в требуемом виде System.Object
. Вдобавок объект System.Object
распаковывается обратно в System.Int32
после его извлечения из ArrayList
через операцию приведения лишь для того, чтобы снова быть упакованными при передаче методу Console.WriteLine()
, поскольку данный метод работает с типом System.Object
.
Упаковка и распаковка удобны с точки зрения программиста, но такой упрощенный подход к передаче данных между стеком и кучей влечет за собой проблемы, связанные с производительностью (снижение скорости выполнения и увеличение размера кода), а также приводит к утрате безопасности в отношении типов. Чтобы понять проблемы с производительностью, примите во внимание действия, которые должны произойти при упаковке и распаковке простого целочисленного значения.
1. Новый объект должен быть размещен в управляемой куче.
2. Значение данных, находящееся в стеке, должно быть передано в выделенное место в памяти.
3. При распаковке значение, которое хранится в объекте, находящемся в куче, должно быть передано обратно в стек.
4. Неиспользуемый в дальнейшем объект, расположенный в куче, будет (со временем) удален сборщиком мусора.
Несмотря на то что показанный конкретный метод
WorkWithArrayList()
не создает значительное узкое место в плане производительности, вы определенно заметите такое влияние, если ArrayList
будет содержать тысячи целочисленных значений, которыми программа манипулирует на регулярной основе. В идеальном мире мы могли бы обрабатывать данные, находящиеся внутри контейнера в стеке, безо всяких проблем с производительностью. Было бы замечательно иметь возможность извлекать данные из контейнера, не прибегая к конструкциям try/catch
(именно это позволяют делать обобщения).
Мы уже затрагивали проблему безопасности в отношении типов, когда рассматривали операции распаковки. Вспомните, что данные должны быть распакованы в тот же самый тип, с которым они объявлялись перед упаковкой. Однако существует еще один аспект безопасности в отношении типов, который необходимо иметь в виду в мире без обобщений: тот факт, что классы из пространства имен
System.Collections
обычно могут хранить любые данные, т.к. их члены прототипированы для оперирования с типом System.Object
. Например, следующий метод строит список ArrayList
с произвольными фрагментами несвязанных данных:
static void ArrayListOfRandomObjects()
{
// ArrayList может хранить вообще все что угодно.
ArrayList allMyObjects = new ArrayList();
allMyObjects.Add(true);
allMyObjects.Add(new OperatingSystem(PlatformID.MacOSX,
new Version(10, 0)));
allMyObjects.Add(66);
allMyObjects.Add(3.14);
}
В ряде случаев вам будет требоваться исключительно гибкий контейнер, который способен хранить буквально все (как было здесь показано). Но большую часть времени вас интересует безопасный в отношении типов контейнер, который может работать только с определенным типом данных. Например, вы можете нуждаться в контейнере, хранящем только объекты типа подключения к базе данных, растрового изображения или класса, реализующего интерфейс
IPointy
.
До появления обобщений единственный способ решения проблемы, касающейся безопасности в отношении типов, предусматривал создание вручную специального класса (строго типизированной) коллекции. Предположим, что вы хотите создать специальную коллекцию, которая способна содержать только объекты типа
Person
:
namespace IssuesWithNonGenericCollections
{
public class Person
{
public int Age {get; set;}
public string FirstName {get; set;}
public string LastName {get; set;}
public Person(){}
public Person(string firstName, string lastName, int age)
{
Age = age;
FirstName = firstName;
LastName = lastName;
}
public override string ToString()
{
return $"Name: {FirstName} {LastName}, Age: {Age}";
}
}
}
Чтобы построить коллекцию, которая способна хранить только объекты
Person
, можно определить переменную-член System.Collection.ArrayList
внутри класса по имени PeopleCollection
и сконфигурировать все члены для оперирования со строго типизированными объектами Person
, а не с объектами типа System.Object
. Ниже приведен простой пример (специальная коллекция производственного уровня могла бы поддерживать множество дополнительных членов и расширять абстрактный базовый класс из пространства имен System.Collections
или System.Collections.Specialized
):
using System.Collections;
namespace IssuesWithNonGenericCollections
{
public class PersonCollection : IEnumerable
{
private ArrayList arPeople = new ArrayList();
// Приведение для вызывающего кода.
public Person GetPerson(int pos) => (Person)arPeople[pos];
// Вставка только объектов Person.
public void AddPerson(Person p)
{
arPeople.Add(p);
}
public void ClearPeople()
{
arPeople.Clear();
}
public int Count => arPeople.Count;
// Поддержка перечисления с помощью foreach.
IEnumerator IEnumerable.GetEnumerator() => arPeople.GetEnumerator();
}
}
Обратите внимание, что класс
PeopleCollection
реализует интерфейс IEnumerable
, который делает возможной итерацию в стиле foreach
по всем элементам, содержащимся в коллекции. Кроме того, методы GetPerson()
и AddPerson()
прототипированы для работы только с объектами Person
, а не растровыми изображениями, строками, подключениями к базам данных или другими элементами. Благодаря определению таких классов теперь обеспечивается безопасность в отношении типов, учитывая, что компилятор C# будет способен выявить любую попытку вставки элемента несовместимого типа. Обновите операторы using
в файле Program.cs
, как показано ниже, и поместите в конец текущего кода метод UserPersonCollection()
:
using System;
using System.Collections;
using IssuesWithNonGenericCollections;
// Операторы верхнего уровня в Program.cs
static void UsePersonCollection()
{
Console.WriteLine("***** Custom Person Collection *****\n");
PersonCollection myPeople = new PersonCollection();
myPeople.AddPerson(new Person("Homer", "Simpson", 40));
myPeople.AddPerson(new Person("Marge", "Simpson", 38));
myPeople.AddPerson(new Person("Lisa", "Simpson", 9));
myPeople.AddPerson(new Person("Bart", "Simpson", 7));
myPeople.AddPerson(new Person("Maggie", "Simpson", 2));
// Это вызовет ошибку на этапе компиляции!
// myPeople.AddPerson(new Car());
foreach (Person p in myPeople)
{
Console.WriteLine(p);
}
}
Хотя специальные коллекции гарантируют безопасность в отношении типов, такой подход обязывает создавать (в основном идентичные) специальные коллекции для всех уникальных типов данных, которые планируется в них помещать. Таким образом, если нужна специальная коллекция, которая могла бы оперировать только с классами, производными от базового класса
Car
, тогда придется построить очень похожий класс коллекции:
using System.Collections;
public class CarCollection : IEnumerable
{
private ArrayList arCars = new ArrayList();
// Приведение для вызывающего кода.
public Car GetCar(int pos) => (Car) arCars[pos];
// Вставка только объектов Car.
public void AddCar(Car c)
{
arCars.Add(c);
}
public void ClearCars()
{
arCars.Clear();
}
public int Count => arCars.Count;
// Поддержка перечисления с помощью foreach.
IEnumerator IEnumerable.GetEnumerator() => arCars.GetEnumerator();
}
Тем не менее, класс специальной коллекции ничего не делает для решения проблемы с накладными расходами по упаковке/распаковке. Даже если создать специальную коллекцию по имени
IntCollection
, которая предназначена для работы только с элементами System.Int32
, то все равно придется выделять память под объект какого-нибудь вида, хранящий данные (например, System.Array
и ArrayList
):
public class IntCollection : IEnumerable
{
private ArrayList arInts = new ArrayList();
// Получение int (выполняется распаковка).
public int GetInt(int pos) => (int)arInts[pos];
// Вставка int (выполняется упаковка).
public void AddInt(int i)
{
arInts.Add(i);
}
public void ClearInts()
{
arInts.Clear();
}
public int Count => arInts.Count;
IEnumerator IEnumerable.GetEnumerator() => arInts.GetEnumerator();
}
Независимо от того, какой тип выбран для хранения целых чисел, в случае применения необобщенных контейнеров затруднительного положения с упаковкой избежать невозможно.
Когда используются классы обобщенных коллекций, все описанные выше проблемы исчезают, включая накладные расходы на упаковку/распаковку и отсутствие безопасности в отношении типов. К тому же необходимость в создании специального класса (обобщенной) коллекции становится довольно редкой. Вместо построения уникальных классов, которые могут хранить объекты людей, автомобилей и целые числа, можно задействовать класс обобщенной коллекции и указать тип хранимых элементов. Добавьте в начало файла
Program.cs
следующий оператор using
:
using System.Collections.Generic;
Взгляните на показанный ниже метод (добавленный в конец файла
Program.cs
), в котором используется класс List
(из пространства имен System.Collection.Generic
) для хранения разнообразных видов данных в строго типизированной манере (пока не обращайте внимания на детали синтаксиса обобщений):
static void UseGenericList()
{
Console.WriteLine("***** Fun with Generics *****\n");
// Этот объект List<> может хранить только объекты Person.
List morePeople = new List();
morePeople.Add(new Person ("Frank", "Black", 50));
Console.WriteLine(morePeople[0]);
// Этот объект ListO может хранить только целые числа.
List moreInts = new List();
moreInts.Add(10);
moreInts.Add(2);
int sum = moreInts[0] + moreInts[1];
// Ошибка на этапе компиляции! Объект Person
// не может быть добавлен в список элементов int!
// moreInts.Add(new Person());
}
Первый контейнер
List
способен содержать только объекты Person
. По этой причине выполнять приведение при извлечении элементов из контейнера не требуется, что делает такой подход более безопасным в отношении типов. Второй контейнер List
может хранить только целые числа, размещенные в стеке; другими словами, здесь не происходит никакой скрытой упаковки/распаковки, которая имеет место в необобщенном типе ArrayList
. Ниже приведен краткий перечень преимуществ обобщенных контейнеров по сравнению с их необобщенными аналогами.
• Обобщения обеспечивают лучшую производительность, т.к. лишены накладных расходов по упаковке/распаковке, когда хранят типы значений.
• Обобщения безопасны в отношении типов, потому что могут содержать только объекты указанного типа.
• Обобщения значительно сокращают потребность в специальных типах коллекций, поскольку при создании обобщенного контейнера указывается "вид типа".
Обобщенные классы, интерфейсы, структуры и делегаты вы можете обнаружить повсюду в библиотеках базовых классов .NET Core, и они могут быть частью любого пространства имен .NET Core. Кроме того, имейте в виду, что применение обобщений далеко не ограничивается простым определением класса коллекции. Разумеется, в оставшихся главах книги вы встретите случаи использования многих других обобщений для самых разных целей.
На заметку! Обобщенным образом могут быть записаны только классы, структуры, интерфейсы и делегаты, но не перечисления.
Глядя на обобщенный элемент в документации по .NET Core или в браузере объектов Visual Studio, вы заметите пару угловых скобок с буквой или другой лексемой внутри. На рис. 10.1 показано окно браузера объектов Visual Studio, в котором отображается набор обобщенных элементов из пространства имен
System.Collections.Generic
, включающий выделенный класс List
.
Формально эти лексемы называются параметрами типа, но в более дружественных к пользователю терминах на них можно ссылаться просто как на заполнители. Конструкцию
<Т>
можно читать как "типа Т
". Таким образом, IEnumerable
можно прочитать как "IEnumerable
типа Т
".
На заметку! Имя параметра типа (заполнитель) роли не играет и зависит от предпочтений разработчика, создавшего обобщенный элемент. Однако обычно имя
T
применяется для представления типов, ТКеу
или К
— для представления ключей и TValue
или V
— для представления значений.
Когда вы создаете обобщенный объект, реализуете обобщенный интерфейс или вызываете обобщенный член, на вас возлагается обязанность по предоставлению значения для параметра типа. Многочисленные примеры вы увидите как в этой главе, так и в остальных материалах книги. Тем не менее, для начала рассмотрим основы взаимодействия с обобщенными типами и членами.
При создании экземпляра обобщенного класса или структуры вы указываете параметр типа, когда объявляете переменную и когда вызываете конструктор. Как было показано в предыдущем фрагменте кода, в методе
UseGenericList()
определены два объекта List
:
// Этот объект List<> может хранить только объекты Person.
List morePeople = new List();
// Этот объект List<> может хранить только целые числа.
List moreInts = new List();
Первую строку приведенного выше кода можно трактовать как "список
List<>
объектов Т
, где Т
— тип Person
" или более просто как "список объектов действующих лиц". После указания параметра типа обобщенного элемента изменить его нельзя (помните, что сущностью обобщений является безопасность в отношении типов). Когда параметр типа задается для обобщенного класса или структуры, все вхождения заполнителя (заполнителей) заменяются предоставленным значением.
Если вы просмотрите полное объявление обобщенного класса
List
в браузере объектов Visual Studio, то заметите, что заполнитель Т
используется в определении повсеместно. Ниже приведен частичный листинг:
// Частичное определение класса List.
namespace System.Collections.Generic
{
public class List : IList, IList, IReadOnlyList
{
...
public void Add(T item);
public void AddRange(IEnumerable<T> collection);
public ReadOnlyCollection<T> AsReadOnly();
public int BinarySearch(T item);
public bool Contains(T item);
public void CopyTo(T[] array);
public int FindIndex(System.Predicate<T> match);
public T FindLast(System.Predicate<T> match);
public bool Remove(T item);
public int RemoveAll(System.Predicate<T> match);
public T[] ToArray();
public bool TrueForAll(System.Predicate<T> match);
public T this[int index] { get; set; }
}
}
В случае создания
List
с указанием объектов Person
результат будет таким же, как если бы тип List
был определен следующим образом:
namespace System.Collections.Generic
{
public class List<Person>
: IList<Person>, IList, IReadOnlyList<Person>
{
...
public void Add(Person item);
public void AddRange(IEnumerable<Person> collection);
public ReadOnlyCollection<Person> AsReadOnly();
public int BinarySearch(Person item);
public bool Contains(Person item);
public void CopyTo(Person[] array);
public int FindIndex(System.Predicate<Person> match);
public Person FindLast(System.Predicate<Person> match);
public bool Remove(Person item);
public int RemoveAll(System.Predicate<Person> match);
public Person[] ToArray();
public bool TrueForAll(System.Predicate<Person> match);
public Person this[int index] { get; set; }
}
}
Несомненно, когда вы создаете в коде переменную обобщенного типа
List
, компилятор вовсе не создает новую реализацию класса List
. Взамен он принимает во внимание только члены обобщенного типа, к которым вы действительно обращаетесь.
В необобщенном классе или структуре разрешено поддерживать обобщенные свойства. В таких случаях необходимо также указывать значение заполнителя во время вызова метода. Например, класс
System.Array
поддерживает набор обобщенных методов. В частности, необобщенный статический метод Sort()
имеет обобщенный аналог по имени Sort()
. Рассмотрим представленный далее фрагмент кода, где Т
— тип int
:
int[] myInts = { 10, 4, 2, 33, 93 };
// Указание заполнителя для обобщенного метода Sort<>().
Array.Sort(myInts);
foreach (int i in myInts)
{
Console.WriteLine(i);
}
Обобщенные интерфейсы обычно реализуются при построении классов или структур,которые нуждаются в поддержке разнообразных аспектов поведения платформы (скажем, клонирования, сортировки и перечисления). В главе 8 вы узнали о нескольких необобщенных интерфейсах, таких как
IComparable
, IEnumerable
, IEnumerator
и IComparer
. Вспомните, что необобщенный интерфейс IComparable
определен примерно так:
public interface IComparable
{
int CompareTo(object obj);
}
В главе 8 этот интерфейс также был реализован классом
Car
, чтобы сделать возможной сортировку стандартного массива. Однако код требовал нескольких проверок времени выполнения и операций приведения, потому что параметром был общий тип System.Object
:
public class Car : IComparable
{
...
// Реализация IComparable.
int IComparable.CompareTo(object obj)
{
if (obj is Car temp)
{
return this.CarID.CompareTo(temp.CarID);
}
throw new ArgumentException("Parameter is not a Car!");
// Параметр не является объектом типа Car!
}
}
Теперь представим, что применяется обобщенный аналог данного интерфейса:
public interface IComparable
{
int CompareTo(T obj);
}
В таком случае код реализации будет значительно яснее:
public class Car : IComparable
{
...
// Реализация IComparable.
int IComparable.CompareTo(Car obj)
{
if (this.CarID > obj.CarID)
{
return 1;
}
if (this.CarID < obj.CarID)
{
return -1;
}
return 0;
}
}
Здесь уже не нужно проверять, относится ли входной параметр к типу
Car
, потому что он может быть только Car
! В случае передачи несовместимого типа данных возникает ошибка на этапе компиляции. Теперь, углубив понимание того, как взаимодействовать с обобщенными элементами, а также усвоив роль параметров типа (т.е. заполнителей), вы готовы к исследованию классов и интерфейсов из пространства имен System.Collections.Generic
.
Когда вы строите приложение .NET Core и необходим способ управления данными в памяти, классы из пространства имен
System.Collections.Generic
вероятно удовлетворят всем требованиям. В начале настоящей главы кратко упоминались некоторые основные необобщенные интерфейсы, реализуемые необобщенными классами коллекций. Не должен вызывать удивление тот факт, что в пространстве имен System.Collections.Generic
для многих из них определены обобщенные замены.
В действительности вы сможете найти некоторое количество обобщенных интерфейсов, которые расширяют свои необобщенные аналоги, что может показаться странным.Тем не менее, за счет этого реализующие их классы будут также поддерживать унаследованную функциональность, которая имеется в их необобщенных родственных версиях. Например, интерфейс
IEnumerable
расширяет IEnumerable
. В табл. 10.4 описаны основные обобщенные интерфейсы, с которыми вы столкнетесь во время работы с обобщенными классами коллекций.
В пространстве имен
System.Collections.Generic
также определены классы, реализующие многие из указанных основных интерфейсов. В табл. 10.5 описаны часто используемые классы из этого пространства имен, реализуемые ими интерфейсы, а также их базовая функциональность.
В пространстве имен
System.Collections.Generic
также определены многие вспомогательные классы и структуры, которые работают в сочетании со специфическим контейнером. Например, тип LinkedListNode
представляет узел внутри обобщенного контейнера LinkedList
, исключение KeyNotFoundException
генерируется при попытке получения элемента из коллекции с применением несуществующего ключа и т.д. Подробные сведения о пространстве имен System.Collections.Generic
доступны в документации по .NET Core.
В любом случае следующая ваша задача состоит в том, чтобы научиться использовать некоторые из упомянутых классов обобщенных коллекций. Тем не менее, сначала полезно ознакомиться со средством языка C# (введенным в версии .NET 3.5), которое упрощает заполнение данными обобщенных (и необобщенных) коллекций.
В главе 4 вы узнали о синтаксисе инициализации массивов, который позволяет устанавливать элементы новой переменной массива во время ее создания. С ним тесно связан синтаксис инициализации коллекций. Данное средство языка C# позволяет наполнять многие контейнеры (такие как
ArrayList
или List
) элементами с применением синтаксиса, похожего на тот, который используется для наполнения базовых массивов. Создайте новый проект консольного приложения .NET Core по имени FunWithCollectionInitialization
. Удалите код, сгенерированный в Program.cs
, и добавьте следующие операторы using
:
using System;
using System.Collections;
using System.Collections.Generic;
using System.Drawing;
На заметку! Синтаксис инициализации коллекций может применяться только к классам, которые поддерживают метод
Add()
, формально определяемый интерфейсами ICollection
и ICollection
.
Взгляните на приведенные ниже примеры:
// Инициализация стандартного массива.
int[] myArrayOfInts = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
// Инициализация обобщенного List<> с элементами int.
List myGenericList = new List { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
// Инициализация ArrayList числовыми данными.
ArrayList myList = new ArrayList { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
Если контейнером является коллекция классов или структур, тогда синтаксис инициализации коллекций можно смешивать с синтаксисом инициализации объектов, получая функциональный код. Вспомните класс
Point
из главы 5, в котором были определены два свойства, X
и Y
. Для построения обобщенного списка List
объектов Point
можно написать такой код:
List myListOfPoints = new List
{
new Point { X = 2, Y = 2 },
new Point { X = 3, Y = 3 },
new Point { X = 4, Y = 4 }
};
foreach (var pt in myListOfPoints)
{
Console.WriteLine(pt);
}
Преимущество этого синтаксиса связано с сокращением объема клавиатурного ввода. Хотя вложенные фигурные скобки могут затруднять чтение кода, если не позаботиться о надлежащем форматировании, вы только вообразите себе объем кода, который пришлось бы написать для наполнения следующего списка
List
объектов Rectangle
без использования синтаксиса инициализации коллекций:
List myListOfRects = new List
{
new Rectangle {
Height = 90, Width = 90,
Location = new Point { X = 10, Y = 10 }},
new Rectangle {
Height = 50,Width = 50,
Location = new Point { X = 2, Y = 2 }},
};
foreach (var r in myListOfRects)
{
Console.WriteLine(r);
}
Создайте новый проект консольного приложения под названием
FunWithGenericCollections
. Добавьте новый файл по имени Person.cs
и поместите в него показанный ниже код (это тот же самый код с определением предыдущего класса Person
):
namespace FunWithGenericCollections
{
public class Person
{
public int Age {get; set;}
public string FirstName {get; set;}
public string LastName {get; set;}
public Person(){}
public Person(string firstName, string lastName, int age)
{
Age = age;
FirstName = firstName;
LastName = lastName;
}
public override string ToString()
{
return $"Name: {FirstName} {LastName}, Age: {Age}";
}
}
}
Удалите сгенерированный код из файла
Program.cs
и добавьте следующие операторы using
:
using System;
using System.Collections.Generic;
using FunWithGenericCollections;
Первым будет исследоваться обобщенный класс
List
, который уже применялся ранее в главе. Класс List
используется чаще других классов из пространства имен System.Collections.Generic
, т.к. он позволяет динамически изменять размер контейнера. Чтобы ознакомиться с его особенностями, добавьте в класс Program
метод UseGenericList()
, в котором задействован класс List
для манипулирования набором объектов Person
; вспомните, что в классе Person
определены три свойства (Age
, FirstName
и LastName
), а также специальная реализация метода ToString()
:
static void UseGenericList()
{
// Создать список объектов Person и заполнить его с помощью
// синтаксиса инициализации объектов и коллекции.
List people = new List()
{
new Person {FirstName= "Homer", LastName="Simpson", Age=47},
new Person {FirstName= "Marge", LastName="Simpson", Age=45},
new Person {FirstName= "Lisa", LastName="Simpson", Age=9},
new Person {FirstName= "Bart", LastName="Simpson", Age=8}
};
// Вывести количество элементов в списке.
Console.WriteLine("Items in list: {0}", people.Count);
// Выполнить перечисление по списку.
foreach (Person p in people)
{
Console.WriteLine(p);
}
// Вставить новый объект Person.
Console.WriteLine("\n->Inserting new person.");
people.Insert(2, new Person { FirstName = "Maggie",
LastName = "Simpson", Age = 2 });
Console.WriteLine("Items in list: {0}", people.Count);
// Скопировать данные в новый массив.
Person[] arrayOfPeople = people.ToArray();
foreach (Person p in arrayOfPeople) // Вывести имена
{
Console.WriteLine("First Names: {0}", p.FirstName);
}
}
Здесь для наполнения списка
List
объектами применяется синтаксис инициализации в качестве сокращенной записи многократного вызова метода Add()
. После вывода количества элементов в коллекции (и прохода по всем элементам) вызывается метод Insert()
. Как видите, метод Insert()
позволяет вставлять новый элемент в List
по указанному индексу.
Наконец, обратите внимание на вызов метода
ToArray()
, который возвращает массив объектов Person
, основанный на содержимом исходного списка List
. Затем осуществляется проход по всем элементам данного массива с использованием синтаксиса индексатора массива. Вызов метода UseGenericList()
в операторах верхнего уровня приводит к получению следующего вывода:
***** Fun with Generic Collections *****
Items in list: 4
Name: Homer Simpson, Age: 47
Name: Marge Simpson, Age: 45
Name: Lisa Simpson, Age: 9
Name: Bart Simpson, Age: 8
->Inserting new person.
Items in list: 5
First Names: Homer
First Names: Marge
First Names: Maggie
First Names: Lisa
First Names: Bart
В классе
List
определено множество дополнительных членов, представляющих интерес, поэтому за полным их описанием обращайтесь в документацию. Давайте рассмотрим еще несколько обобщенных коллекций, в частности Stack
, Queue
и SortedSet
, что должно способствовать лучшему пониманию основных вариантов хранения данных в приложении.
Класс
Stack
представляет коллекцию элементов, которая обслуживает элементы в стиле "последний вошел — первый вышел" (LIFO). Как и можно было ожидать, в Stack
определены члены Push()
и Pop()
, предназначенные для вставки и удаления элементов из стека. Приведенный ниже метод создает стек объектов Person
:
static void UseGenericStack()
{
Stack stackOfPeople = new();
stackOfPeople.Push(new Person { FirstName = "Homer",
LastName = "Simpson", Age = 47 });
stackOfPeople.Push(new Person { FirstName = "Marge",
LastName = "Simpson", Age = 45 });
stackOfPeople.Push(new Person { FirstName = "Lisa",
LastName = "Simpson", Age = 9 });
// Просмотреть верхний элемент, вытолкнуть его и просмотреть снова..
Console.WriteLine("First person is: {0}", stackOfPeople.Peek());
Console.WriteLine("Popped off {0}", stackOfPeople.Pop());
Console.WriteLine("\nFirst person is: {0}", stackOfPeople.Peek());
Console.WriteLine("Popped off {0}", stackOfPeople.Pop());
Console.WriteLine("\nFirst person item is: {0}", stackOfPeople.Peek());
Console.WriteLine("Popped off {0}", stackOfPeople.Pop());
try
{
Console.WriteLine("\nnFirst person is: {0}", stackOfPeople.Peek());
Console.WriteLine("Popped off {0}", stackOfPeople.Pop());
}
catch (InvalidOperationException ex)
{
Console.WriteLine("\nError! {0}", ex.Message); // Ошибка! Стек пуст
}
}
В коде строится стек, который содержит информацию о трех лицах, добавленных в алфавитном порядке следования их имен:
Homer
, Marge
и Lisa
. Заглядывая (посредством метода Реек()
) в стек, вы будете всегда видеть объект, находящийся на его вершине; следовательно, первый вызов Реек()
возвращает третий объект Person
. После серии вызовов Pop()
и Peek()
стек, в конце концов, опустошается, после чего дополнительные вызовы Реек()
и Pop()
приводят к генерации системного исключения. Вот как выглядит вывод:
***** Fun with Generic Collections *****
First person is: Name: Lisa Simpson, Age: 9
Popped off Name: Lisa Simpson, Age: 9
First person is: Name: Marge Simpson, Age: 45
Popped off Name: Marge Simpson, Age: 45
First person item is: Name: Homer Simpson, Age: 47
Popped off Name: Homer Simpson, Age: 47
Error! Stack empty.
Очереди — это контейнеры, которые обеспечивают доступ к элементам в стиле "первый вошел — первый вышел" (FIFO). К сожалению, людям приходится сталкиваться с очередями практически ежедневно: в банке, в супермаркете, в кафе. Когда нужно смоделировать сценарий, в котором элементы обрабатываются в режиме FIFO, класс
Queue
подходит наилучшим образом. Дополнительно к функциональности, предоставляемой поддерживаемыми интерфейсами, в Queue
определены основные члены, перечисленные в табл. 10.6.
Теперь давайте посмотрим на описанные методы в работе. Можно снова задействовать класс
Person
и построить объект Queue
, эмулирующий очередь людей, которые ожидают заказанный кофе.
static void UseGenericQueue()
{
// Создать очередь из трех человек.
Queue peopleQ = new();
peopleQ.Enqueue(new Person {FirstName= "Homer", LastName="Simpson", Age=47});
peopleQ.Enqueue(new Person {FirstName= "Marge", LastName="Simpson", Age=45});
peopleQ.Enqueue(new Person {FirstName= "Lisa", LastName="Simpson", Age=9});
// Заглянуть, кто первый в очереди.
Console.WriteLine("{0} is first in line!", peopleQ.Peek().FirstName);
// Удалить всех из очереди.
GetCoffee(peopleQ.Dequeue());
GetCoffee(peopleQ.Dequeue());
GetCoffee(peopleQ.Dequeue());
// Попробовать извлечь кого-то из очереди снова
try
{
GetCoffee(peopleQ.Dequeue());
}
catch(InvalidOperationException e)
{
Console.WriteLine("Error! {0}", e.Message); //Ошибка! Очередь пуста.
}
// Локальная вспомогательная функция
static void GetCoffee(Person p)
{
Console.WriteLine("{0} got coffee!", p.FirstName);
}
}
Здесь с применением метода
Enqueue()
в Queue
вставляются три элемента. Вызов Peek()
позволяет просматривать (но не удалять) первый элемент, находящийся в текущий момент внутри Queue
. Наконец, вызов Dequeue()
удаляет элемент из очереди и передает его на обработку вспомогательной функции GetCoffee()
. Обратите внимание, что если попытаться удалить элемент из пустой очереди, то сгенерируется исключение времени выполнения. Ниже показан вывод, полученный в результате вызова метода UseGenericQueue()
:
***** Fun with Generic Collections *****
Homer is first in line!
Homer got coffee!
Marge got coffee!
Lisa got coffee!
Error! Queue empty.
Класс
SortedSet
полезен тем, что при вставке или удалении элементов он автоматически обеспечивает сортировку элементов в наборе. Однако классу SortedSet
необходимо сообщить, каким образом должны сортироваться объекты, путем передачи его конструктору в качестве аргумента объекта, который реализует обобщенный интерфейс IComparer
.
Начните с создания нового класса по имени
SortPeopleByAge
, реализующего интерфейс IComparer
, где Т
— тип Person
. Вспомните, что в этом интерфейсе определен единственный метод по имени Compare()
, в котором можно запрограммировать логику сравнения элементов. Вот простая реализация:
using System.Collections.Generic;
namespace FunWithGenericCollections
{
class SortPeopleByAge : IComparer
{
public int Compare(Person firstPerson, Person secondPerson)
{
if (firstPerson?.Age > secondPerson?.Age)
{
return 1;
}
if (firstPerson?.Age < secondPerson?.Age)
{
return -1;
}
return 0;
}
}
}
Теперь добавьте в класс
Program
следующий новый метод, который позволит продемонстрировать применение SortedSet
:
static void UseSortedSet()
{
// Создать несколько объектов Person с разными значениями возраста.
SortedSet setOfPeople = new SortedSet(new SortPeopleByAge())
{
new Person {FirstName= "Homer", LastName="Simpson", Age=47},
new Person {FirstName= "Marge", LastName="Simpson", Age=45},
new Person {FirstName= "Lisa", LastName="Simpson", Age=9},
new Person {FirstName= "Bart", LastName="Simpson", Age=8}
};
// Обратите внимание, что элементы отсортированы по возрасту.
foreach (Person p in setOfPeople)
{
Console.WriteLine(p);
}
Console.WriteLine();
// Добавить еще несколько объектов Person с разными значениями возраста.
setOfPeople.Add(new Person { FirstName = "Saku", LastName = "Jones", Age = 1 });
setOfPeople.Add(new Person { FirstName = "Mikko", LastName = "Jones", Age = 32 });
// Элементы по-прежнему отсортированы по возрасту.
foreach (Person p in setOfPeople)
{
Console.WriteLine(p);
}
}
Запустив приложение, легко заметить, что список объектов будет всегда упорядочен на основе значения свойства
Age
независимо от порядка вставки и удаления объектов:
***** Fun with Generic Collections *****
Name: Bart Simpson, Age: 8
Name: Lisa Simpson, Age: 9
Name: Marge Simpson, Age: 45
Name: Homer Simpson, Age: 47
Name: Saku Jones, Age: 1
Name: Bart Simpson, Age: 8
Name: Lisa Simpson, Age: 9
Name: Mikko Jones, Age: 32
Name: Marge Simpson, Age: 45
Name: Homer Simpson, Age: 47
Еще одной удобной обобщенной коллекцией является класс
Dictionary
, позволяющий хранить любое количество объектов, на которые можно ссылаться через уникальный ключ. Таким образом, вместо получения элемента из List
с использованием числового идентификатора (например, "извлечь второй объект") можно применять уникальный строковый ключ (скажем, "предоставить объект с ключом Homer
").
Как и другие классы коллекций, наполнять
Dictionary
можно путем вызова обобщенного метода Add()
вручную. Тем не менее, заполнять Dictionary
допускается также с использованием синтаксиса инициализации коллекций. Имейте в виду, что при наполнении данного объекта коллекции ключи должны быть уникальными. Если вы по ошибке укажете один и тот же ключ несколько раз, то получите исключение времени выполнения.
Взгляните на следующий метод, который наполняет
Dictionary
разнообразными объектами. Обратите внимание, что при создании объекта Dictionary
в качестве аргументов конструктора передаются тип ключа (ТКеу
) и тип внутренних объектов (TValue
). В этом примере для ключа указывается тип данных string
, а для значения — тип Person
. Кроме того, имейте в виду, что синтаксис инициализации объектов можно сочетать с синтаксисом инициализации коллекций.
private static void UseDictionary()
{
// Наполнить с помощью метода Add()
Dictionary peopleA = new Dictionary();
peopleA.Add("Homer", new Person { FirstName = "Homer",
LastName = "Simpson", Age = 47 });
peopleA.Add("Marge", new Person { FirstName = "Marge",
LastName = "Simpson", Age = 45 });
peopleA.Add("Lisa", new Person { FirstName = "Lisa",
LastName = "Simpson", Age = 9 });
// Получить элемент с ключом Homer.
Person homer = peopleA["Homer"];
Console.WriteLine(homer);
// Наполнить с помощью синтаксиса инициализации.
Dictionary peopleB = new Dictionary()
{
{ "Homer", new Person { FirstName = "Homer",
LastName = "Simpson", Age = 47 } },
{ "Marge", new Person { FirstName = "Marge",
LastName = "Simpson", Age = 45 } },
{ "Lisa", new Person { FirstName = "Lisa",
LastName = "Simpson", Age = 9 } }
};
// Получить элемент с ключом Lisa.
Person lisa = peopleB["Lisa"];
Console.WriteLine(lisa);
}
Наполнять
Dictionary
также возможно с применением связанного синтаксиса инициализации, который является специфичным для контейнера данного типа (вполне ожидаемо называемый инициализацией словарей). Подобно синтаксису, который использовался при наполнении объекта personB
в предыдущем примере, для объекта коллекции определяется область инициализации; однако можно также применять индексатор, чтобы указать ключ, и присвоить ему новый объект:
// Наполнить с помощью синтаксиса инициализации словарей.
Dictionary peopleC = new Dictionary()
{
["Homer"] = new Person { FirstName = "Homer",
LastName = "Simpson", Age = 47 },
["Marge"] = new Person { FirstName = "Marge",
LastName = "Simpson", Age = 45 },
["Lisa"] = new Person { FirstName = "Lisa",
LastName = "Simpson", Age = 9 }
};
Теперь, когда вы понимаете, как работать с основными обобщенными классами, можно кратко рассмотреть дополнительное пространство имен, связанное с коллекциями —
System.Collections.ObjectModel
. Это относительно небольшое пространство имен, содержащее совсем мало классов. В табл. 10.7 документированы два класса, о которых вы обязательно должны быть осведомлены.
Класс
ObservableCollection
удобен своей возможностью информировать внешние объекты, когда его содержимое каким-то образом изменяется (как и можно было догадаться, работа с ReadOnlyObservableCollection
похожа, но по своей природе допускает только чтение).
Создайте новый проект консольного приложения по имени
FunWithObservableCollections
и импортируйте в первоначальный файл кода C# пространство имен System.Collections.ObjectModel
. Во многих отношениях работа с ObservableCollection
идентична работе с List
, учитывая, что оба класса реализуют те же самые основные интерфейсы. Уникальным класс ObservableCollection
> делает тот факт, что он поддерживает событие по имени CollectionChanged
. Указанное событие будет инициироваться каждый раз, когда вставляется новый элемент, удаляется (или перемещается) существующий элемент либо модифицируется вся коллекция целиком.
Подобно любому другому событию событие
CollectionChanged
определено в терминах делегата, которым в данном случае является NotifyCollectionChangedEventHandler
. Этот делегат может вызывать любой метод, который принимает object
в первом параметре и NotifyCollectionChangedEventArgs
— во втором. Рассмотрим следующий код, в котором наполняется наблюдаемая коллекция, содержащая объекты Person
, и осуществляется привязка к событию CollectionChanged
:
using System;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using FunWithObservableCollections;
// Сделать коллекцию наблюдаемой
// и добавить в нее несколько объектов Person.
ObservableCollection people = new ObservableCollection()
{
new Person{ FirstName = "Peter", LastName = "Murphy", Age = 52 },
new Person{ FirstName = "Kevin", LastName = "Key", Age = 48 },
};
// Привязаться к событию CollectionChanged.
people.CollectionChanged += people_CollectionChanged;
static void people_CollectionChanged(object sender,
System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
throw new NotImplementedException();
}
Входной параметр
NotifyCollectionChangedEventArgs
определяет два важных свойства, OldIterns
и NewItems
, которые выдают список элементов, имеющихся в коллекции перед генерацией события, и список новых элементов, вовлеченных в изменение. Тем не менее, такие списки будут исследоваться только в подходящих обстоятельствах. Вспомните, что событие CollectionChanged
инициируется при добавлении, удалении, перемещении или сбросе элементов. Чтобы выяснить, какое из упомянутых действий запустило событие, можно использовать свойство Action
объекта NotifyCollectionChangedEventArgs
. Свойство Action
допускается проверять на предмет равенства любому из членов перечисления NotifyCollectionChangedAction
:
public enum NotifyCollectionChangedAction
{
Add = 0,
Remove = 1,
Replace = 2,
Move = 3,
Reset = 4,
}
Ниже показана реализация обработчика событий
CollectionChanged
, который будет обходить старый и новый наборы, когда элемент вставляется или удаляется из имеющейся коллекции (обратите внимание на оператор using
для System.Collections.Specialized
):
using System.Collections.Specialized;
...
static void people_CollectionChanged(object sender,
NotifyCollectionChangedEventArgs e)
{
// Выяснить действие, которое привело к генерации события.
Console.WriteLine("Action for this event: {0}", e.Action);
// Было что-то удалено.
if (e.Action == NotifyCollectionChangedAction.Remove)
{
Console.WriteLine("Here are the OLD items:"); // старые элементы
foreach (Person p in e.OldItems)
{
Console.WriteLine(p.ToString());
}
Console.WriteLine();
}
// Было что-то добавлено.
if (e.Action == NotifyCollectionChangedAction.Add)
{
// Теперь вывести новые элементы, которые были вставлены.
Console.WriteLine("Here are the NEW items:"); // Новые элементы
foreach (Person p in e.NewItems)
{
Console.WriteLine(p.ToString());
}
}
}
Модифицируйте вызывающий код для добавления и удаления элемента:
// Добавить новый элемент.
people.Add(new Person("Fred", "Smith", 32));
// Удалить элемент.
people.RemoveAt(0);
В результате запуска программы вы получите вывод следующего вида:
Action for this event: Add
Here are the NEW items:
Name: Fred Smith, Age: 32
Action for this event: Remove
Here are the OLD items:
Name: Peter Murphy, Age: 52
На этом исследование различных пространств имен, связанных с коллекциями, завершено. В конце главы будет также объясняться, как и для чего строить собственные обобщенные методы и обобщенные типы.
Несмотря на то что большинство разработчиков обычно применяют обобщенные типы, имеющиеся в библиотеках базовых классов, существует также возможность построения собственных обобщенных методов и специальных обобщенных типов. Давайте посмотрим, как включать обобщения в свои проекты. Первым делом будет построен обобщенный метод обмена. Начните с создания нового проекта консольного приложения по имени
CustomGenericMethods
.
Построение специальных обобщенных методов представляет собой более развитую версию традиционной перегрузки методов. В главе 2 вы узнали, что перегрузка — это действие по определению нескольких версий одного метода, которые отличаются друг от друга количеством или типами параметров.
Хотя перегрузка является полезным средством объектно-ориентированного языка, проблема заключается в том, что при этом довольно легко получить в итоге огромное количество методов, которые по существу делают одно и то же. Например, пусть необходимо создать методы, которые позволяют менять местами два фрагмента данных посредством простой процедуры. Вы можете начать с написания нового статического класса с методом, который способен оперировать целочисленными значениями:
using System;
namespace CustomGenericMethods
{
static class SwapFunctions
{
// Поменять местами два целочисленных значения.
static void Swap(ref int a, ref int b)
{
int temp = a;
a = b;
b = temp;
}
}
}
Пока все идет хорошо. Но теперь предположим, что нужно менять местами также и два объекта
Person
; действие потребует написания новой версии метода Swap()
:
// Поменять местами два объекта Person.
static void Swap(ref Person a, ref Person b)
{
Person temp = a;
a = b;
b = temp;
}
Вне всяких сомнений вам должно быть ясно, чем все закончится. Если также понадобится менять местами два значения с плавающей точкой, два объекта растровых изображений, два объекта автомобилей, два объекта кнопок или что-нибудь еще, то придется писать дополнительные методы, что в итоге превратится в настоящий кошмар при сопровождении. Можно было бы построить один (необобщенный) метод, оперирующий с параметрами типа
object
, но тогда возвратятся все проблемы, которые были описаны ранее в главе, т.е. упаковка, распаковка, отсутствие безопасности в отношении типов, явное приведение и т.д.
Наличие группы перегруженных методов, отличающихся только входными аргументами — явный признак того, что обобщения могут облегчить ситуацию. Рассмотрим следующий обобщенный метод
Swap()
, который способен менять местами два значения типа Т
:
// Этот метод будет менять местами два элемента
// типа, указанного в параметре <Т>.
static void Swap(ref T a, ref T b)
{
Console.WriteLine("You sent the Swap() method a {0}", typeof(T));
T temp = a;
a = b;
b = temp;
}
Обратите внимание, что обобщенный метод определен за счет указания параметра типа после имени метода, но перед списком параметров. Здесь заявлено, что метод
Swap()
способен оперировать на любых двух параметрах типа <Т>
. Для придания некоторой пикантности имя замещаемого типа выводится на консоль с использованием операции typeof()
языка С#. Взгляните на показанный ниже вызывающий код, который меняет местами целочисленные и строковые значения:
Console.WriteLine("***** Fun with Custom Generic Methods *****\n");
// Поменять местами два целочисленных значения.
int a = 10, b = 90;
Console.WriteLine("Before swap: {0}, {1}", a, b);
SwapFunctions.Swap(ref a, ref b);
Console.WriteLine("After swap: {0}, {1}", a, b);
Console.WriteLine();
// Поменять местами два строковых значения.
string s1 = "Hello", s2 = "There";
Console.WriteLine("Before swap: {0} {1}!", s1, s2);
SwapFunctions.Swap(ref s1, ref s2);
Console.WriteLine("After swap: {0} {1}!", s1, s2);
Console.ReadLine();
Вот вывод:
***** Fun with Custom Generic Methods *****
Before swap: 10, 90
You sent the Swap() method a System.Int32
After swap: 90, 10
Before swap: Hello There!
You sent the Swap() method a System.String
After swap: There Hello!
Главное преимущество такого подхода в том, что придется сопровождать только одну версию
Swap()
, однако она в состоянии работать с любыми двумя элементами заданного типа в безопасной в отношении типов манере. Еще лучше то, что находящиеся в стеке элементы остаются в стеке, а расположенные в куче — соответственно в куче.
При вызове обобщенных методов вроде
Swap()
параметр типа можно опускать, если (и только если) обобщенный метод принимает аргументы, поскольку компилятор в состоянии вывести параметр типа на основе параметров членов. Например, добавив к операторам верхнего уровня следующий код, можно менять местами два значения System.Boolean
:
// Компилятор выведет тип System.Boolean.
bool b1 = true, b2 = false;
Console.WriteLine("Before swap: {0}, {1}", b1, b2);
SwapFunctions.Swap(ref b1, ref b2);
Console.WriteLine("After swap: {0}, {1}", b1, b2);
Несмотря на то что компилятор может определить параметр типа на основе типа данных, который применялся в объявлениях
b1
и b2
, вы должны выработать привычку всегда указывать параметр типа явно:
SwapFunctions.Swap(ref b1, ref b2);
Такой подход позволяет другим программистам понять, что метод на самом деле является обобщенным. Кроме того, выведение типов параметров работает только в случае, если обобщенный метод принимает, по крайней мере, один параметр. Например, пусть в классе
Program
определен обобщенный метод DisplayBaseClass()
:
static void DisplayBaseClass()
{
// BaseType - метод, используемый в рефлексии;
// он будет описан в главе 17
Console.WriteLine("Base class of {0} is: {1}.",
typeof(T), typeof(T).BaseType);
}
В таком случае при его вызове потребуется указать параметр типа:
...
// Если метод не принимает параметров,
// то должен быть указан параметр типа.
DisplayBaseClass();
DisplayBaseClass();
// Ошибка на этапе компиляции! Нет параметров?
// Должен быть предоставлен заполнитель!
// DisplayBaseClass();
Console.ReadLine();
Разумеется, обобщенные методы не обязаны быть статическими, как в приведенных выше примерах. Кроме того, применимы все правила и варианты для необобщенных методов.
Так как вы уже знаете, каким образом определять и вызывать обобщенные методы, наступило время уделить внимание конструированию обобщенной структуры (процесс построения обобщенного класса идентичен) в новом проекте консольного приложения по имени
GenericPoint
. Предположим, что вы построили обобщенную структуру Point
, которая поддерживает единственный параметр типа, определяющий внутреннее представление координат (х, у). Затем в вызывающем коде можно создавать типы Point
:
// Точка с координатами типа int.
Point p = new Point(10, 10);
// Точка с координатами типа double.
Point p2 = new Point(5.4, 3.3);
// Точка с координатами типа string.
Point p3 = new Point(""",""3"");
Создание точки с использованием строк поначалу может показаться несколько странным, но возьмем случай мнимых чисел, и тогда применение строк для значений
X
и Y
точки может обрести смысл. Так или иначе, такая возможность демонстрирует всю мощь обобщений. Вот полное определение структуры Point
:
namespace GenericPoint
{
// Обобщенная структура Point.
public struct Point
{
// Обобщенные данные состояния.
private T _xPos;
private T _yPos;
// Обобщенный конструктор.
public Point(T xVal, T yVal)
{
_xPos = xVal;
_yPos = yVal;
}
// Обобщенные свойства.
public T X
{
get => _xPos;
set => _xPos = value;
}
public T Y
{
get => _yPos;
set => _yPos = value;
}
public override string ToString() => $"[{_xPos}, {_yPos}]";
}
}
Как видите, структура
Point
задействует параметр типа в определениях полей данных, в аргументах конструктора и в определениях свойств.
С появлением обобщений ключевое слово
default
получило двойную идентичность. Вдобавок к использованию внутри конструкции switch
оно также может применяться для установки параметра типа в стандартное значение. Это очень удобно, т.к. действительные типы, подставляемые вместо заполнителей, обобщенному типу заранее не известны, а потому он не может безопасно предполагать, какими будут стандартные значения. Параметры типа подчиняются следующим правилам:
• числовые типы имеют стандартное значение
0
;
• ссылочные типы имеют стандартное значение
null
;
• поля структур устанавливаются в
0
(для типов значений) или в null
(для ссылочных типов).
Чтобы сбросить экземпляр
Point
в начальное состояние, значения X
и Y
можно было бы установить в 0
напрямую. Это предполагает, что вызывающий код будет предоставлять только числовые данные. А как насчет версии string
? Именно здесь пригодится синтаксис default(Т)
. Ключевое слово default
сбрасывает переменную в стандартное значение для ее типа данных. Добавьте метод по имени ResetPoint()
:
// Сбросить поля в стандартное значение параметра типа.
// Ключевое слово default в языке C# перегружено.
// При использовании с обобщениями оно представляет
// стандартное значение параметра типа.
public void ResetPoint()
{
_xPos = default(T);
_yPos = default(T);
}
Теперь, располагая методом
ResetPoint()
, вы можете в полной мере использовать методы структуры Point
.
using System;
using GenericPoint;
Console.WriteLine("***** Fun with Generic Structures *****\n");
// Точка с координатами типа int.
Point p = new Point(10, 10);
Console.WriteLine("p.ToString()={0}", p.ToString());
p.ResetPoint();
Console.WriteLine("p.ToString()={0}", p.ToString());
Console.WriteLine();
// Точка с координатами типа double.
Point p2 = new Point(5.4, 3.3);
Console.WriteLine("p2.ToString()={0}", p2.ToString());
p2.ResetPoint();
Console.WriteLine("p2.ToString()={0}", p2.ToString());
Console.WriteLine();
// Точка с координатами типа string.
Point p3 = new Point("i", "3i");
Console.WriteLine("p3.ToString()={0}", p3.ToString());
p3.ResetPoint();
Console.WriteLine("p3.ToString()={0}", p3.ToString());
Console.ReadLine();
Ниже приведен вывод:
***** Fun with Generic Structures *****
p.ToString()=[10, 10]
p.ToString()=[0, 0]
p2.ToString()=[5.4, 3.3]
p2.ToString()=[0, 0]
p3.ToString()=[i, 3i]
p3.ToString()=[, ]
В дополнение к установке стандартного значения свойства в версии C# 7.1 появились выражения
default
литерального вида, которые устраняют необходимость в указании типа переменной в default
. Модифицируйте метод ResetPoint(),
как показано ниже:
public void ResetPoint()
{
_xPos = default;
_yPos = default;
}
Выражение
default
не ограничивается простыми переменными и может также применяться к сложным типам. Например, вот как можно создать и инициализировать структуру Point
:
Point p4 = default;
Console.WriteLine("p4.ToString()={0}", p4.ToString());
Console.WriteLine();
Point p5 = default;
Console.WriteLine("p5.ToString()={0}", p5.ToString());
Еще одним обновлением в версии C# 7.1 является возможность использования сопоставления с образцом в обобщениях. Взгляните на приведенный далее метод, проверяющий экземпляр
Point
на предмет типа данных, на котором он основан (вероятно, неполный, но достаточный для того, чтобы продемонстрировать концепцию):
static void PatternMatching(Point p)
{
switch (p)
{
case Point pString:
Console.WriteLine("Point is based on strings");
return;
case Point pInt:
Console.WriteLine("Point is based on ints");
return;
}
}
Для использования кода сопоставления с образцом модифицируйте операторы верхнего уровня следующим образом:
Point p4 = default;
Point p5 = default;
PatternMatching(p4);
PatternMatching(p5);
Как объяснялось в настоящей главе, любой обобщенный элемент имеет, по крайней мере, один параметр типа, который необходимо указывать во время взаимодействия с данным обобщенным типом или его членом. Уже одно это обстоятельство позволяет строить код, безопасный в отношении типов; тем не менее, вы также можете применять ключевое слово where для определения особых требований к отдельному параметру типа.
С помощью ключевого слова
where
можно добавлять набор ограничений к конкретному параметру типа, которые компилятор C# проверит на этапе компиляции. В частности, параметр типа можно ограничить, как описано в табл. 10.8.
Возможно, применять ключевое слово
where
в проектах C# вам никогда и не придется, если только не требуется строить какие-то исключительно безопасные в отношении типов специальные коллекции. Невзирая на сказанное, в следующих нескольких примерах (частичного) кода демонстрируется работа с ключевым словом where
.
Начнем с предположения о том, что создан специальный обобщенный класс, и необходимо гарантировать наличие в параметре типа стандартного конструктора. Это может быть полезно, когда специальный обобщенный класс должен создавать экземпляры типа
Т
, потому что стандартный конструктор является единственным конструктором, потенциально общим для всех типов. Кроме того, подобное ограничение Т
позволяет получить проверку на этапе компиляции; если Т
— ссылочный тип, то программист будет помнить о повторном определении стандартного конструктора в объявлении класса (как вам уже известно, в случае определения собственного конструктора класса стандартный конструктор из него удаляется).
// Класс MyGenericClass является производным от object, в то время как
// содержащиеся в нем элементы должны иметь стандартный конструктор.
public class MyGenericClass where T : new()
{
...
}
Обратите внимание, что конструкция where указывает параметр типа, подлежащий ограничению, за которым следует операция двоеточия. После операции двоеточия перечисляются все возможные ограничения (в данном случае — стандартный конструктор). Вот еще один пример:
// Класс MyGenericClass является производным от object, в то время как
// содержащиеся в нем элементы должны относиться к классу, реализующему
// интерфейс IDrawable, и поддерживать стандартный конструктор.
public class MyGenericClass where T : class, IDrawable, new()
{
...
}
Здесь к типу
T
предъявляются три требования. Во-первых, он должен быть ссылочным типом (не структурой), как помечено лексемой class
. Во-вторых, Т
должен реализовывать интерфейс IDrawable
. В-третьих, тип Т
также должен иметь стандартный конструктор. Множество ограничений перечисляются в виде списка с разделителями-запятыми, но имейте в виду, что ограничение new()
должно указываться последним! Таким образом, представленный далее код не скомпилируется:
// Ошибка! Ограничение new() должно быть последним в списке!
public class MyGenericClass where T : new(), class, IDrawable
{
...
}
При создании класса обобщенной коллекции с несколькими параметрами типа можно указывать уникальный набор ограничений для каждого параметра, применяя отдельные конструкции
where
:
// Тип <К> должен расширять SomeBaseClass и иметь стандартный конструктор,
// в то время как тип <Т> должен быть структурой и реализовывать
// обобщенный интерфейс IComparable.
public class MyGenericClass where K : SomeBaseClass, new()
where T : struct, IComparable
{
...
}
Необходимость построения полного специального обобщенного класса коллекции возникает редко; однако ключевое слово
where
допускается использовать также в обобщенных методах. Например, если нужно гарантировать, что метод Swap()
может работать только со структурами, измените его код следующим образом:
// Этот метод меняет местами любые структуры, но не классы.
static void Swap(ref T a, ref T b) where T : struct
{
...
}
Обратите внимание, что если ограничить метод
Swap()
в подобной манере, то менять местами объекты string
(как было показано в коде примера) больше не удастся, т.к. string
является ссылочным типом.
В завершение главы следует упомянуть об еще одном факте, связанном с обобщенными методами и ограничениями. При создании обобщенных методов может оказаться неожиданным получение ошибки на этапе компиляции в случае применения к параметрам типа любых операций C# (
+
, -
, *
, ==
и т.д.). Например, только вообразите, насколько полезным оказался бы класс, способный выполнять сложение, вычитание, умножение и деление с обобщенными типами:
// Ошибка на этапе компиляции! Невозможно
// применять операции к параметрам типа!
public class BasicMath
{
public T Add(T arg1, T arg2)
{ return arg1 + arg2; }
public T Subtract(T arg1, T arg2)
{ return arg1 - arg2; }
public T Multiply(T arg1, T arg2)
{ return arg1 * arg2; }
public T Divide(T arg1, T arg2)
{ return arg1 / arg2; }
}
К сожалению, приведенный выше класс
BasicMath
не скомпилируется. Хотя это может показаться крупным недостатком, следует вспомнить, что обобщения имеют общий характер. Конечно, числовые данные прекрасно работают с двоичными операциями С#. Тем не менее, справедливости ради, если аргумент <Т>
является специальным классом или структурой, то компилятор мог бы предположить, что он поддерживает операции +
, -
, *
и /
. В идеале язык C# позволял бы ограничивать обобщенный тип поддерживаемыми операциями, как показано ниже:
// Только в целях иллюстрации!
public class BasicMath where T : operator +, operator -,
operator *, operator /
{
public T Add(T arg1, T arg2)
{ return arg1 + arg2; }
public T Subtract(T arg1, T arg2)
{ return arg1 - arg2; }
public T Multiply(T arg1, T arg2)
{ return arg1 * arg2; }
public T Divide(T arg1, T arg2)
{ return arg1 / arg2; }
}
Увы, ограничения операций в текущей версии C# не поддерживаются. Однако достичь желаемого результата можно (хотя и с дополнительными усилиями) путем определения интерфейса, который поддерживает такие операции (интерфейсы C# могут определять операции!), и указания ограничения интерфейса для обобщенного класса. В любом случае первоначальный обзор построения специальных обобщенных типов завершен. Во время исследования типа делегата в главе 12 мы вновь обратимся к теме обобщений.
Глава начиналась с рассмотрения необобщенных типов коллекций в пространствах имен
System.Collections
и System.Collections.Specialized
, включая разнообразные проблемы, которые связаны со многими необобщенными контейнерами, в том числе отсутствие безопасности в отношении типов и накладные расходы времени выполнения в форме операций упаковки и распаковки. Как упоминалось, именно по этим причинам в современных приложениях .NET будут использоваться классы обобщенных коллекций из пространств имен System.Collections.Generic
и System.Collections.ObjectModel
.
Вы видели, что обобщенный элемент позволяет указывать заполнители (параметры типа), которые задаются во время создания объекта (или вызова в случае обобщенных методов). Хотя чаще всего вы будете просто применять обобщенные типы, предоставляемые библиотеками базовых классов .NET, также имеется возможность создавать собственные обобщенные типы (и обобщенные методы). При этом допускается указывать любое количество ограничений (с использованием ключевого слова
where
) для повышения уровня безопасности в отношении типов и гарантии того, что операции выполняются над типами известного размера, демонстрируя наличие определенных базовых возможностей.
В качестве финального замечания: не забывайте, что обобщения можно обнаружить во многих местах внутри библиотек базовых классов .NET. Здесь мы сосредоточились конкретно на обобщенных коллекциях. Тем не менее, по мере проработки материала оставшихся глав (и освоения платформы) вы наверняка найдете обобщенные классы, структуры и делегаты в том или ином пространстве имен. Кроме того, будьте готовы столкнуться с обобщенными членами в необобщенном классе!
В настоящей главе ваше понимание языка программирования C# будет углублено за счет исследования нескольких более сложных тем. Сначала вы узнаете, как реализовывать и применять индексаторный метод. Такой механизм C# позволяет строить специальные типы, которые предоставляют доступ к внутренним элементам с использованием синтаксиса, подобного синтаксису массивов. Вы научитесь перегружать разнообразные операции (
+
, -
, <
, >
и т.д.) и создавать для своих типов специальные процедуры явного и неявного преобразования (а также ознакомитесь с причинами, по которым они могут понадобиться).
Затем будут обсуждаться темы, которые особенно полезны при работе с API-интерфейсами LINQ (хотя они применимы и за рамками контекста LINQ): расширяющие методы и анонимные типы.
В завершение главы вы узнаете, каким образом создавать контекст "небезопасного" кода, чтобы напрямую манипулировать неуправляемыми указателями. Хотя использование указателей в приложениях C# — довольно редкое явление, понимание того, как это делается, может пригодиться в определенных обстоятельствах, связанных со сложными сценариями взаимодействия.
Программистам хорошо знаком процесс доступа к индивидуальным элементам, содержащимся внутри простого массива, с применением операции индекса (
[]
). Вот пример:
// Организовать цикл по аргументам командной строки
// с использованием операции индекса.
for(int i = 0; i < args.Length; i++)
{
Console.WriteLine("Args: {0}", args[i]);
}
// Объявить массив локальных целочисленных значений.
int[] myInts = { 10, 9, 100, 432, 9874};
// Применить операцию индекса для доступа к каждому элементу.
for(int j = 0; j < myInts.Length; j++)
{
Console.WriteLine("Index {0} = {1} ", j, myInts[j]);
}
Console.ReadLine();
Приведенный код ни в коем случае не является чем-то совершенно новым. Но в языке C# предлагается возможность проектирования специальных классов и структур, которые могут индексироваться подобно стандартному массиву, за счет определения индексаторного метода.Такое языковое средство наиболее полезно при создании специальных классов коллекций (обобщенных или необобщенных).
Прежде чем выяснять, каким образом реализуется специальный индексатор, давайте начнем с того, что продемонстрируем его в действии. Пусть к специальному типу
PersonCollection
, разработанному в главе 10 (в проекте IssuesWithNonGenericCollections
), добавлена поддержка индексаторного метода. Хотя сам индексатор пока не добавлен, давайте посмотрим, как он используется внутри нового проекта консольного приложения по имени SimpleIndexer
:
using System;
using System.Collections.Generic;
using System.Data;
using SimpleIndexer;
// Индексаторы позволяют обращаться к элементам в стиле массива.
Console.WriteLine("***** Fun with Indexers *****\n");
PersonCollection myPeople = new PersonCollection();
// Добавить объекты с применением синтаксиса индексатора.
myPeople[0] = new Person("Homer", "Simpson", 40);
myPeople[1] = new Person("Marge", "Simpson", 38);
myPeople[2] = new Person("Lisa", "Simpson", 9);
myPeople[3] = new Person("Bart", "Simpson", 7);
myPeople[4] = new Person("Maggie", "Simpson", 2);
// Получить и отобразить элементы, используя индексатор.
for (int i = 0; i < myPeople.Count; i++)
{
Console.WriteLine("Person number: {0}", i); // номер лица
Console.WriteLine("Name: {0} {1}",
myPeople[i].FirstName, myPeople[i].LastName); // имя и фамилия
Console.WriteLine("Age: {0}", myPeople[i].Age); // возраст
Console.WriteLine();
}
Как видите, индексаторы позволяют манипулировать внутренней коллекцией подобъектов подобно стандартному массиву. Но тут возникает серьезный вопрос: каким образом сконфигурировать класс
PersonCollection
(или любой другой класс либо структуру) для поддержки такой функциональности? Индексатор представлен как слегка видоизмененное определение свойства С#. В своей простейшей форме индексатор создается с применением синтаксиса this[]
. Ниже показано необходимое обновление класса PersonCollection
:
using System.Collections;
namespace SimpleIndexer
{
// Добавить индексатор к существующему определению класса.
public class PersonCollection : IEnumerable
{
private ArrayList arPeople = new ArrayList();
...
// Специальный индексатор для этого класса.
public Person this[int index]
{
get => (Person)arPeople[index];
set => arPeople.Insert(index, value);
}
}
}
Если не считать факт использования ключевого слова
this
с квадратными скобками, то индексатор похож на объявление любого другого свойства С#. Например, роль области get
заключается в возвращении корректного объекта вызывающему коду. Здесь мы достигаем цели делегированием запроса к индексатору объекта ArrayList
, т.к. данный класс также поддерживает индексатор. Область set
контролирует добавление новых объектов Person
, что достигается вызовом метода Insert()
объекта ArrayList
.
Индексаторы являются еще одной формой "синтаксического сахара", учитывая то, что такую же функциональность можно получить с применением "нормальных" открытых методов наподобие
AddPerson()
или GetPerson()
. Тем не менее, поддержка индексаторных методов в специальных типах коллекций обеспечивает хорошую интеграцию с инфраструктурой библиотек базовых классов .NET Core.
Несмотря на то что создание индексаторных методов является вполне обычным явлением при построении специальных коллекций, не забывайте, что обобщенные типы предлагают такую функциональность в готовом виде. В следующем методе используется обобщенный список
List
объектов Person
. Обратите внимание, что индексатор List
можно просто применять непосредственно:
using System.Collections.Generic;
static void UseGenericListOfPeople()
{
List myPeople = new List();
myPeople.Add(new Person("Lisa", "Simpson", 9));
myPeople.Add(new Person("Bart", "Simpson", 7));
// Изменить первый объект лица с помощью индексатора.
myPeople[0] = new Person("Maggie", "Simpson", 2);
// Получить и отобразить каждый элемент, используя индексатор.
for (int i = 0; i < myPeople.Count; i++)
{
Console.WriteLine("Person number: {0}", i);
Console.WriteLine("Name: {0} {1}",
myPeople[i].FirstName, myPeople[i].LastName);
Console.WriteLine("Age: {0}", myPeople[i].Age);
Console.WriteLine();
}
}
В текущей версии класса
PersonCollection
определен индексатор, позволяющий вызывающему коду идентифицировать элементы с применением числовых значений. Однако вы должны понимать, что это не требование индексаторного метода. Предположим, что вы предпочитаете хранить объекты Person
, используя тип System.Collections.Generic.Dictionary
, а не ArrayList
. Поскольку типы Dictionary
разрешают доступ к содержащимся внутри них элементам с применением ключа (такого как фамилия лица), индексатор можно было бы определить следующим образом:
using System.Collections;
using System.Collections.Generic;
namespace SimpleIndexer
{
public class PersonCollectionStringIndexer : IEnumerable
{
private Dictionary listPeople =
new Dictionary();
// Этот индексатор возвращает объект лица на основе строкового индекса.
public Person this[string name]
{
get => (Person)listPeople[name];
set => listPeople[name] = value;
}
public void ClearPeople()
{
listPeople.Clear();
}
public int Count => listPeople.Count;
IEnumerator IEnumerable.GetEnumerator() => listPeople.GetEnumerator();
}
}
Теперь вызывающий код способен взаимодействовать с содержащимися внутри объектами
Person
:
Console.WriteLine("***** Fun with Indexers *****\n");
PersonCollectionStringIndexer myPeopleStrings =
new PersonCollectionStringIndexer();
myPeopleStrings["Homer"] =
new Person("Homer", "Simpson", 40);
myPeopleStrings["Marge"] =
new Person("Marge", "Simpson", 38);
// Получить объект лица Homer и вывести данные.
Person homer = myPeopleStrings["Homer"];
Console.ReadLine();
И снова, если бы обобщенный тип
Dictionary
, напрямую, то функциональность индексаторного метода была бы получена в готовом виде без построения специального необобщенного класса, поддерживающего строковый индексатор. Тем не менее, имейте в виду, что тип данных любого индексатора будет основан на том, как поддерживающий тип коллекции позволяет вызывающему коду извлекать элементы.
Индексаторные методы могут быть перегружены в отдельном классе или структуре. Таким образом, если имеет смысл предоставить вызывающему коду возможность доступа к элементам с применением числового индекса или строкового значения, то в одном типе можно определить несколько индексаторов. Например, в ADO.NET (встроенный API-интерфейс .NET для доступа к базам данных) класс
DataSe
t поддерживает свойство по имени Tables
, которое возвращает строго типизированную коллекцию DataTableCollection
. В свою очередь тип DataTableCollection
определяет три индексатора для получения и установки объектов DataTable
— по порядковой позиции, по дружественному строковому имени и по строковому имени с дополнительным пространством имен:
public sealed class DataTableCollection : InternalDataCollectionBase
{
...
// Перегруженные индексаторы.
public DataTable this[int index] { get; }
public DataTable this[string name] { get; }
public DataTable this[string name, string tableNamespace] { get; }
}
Поддержка индексаторных методов вполне обычна для типов в библиотеках базовых классов. Поэтому даже если текущий проект не требует построения специальных индексаторов для классов и структур, помните о том, что многие типы уже поддерживают такой синтаксис.
Допускается также создавать индексаторный метод, который принимает несколько параметров. Предположим, что есть специальная коллекция, хранящая элементы в двумерном массиве. В таком случае индексаторный метод можно определить следующим образом:
public class SomeContainer
{
private int[,] my2DintArray = new int[10, 10];
public int this[int row, int column]
{ /* получить или установить значение в двумерном массиве */ }
}
Если только вы не строите высокоспециализированный класс коллекций, то вряд ли будете особо нуждаться в создании многомерного индексатора. Пример ADO.NET еще раз демонстрирует, насколько полезной может оказаться такая конструкция. Класс
DataTable
в ADO.NET по существу представляет собой коллекцию строк и столбцов, похожую на миллиметровку или на общую структуру электронной таблицы Microsoft Excel.
Хотя объекты
DataTable
обычно наполняются без вашего участия с использованием связанного "адаптера данных", в приведенном ниже коде показано, как вручную создать находящийся в памяти объект DataTable
, который содержит три столбца (для имени, фамилии и возраста каждой записи). Обратите внимание на то, как после добавления одной строки в DataTable
с помощью многомерного индексатора производится обращение ко всем столбцам первой (и единственной) строки. (Если вы собираетесь следовать примеру, тогда импортируйте в файл кода пространство имен System.Data
.)
static void MultiIndexerWithDataTable()
{
// Создать простой объект DataTable с тремя столбцами.
DataTable myTable = new DataTable();
myTable.Columns.Add(new DataColumn("FirstName"));
myTable.Columns.Add(new DataColumn("LastName"));
myTable.Columns.Add(new DataColumn("Age"));
// Добавить строку в таблицу.
myTable.Rows.Add("Mel", "Appleby", 60);
// Использовать многомерный индексатор для вывода деталей первой строки.
Console.WriteLine("First Name: {0}", myTable.Rows[0][0]);
Console.WriteLine("Last Name: {0}", myTable.Rows[0][1]);
Console.WriteLine("Age : {0}", myTable.Rows[0][2]);
}
Начиная с главы 21, мы продолжим рассмотрение ADO.NET, так что не переживайте, если что-то в приведенном выше коде выглядит незнакомым. Пример просто иллюстрирует, что индексаторные методы способны поддерживать множество измерений, а при правильном применении могут упростить взаимодействие с объектами, содержащимися в специальных коллекциях.
Индексаторы могут определяться в выбранном типе интерфейса .NET Core, чтобы позволить поддерживающим типам предоставлять специальные реализации. Ниже показан простой пример интерфейса, который задает протокол для получения строковых объектов с использованием числового индексатора:
public interface IStringContainer
{
string this[int index] { get; set; }
}
При таком определении интерфейса любой класс или структура, которые его реализуют, должны поддерживать индексатор с чтением/записью, манипулирующий элементами с применением числового значения. Вот частичная реализация класса подобного вида:
class SomeClass : IStringContainer
{
private List myStrings = new List();
public string this[int index]
{
get => myStrings[index];
set => myStrings.Insert(index, value);
}
}
На этом первая крупная тема настоящей главы завершена. А теперь давайте перейдем к исследованиям языкового средства, которое позволяет строить специальные классы и структуры, уникальным образом реагирующие на внутренние операции С#. Итак, займемся концепцией перегрузки операций.
Как и любой язык программирования, C# имеет заготовленный набор лексем, используемых для выполнения базовых операций над встроенными типами. Например, вы знаете, что операция
+
может применяться к двум целым числам, чтобы получить большее целое число:
// Операция + с целыми числами.
int a = 100;
int b = 240;
int c = a + b; // с теперь имеет значение 340
Опять-таки, здесь нет ничего нового, но задумывались ли вы когда-нибудь о том, что одну и ту же операцию
+
разрешено использовать с большинством встроенных типов данных С#? Скажем, взгляните на следующий код:
// Операция + со строками.
string s1 = "Hello";
string s2 = " world!";
string s3 = s1 + s2; // s3 теперь имеет значение "Hello World!"
Операция
+
функционирует специфическим образом на основе типа предоставленных данных (в рассматриваемом случае строкового или целочисленного). Когда операция +
применяется к числовым типам, в результате выполняется суммирование операндов, а когда к строковым типам — то конкатенация строк.
Язык C# дает возможность строить специальные классы и структуры, которые также уникально реагируют на один и тот же набор базовых лексем (вроде операции
+
). Хотя не каждая операция C# может быть перегружена, перегрузку допускают многие операции (табл. 11.1).
Чтобы проиллюстрировать процесс перегрузки бинарных операций, рассмотрим приведенный ниже простой класс
Point
, который определен в новом проекте консольного приложения по имени OverloadedOps
:
using System;
namespace OverloadedOps
{
// Простой будничный класс С#.
public class Point
{
public int X {get; set;}
public int Y {get; set;}
public Point(int xPos, int yPos)
{
X = xPos;
Y = yPos;
}
public override string ToString()
=> $"[{this.X}, {this.Y}]";
}
}
Рассуждая логически, суммирование объектов
Point
имеет смысл. Например, сложение двух переменных Point
должно давать новый объект Point
с просуммированными значениями свойств X
и Y
. Конечно, полезно также и вычитать один объект Point
из другого. В идеале желательно иметь возможность записи примерно такого кода:
using System;
using OverloadedOps;
// Сложение и вычитание двух точек?
Console.WriteLine("***** Fun with Overloaded Operators *****\n");
// Создать две точки.
Point ptOne = new Point(100, 100);
Point ptTwo = new Point(40, 40);
Console.WriteLine("ptOne = {0}", ptOne);
Console.WriteLine("ptTwo = {0}", ptTwo);
// Сложить две точки, чтобы получить большую точку?
Console.WriteLine("ptOne + ptTwo: {0} ", ptOne + ptTwo);
// Вычесть одну точку из другой, чтобы получить меньшую?
Console.WriteLine("ptOne - ptTwo: {0} ", ptOne - ptTwo);
Console.ReadLine();
Тем не менее, с существующим видом класса
Point
вы получите ошибки на этапе компиляции, потому что типу Point
не известно, как реагировать на операцию +
или -
. Для оснащения специального типа способностью уникально реагировать на встроенные операции язык C# предлагает ключевое слово operator, которое может использоваться только в сочетании с ключевым словом static
. При перегрузке бинарной операции (такой как +
и -
) вы чаще всего будете передавать два аргумента того же типа, что и класс, определяющий операцию (Point
в этом примере):
// Более интеллектуальный тип Point.
public class Point
{
...
// Перегруженная операция +.
public static Point operator + (Point p1, Point p2)
=> new Point(p1.X + p2.X, p1.Y + p2.Y);
// Перегруженная операция -.
public static Point operator - (Point p1, Point p2)
=> new Point(p1.X - p2.X, p1.Y - p2.Y);
}
Логика, положенная в основу операции
+
, предусматривает просто возвращение нового объекта Point
, основанного на сложении соответствующих полей входных параметров Point
. Таким образом, когда вы пишете p1 + р2
, "за кулисами" происходит следующий скрытый вызов статического метода operator +
:
// Псевдокод: Point рЗ = Point.operator+ (pi, р2)
Point p3 = p1 + p2;
Аналогично выражение
pi - р2
отображается так:
// Псевдокод: Point р4 = Point.operator- (pi, р2)
Point p4 = p1 - p2;
После произведенной модификации типа
Point
программа скомпилируется и появится возможность сложения и вычитания объектов Point
, что подтверждает представленный далее вывод:
***** Fun with Overloaded Operators *****
ptOne = [100, 100]
ptTwo = [40, 40]
ptOne + ptTwo: [140, 140]
ptOne - ptTwo: [60, 60]
При перегрузке бинарной операции передавать ей два параметра того же самого типа не обязательно. Если это имеет смысл, тогда один из аргументов может относиться к другому типу. Например, ниже показана перегруженная операция
+
, которая позволяет вызывающему коду получить новый объект Point
на основе числовой коррекции:
public class Point
{
...
public static Point operator + (Point p1, int change)
=> new Point(p1.X + change, p1.Y + change);
public static Point operator + (int change, Point p1)
=> new Point(p1.X + change, p1.Y + change);
}
Обратите внимание, что если вы хотите передавать аргументы в любом порядке, то потребуется реализовать обе версии метода (т.е. нельзя просто определить один из методов и рассчитывать, что компилятор автоматически будет поддерживать другой). Теперь новые версии операции
+
можно применять следующим образом:
// Выводит [110, 110].
Point biggerPoint = ptOne + 10;
Console.WriteLine("ptOne + 10 = {0}", biggerPoint);
// Выводит [120, 120].
Console.WriteLine("10 + biggerPoint = {0}", 10 + biggerPoint);
Console.WriteLine();
Если до перехода на C# вы имели дело с языком C++, тогда вас может удивить отсутствие возможности перегрузки операций сокращенного присваивания (
+=
, -+
и т.д.). Не беспокойтесь. В C# операции сокращенного присваивания автоматически эмулируются в случае перегрузки связанных бинарных операций. Таким образом, если в классе Point
уже перегружены операции +
и -
, то можно написать приведенный далее код:
// Перегрузка бинарных операций автоматически обеспечивает
// перегрузку сокращенных операций.
...
// Автоматически перегруженная операция +=
Point ptThree = new Point(90, 5);
Console.WriteLine("ptThree = {0}", ptThree);
Console.WriteLine("ptThree += ptTwo: {0}", ptThree += ptTwo);
// Автоматически перегруженная операция -=
Point ptFour = new Point(0, 500);
Console.WriteLine("ptFour = {0}", ptFour);
Console.WriteLine("ptFour -= ptThree: {0}", ptFour -= ptThree);
Console.ReadLine();
В языке C# также разрешено перегружать унарные операции, такие как
++
и --
. При перегрузке унарной операции также должно использоваться ключевое слово static
с ключевым словом operator
, но в этом случае просто передается единственный параметр того же типа, что и класс или структура, где операция определена. Например, дополните реализацию Point
следующими перегруженными операциями:
public class Point
{
...
// Добавить 1 к значениям X/Y входного объекта Point.
public static Point operator ++(Point p1)
=> new Point(p1.X+1, p1.Y+1);
// Вычесть 1 из значений X/Y входного объекта Point.
public static Point operator --(Point p1)
=> new Point(p1.X-1, p1.Y-1);
}
В результате появляется возможность инкрементировать и декрементировать значения
X
и Y
класса Point
:
...
// Применение унарных операций ++ и -- к объекту Point.
Point ptFive = new Point(1, 1);
Console.WriteLine("++ptFive = {0}", ++ptFive); // [2, 2]
Console.WriteLine("--ptFive = {0}", --ptFive); // [1, 1]
// Применение тех же операций в виде постфиксного инкремента/декремента.
Point ptSix = new Point(20, 20);
Console.WriteLine("ptSix++ = {0}", ptSix++); // [20, 20]
Console.WriteLine("ptSix-- = {0}", ptSix--); // [21, 21]
Console.ReadLine();
В предыдущем примере кода специальные операции
++
и --
применяются двумя разными способами. В языке C++ допускается перегружать операции префиксного и постфиксного инкремента/декремента по отдельности. В C# это невозможно. Однако возвращаемое значение инкремента/декремента автоматически обрабатывается корректно (т.е. для перегруженной операции ++
выражение pt++
дает значение неизмененного объекта, в то время как результатом ++pt
будет новое значение, устанавливаемое перед использованием в выражении).
Как упоминалось в главе 6, метод
System.Object.Equals()
может быть перегружен для выполнения сравнений на основе значений (а не ссылок) между ссылочными типами. Если вы решили переопределить Equals()
(часто вместе со связанным методом System.Object.GetHashCode()
), то легко переопределите и операции проверки эквивалентности (==
и !=
). Взгляните на обновленный тип Point
:
// В данной версии типа Point также перегружены операции == и !=.
public class Point
{
...
public override bool Equals(object o)
=> o.ToString() == this.ToString();
public override int GetHashCode()
=> this.ToString().GetHashCode();
// Теперь перегрузить операции == и !=.
public static bool operator ==(Point p1, Point p2)
=> p1.Equals(p2);
public static bool operator !=(Point p1, Point p2)
=> !p1.Equals(p2);
}
Обратите внимание, что для выполнения всей работы в реализациях операций
==
и !=
просто вызывается перегруженный метод Equals()
. Вот как теперь можно применять класс Point
:
// Использование перегруженных операций эквивалентности.
...
Console.WriteLine("ptOne == ptTwo : {0}", ptOne == ptTwo);
Console.WriteLine("ptOne != ptTwo : {0}", ptOne != ptTwo);
Console.ReadLine();
Как видите, сравнение двух объектов с использованием хорошо знакомых операций
==
и !=
выглядит намного интуитивно понятнее, чем вызов метода Object.Equals()
. При перегрузке операций эквивалентности для определенного класса имейте в виду, что C# требует, чтобы в случае перегрузки операции ==
обязательно перегружалась также и операция !=
(компилятор напомнит, если вы забудете это сделать).
В главе 8 было показано, каким образом реализовывать интерфейс
IComparable
для сравнения двух похожих объектов. В действительности для того же самого класса можно также перегрузить операции сравнения (<
, >
, <=
и >=
). Как и в случае операций эквивалентности, язык C# требует, чтобы при перегрузке операции <
обязательно перегружалась также операция >
. Если класс Point
перегружает указанные операции сравнения, тогда пользователь объекта может сравнивать объекты Point
:
// Использование перегруженных операций < и >.
...
Console.WriteLine("ptOne < ptTwo : {0}", ptOne < ptTwo);
Console.WriteLine("ptOne > ptTwo : {0}", ptOne > ptTwo);
Console.ReadLine();
Когда интерфейс
IComparable
(или, что еще лучше, его обобщенный эквивалент) реализован, перегрузка операций сравнения становится тривиальной. Вот модифицированное определение класса:
// Объекты Point также можно сравнивать посредством операций сравнения.
public class Point : IComparable
{
...
public int CompareTo(Point other)
{
if (this.X > other.X && this.Y > other.Y)
{
return 1;
}
if (this.X < other.X && this.Y < other.Y)
{
return -1;
}
return 0;
}
public static bool operator <(Point p1, Point p2)
=> p1.CompareTo(p2) < 0;
public static bool operator >(Point p1, Point p2)
=> p1.CompareTo(p2) > 0;
public static bool operator <=(Point p1, Point p2)
=> p1.CompareTo(p2) <= 0;
public static bool operator >=(Point p1, Point p2)
=> p1.CompareTo(p2) >= 0;
}
Как уже упоминалось, язык C# предоставляет возможность построения типов, которые могут уникальным образом реагировать на разнообразные встроенные хорошо известные операции. Перед добавлением поддержки такого поведения в классы вы должны удостовериться в том, что операции, которые планируется перегружать, имеют какой-нибудь смысл в реальности.
Например, пусть перегружена операция умножения для класса
MiniVan
, представляющего минивэн. Что по своей сути будет означать перемножение двух объектов MiniVan
? В нем нет особого смысла. На самом деле коллеги по команде даже могут быть озадачены, когда увидят следующее применение класса MiniVan
:
// Что?! Понять это непросто...
MiniVan newVan = myVan * yourVan;
Перегрузка операций обычно полезна только при построении атомарных типов данных. Векторы, матрицы, текст, точки, фигуры, множества и т.п. будут подходящими кандидатами на перегрузку операций, но люди, менеджеры, автомобили, подключения к базе данных и веб-страницы — нет. В качестве эмпирического правила запомните, что если перегруженная операция затрудняет понимание пользователем функциональности типа, то не перегружайте ее. Используйте такую возможность с умом.
Давайте теперь обратимся к теме, тесно связанной с перегрузкой операций, а именно — к специальным преобразованиям типов. Чтобы заложить фундамент для последующего обсуждения, кратко вспомним понятие явных и неявных преобразований между числовыми данными и связанными типами классов.
В терминах встроенных числовых типов (
sbyte
, int
, float
и т.д.) явное преобразование требуется, когда вы пытаетесь сохранить большее значение в контейнере меньшего размера, т.к. подобное действие может привести к утере данных. По существу тем самым вы сообщаете компилятору, что отдаете себе отчет в том, что делаете. И наоборот — неявное преобразование происходит автоматически, когда вы пытаетесь поместить меньший тип в больший целевой тип, что не должно вызвать потерю данных:
int a = 123;
long b = a; // Неявное преобразование из int в long.
int c = (int) b; // Явное преобразование из long в int.
В главе 6 было показано, что типы классов могут быть связаны классическим наследованием (отношение "является"). В таком случае процесс преобразования C# позволяет осуществлять приведение вверх и вниз по иерархии классов. Например, производный класс всегда может быть неявно приведен к базовому классу. Тем не менее, если вы хотите сохранить объект базового класса в переменной производного класса, то должны выполнить явное приведение:
// Два связанных типа классов.
class Base{}
class Derived : Base{}
// Неявное приведение производного класса к базовому.
Base myBaseType;
myBaseType = new Derived();
// Для сохранения ссылки на базовый класс в переменной
// производного класса требуется явное преобразование.
Derived myDerivedType = (Derived)myBaseType;
Продемонстрированное явное приведение работает из-за того, что классы
Base
и Derived
связаны классическим наследованием, а объект myBaseType
создан как экземпляр Derived
. Однако если myBaseType
является экземпляром Base
, тогда приведение вызывает генерацию исключения InvalidCastException
. При наличии сомнений по поводу успешности приведения вы должны использовать ключевое слово as
, как обсуждалось в главе 6. Ниже показан переделанный пример:
// Неявное приведение производного класса к базовому.
Base myBaseType2 = new();
// Сгенерируется исключение InvalidCastException :
// Derived myDerivedType2 = (Derived)myBaseType2 as Derived;
// Исключения нет, myDerivedType2 равен null:
Derived myDerivedType2 = myBaseType2 as Derived;
Но что если есть два типа классов в разных иерархиях без общего предка (кроме
System.Object
), которые требуют преобразований? Учитывая, что они не связаны классическим наследованием, типичные операции приведения здесь не помогут (и вдобавок компилятор сообщит об ошибке).
В качестве связанного замечания обратимся к типам значений (структурам). Предположим, что имеются две структуры с именами
Square
и Rectangle
. Поскольку они не могут задействовать классическое наследование (т.к. запечатаны), не существует естественного способа выполнить приведение между этими по внешнему виду связанными типами.
Несмотря на то что в структурах можно было бы создать вспомогательные методы (наподобие
Rectangle.ToSquare()
), язык C# позволяет строить специальные процедуры преобразования, которые дают типам возможность реагировать на операцию приведения ()
. Следовательно, если корректно сконфигурировать структуры, тогда для явного преобразования между ними можно будет применять такой синтаксис:
// Преобразовать Rectangle в Square!
Rectangle rect = new Rectangle
{
Width = 3;
Height = 10;
}
Square sq = (Square)rect;
Начните с создания нового проекта консольного приложения по имени
CustomConversions
. В языке C# предусмотрены два ключевых слова, explicit
и implicit
, которые можно использовать для управления тем, как типы должны реагировать на попытку преобразования. Предположим, что есть следующие определения структур:
using System;
namespace CustomConversions
{
public struct Rectangle
{
public int Width {get; set;}
public int Height {get; set;}
public Rectangle(int w, int h)
{
Width = w;
Height = h;
}
public void Draw()
{
for (int i = 0; i < Height; i++)
{
for (int j = 0; j < Width; j++)
{
Console.Write("*");
}
Console.WriteLine();
}
}
public override string ToString()
=> $"[Width = {Width}; Height = {Height}]";
}
}
using System;
namespace CustomConversions
{
public struct Square
{
public int Length {get; set;}
public Square(int l) : this()
{
Length = l;
}
public void Draw()
{
for (int i = 0; i < Length; i++)
{
for (int j = 0; j < Length; j++)
{
Console.Write("*");
}
Console.WriteLine();
}
}
public override string ToString() => $"[Length = {Length}]";
// Rectangle можно явно преобразовывать в Square.
public static explicit operator Square(Rectangle r)
{
Square s = new Square {Length = r.Height};
return s;
}
}
}
Обратите внимание, что в текущей версии типа
Square
определена явная операция преобразования. Подобно перегрузке операций процедуры преобразования используют ключевое слово operator в сочетании с ключевым словом explicit
или implicit
и должны быть определены как static
. Входным параметром является сущность, из которой выполняется преобразование, а типом операции — сущность, в которую производится преобразование.
В данном случае предположение заключается в том, что квадрат (будучи геометрической фигурой с четырьмя сторонами равной длины) может быть получен из высоты прямоугольника. Таким образом, вот как преобразовать
Rectangle
в Square
:
using System;
using CustomConversions;
Console.WriteLine("***** Fun with Conversions *****\n");
// Создать экземпляр Rectangle.
Rectangle r = new Rectangle(15, 4);
Console.WriteLine(r.ToString());
r.Draw();
Console.WriteLine();
// Преобразовать r в Square на основе высоты Rectangle
.
Square s = (Square)r;
Console.WriteLine(s.ToString());
s.Draw();
Console.ReadLine();
Ниже показан вывод:
***** Fun with Conversions *****
[Width = 15; Height = 4]
***************
***************
***************
***************
[Length = 4]
****
****
****
****
Хотя преобразование
Rectangle
в Square
в пределах той же самой области действия может быть не особенно полезным, взгляните на следующий метод, который спроектирован для приема параметров типа Square
:
// Этот метод требует параметр типа Square.
static void DrawSquare(Square sq)
{
Console.WriteLine(sq.ToString());
sq.Draw();
}
Благодаря наличию операции явного преобразования в типе
Square
методу DrawSquare()
на обработку можно передавать типы Rectangle
, применяя явное приведение:
...
// Преобразовать Rectangle в Square для вызова метода.
Rectangle rect = new Rectangle(10, 5);
DrawSquare((Square)rect);
Console.ReadLine();
Теперь, когда экземпляры
Rectangle
можно явно преобразовывать в экземпляры Square
, давайте рассмотрим несколько дополнительных явных преобразований. Учитывая, что квадрат симметричен по всем сторонам, полезно предусмотреть процедуру преобразования, которая позволит вызывающему коду привести целочисленный тип к типу Square
(который, естественно, будет иметь длину стороны, равную переданному целочисленному значению). А что если вы захотите модифицировать еще и Square
так, чтобы вызывающий код мог выполнять приведение из Square
в int
? Вот как выглядит логика вызова:
...
// Преобразование int в Square.
Square sq2 = (Square)90;
Console.WriteLine("sq2 = {0}", sq2);
// Преобразование Square в int.
int side = (int)sq2;
Console.WriteLine("Side length of sq2 = {0}", side);
Console.ReadLine();
Ниже показаны изменения, внесенные в структуру
Square
:
public struct Square
{
...
public static explicit operator Square(int sideLength)
{
Square newSq = new Square {Length = sideLength};
return newSq;
}
public static explicit operator int (Square s) => s.Length;
}
По правде говоря, преобразование
Square
в int
может показаться не слишком интуитивно понятной (или полезной) операцией (скорее всего, вы просто будете передавать нужные значения конструктору). Тем не менее, оно указывает на важный факт, касающийся процедур специальных преобразований: компилятор не беспокоится о том, из чего и во что происходит преобразование, до тех пор, пока вы пишете синтаксически корректный код.
Таким образом, как и с перегрузкой операций, возможность создания операции явного приведения для заданного типа вовсе не означает необходимость ее создания. Обычно этот прием будет наиболее полезным при создании типов структур, учитывая, что они не могут принимать участие в классическом наследовании (где приведение обеспечивается автоматически).
До сих пор мы создавали различные специальные операции явного преобразования. Но что насчет следующего неявного преобразования?
...
Square s3 = new Square {Length = 83};
// Попытка сделать неявное приведение?
Rectangle rect2 = s3;
Console.ReadLine();
Данный код не скомпилируется, т.к. вы не предоставили процедуру неявного преобразования для типа
Rectangle
. Ловушка здесь вот в чем: определять одновременно функции явного и неявного преобразования не разрешено, если они не различаются по типу возвращаемого значения или по списку параметров. Это может показаться ограничением; однако вторая ловушка связана с тем, что когда тип определяет процедуру неявного преобразования, то вызывающий код вполне законно может использовать синтаксис явного приведения!
Запутались? Чтобы прояснить ситуацию, давайте добавим к структуре
Rectangle
процедуру неявного преобразования с применением ключевого слова implicit
(обратите внимание, что в показанном ниже коде предполагается, что ширина результирующего прямоугольника вычисляется умножением стороны квадрата на 2):
public struct Rectangle
{
...
public static implicit operator Rectangle(Square s)
{
Rectangle r = new Rectangle
{
Height = s.Length,
Width = s.Length * 2 // Предположим, что ширина нового
// квадрата будет равна (Length х 2)..
};
return r;
}
}
После такой модификации можно выполнять преобразование между типами:
...
// Неявное преобразование работает!
Square s3 = new Square { Length= 7};
Rectangle rect2 = s3;
Console.WriteLine("rect2 = {0}", rect2);
// Синтаксис явного приведения также работает!
Square s4 = new Square {Length = 3};
Rectangle rect3 = (Rectangle)s4;
Console.WriteLine("rect3 = {0}", rect3);
Console.ReadLine();
На этом обзор определения операций специального преобразования завершен. Как и с перегруженными операциями, помните о том, что данный фрагмент синтаксиса представляет собой просто сокращенное обозначение для "нормальных" функций-членов и потому всегда необязателен. Тем не менее, в случае правильного использования специальные структуры могут применяться более естественным образом, поскольку будут трактоваться как настоящие типы классов, связанные наследованием.
В версии .NET 3.5 появилась концепция расширяющих методов, которая позволила добавлять новые методы или свойства к классу либо структуре, не модифицируя исходный тип непосредственно. Когда такой прием может оказаться полезным? Рассмотрим следующие ситуации.
Пусть есть класс, находящийся в производстве. Со временем выясняется, что имеющийся класс должен поддерживать несколько новых членов. Изменение текущего определения класса напрямую сопряжено с риском нарушения обратной совместимости со старыми кодовыми базами, использующими его, т.к. они могут не скомпилироваться с последним улучшенным определением класса. Один из способов обеспечения обратной совместимости предусматривает создание нового класса, производного от существующего, но тогда придется сопровождать два класса. Как все мы знаем, сопровождение кода является самой скучной частью деятельности разработчика программного обеспечения.
Представим другую ситуацию. Предположим, что имеется структура (или, может быть, запечатанный класс), и необходимо добавить новые члены, чтобы получить полиморфное поведение в рамках системы. Поскольку структуры и запечатанные классы не могут быть расширены, единственный выбор заключается в том, чтобы добавить желаемые члены к типу, снова рискуя нарушить обратную совместимость!
За счет применения расширяющих методов появляется возможность модифицировать типы, не создавая подклассов и не изменяя код типа напрямую. Загвоздка в том, что новая функциональность предлагается типом, только если в текущем проекте будут присутствовать ссылки на расширяющие методы.
Первое ограничение, связанное с расширяющими методами, состоит в том, что они должны быть определены внутри статического класса (см. главу 5), а потому каждый расширяющий метод должен объявляться с ключевым словом
static
. Вторая проблема в том, что все расширяющие методы помечаются как таковые посредством ключевого слова this
в качестве модификатора первого (и только первого) параметра заданного метода. Параметр, помеченный с помощью this
, представляет расширяемый элемент.
В целях иллюстрации создайте новый проект консольного приложения под названием
ExtensionMethods
. Предположим, что создается класс по имени МуExtensions
, в котором определены два расширяющих метода. Первый расширяющий метод позволяет объекту любого типа взаимодействовать с новым методом DisplayDefiningAssembly()
, который использует типы из пространства имен System.Reflection
для отображения имени сборки, содержащей данный тип.
На заметку! API-интерфейс рефлексии формально рассматривается в главе 17. Если эта тема для вас нова, тогда просто запомните, что рефлексия позволяет исследовать структуру сборок, типов и членов типов во время выполнения.
Второй расширяющий метод по имени
ReverseDigits()
позволяет любому значению типа int
получить новую версию самого себя с обратным порядком следования цифр. Например, если целочисленное значение 1234
вызывает ReverseDigits()
, то в результате возвратится 4321
. Взгляните на следующую реализацию класса (не забудьте импортировать пространство имен System.Reflection
):
using System;
using System.Reflection;
namespace MyExtensionMethods
{
static class MyExtensions
{
// Этот метод позволяет объекту любого типа
// отобразить сборку, в которой он определен
public static void DisplayDefiningAssembly(this object obj)
{
Console.WriteLine("{0} lives here: => {1}\n",
obj.GetType().Name,
Assembly.GetAssembly(obj.GetType()).GetName().Name);
}
// Этот метод позволяет любому целочисленному значению изменить
// порядок следования десятичных цифр на обратный.
// Например, для 56 возвратится 65.
public static int ReverseDigits(this int i)
{
// Транслировать int в string и затем получить все его символы.
char[] digits = i.ToString().ToCharArray();
// Изменить порядок следования элементов массива.
Array.Reverse(digits);
// Поместить обратно в строку.
string newDigits = new string(digits);
// Возвратить модифицированную строку как int.
return int.Parse(newDigits);
}
}
}
Снова обратите внимание на то, что первый параметр каждого расширяющего метода снабжен ключевым словом
this
, находящимся перед определением типа параметра. Первый параметр расширяющего метода всегда представляет расширяемый тип. Учитывая, что метод DisplayDefiningAssembly()
был прототипирован для расширения System.Object
, этот новый член теперь присутствует в каждом типе, поскольку Object
является родительским для всех типов платформы .NET Core. Однако метод ReverseDigits()
прототипирован для расширения только целочисленных типов, и потому если к нему обращается какое-то другое значение, то возникнет ошибка на этапе компиляции.
На заметку! Запомните, что каждый расширяющий метод может иметь множество параметров, но только первый параметр разрешено помечать посредством
this
. Дополнительные параметры будут трактоваться как нормальные входные параметры, применяемые методом.
Располагая созданными расширяющими методами, рассмотрим следующий код, в котором они используются с разнообразными типами из библиотек базовых классов:
using System;
using MyExtensionMethods;
Console.WriteLine("***** Fun with Extension Methods *****\n");
// В int появилась новая отличительная черта!
int myInt = 12345678;
myInt.DisplayDefiningAssembly();
// И в SoundPlayer!
System.Data.DataSet d = new System.Data.DataSet();
d.DisplayDefiningAssembly();
// Использовать новую функциональность int.
Console.WriteLine("Value of myInt: {0}", myInt);
Console.WriteLine("Reversed digits of myInt: {0}",
myInt.ReverseDigits());
Console.ReadLine();
Ниже показан вывод:
***** Fun with Extension Methods *****
Int32 lives here: => System.Private.CoreLib
DataSet lives here: => System.Data.Common
Value of myInt: 12345678
Reversed digits of myInt: 87654321
Когда определяется класс, содержащий расширяющие методы, он вне всяких сомнений будет принадлежать какому-то пространству имен. Если это пространство имен отличается от пространства имен, где расширяющие методы применяются, тогда придется использовать ключевое слово
using
языка С#, которое позволит файлу кода иметь доступ ко всем расширяющим методам интересующего типа. Об этом важно помнить, потому что если явно не импортировать корректное пространство имен, то в таком файле кода C# расширяющие методы будут недоступными.
Хотя на первый взгляд может показаться, что расширяющие методы глобальны по своей природе, на самом деле они ограничены пространствами имен, где определены, или пространствами имен, которые их импортируют. Вспомните, что вы поместили класс
MyExtensions
в пространство имен MyExtensionMethods
, как показано ниже:
namespace MyExtensionMethods
{
static class MyExtensions
{
...
}
}
Для использования расширяющих методов класса
MyExtensions
необходимо явно импортировать пространство имен MyExtensionMethods
, как делалось в рассмотренных ранее примерах операторов верхнего уровня.
К настоящему моменту вы видели, как расширять классы (и косвенно структуры, которые следуют тому же синтаксису) новой функциональностью через расширяющие методы. Также есть возможность определить расширяющий метод, который способен расширять только класс или структуру, реализующую корректный интерфейс. Например, можно было бы заявить следующее: если класс или структура реализует интерфейс
IEnumerable
, тогда этот тип получит новые члены. Разумеется, вполне допустимо требовать, чтобы тип поддерживал вообще любой интерфейс, включая ваши специальные интерфейсы.
В качестве примера создайте новый проект консольного приложения по имени
InterfaceExtensions
. Цель здесь заключается в том, чтобы добавить новый метод к любому типу, который реализует интерфейс IEnumerable
, что охватывает все массивы и многие классы необобщенных коллекций (вспомните из главы 10, что обобщенный интерфейс IEnumerable
расширяет необобщенный интерфейс IEnumerable
). Добавьте к проекту следующий расширяющий класс:
using System;
namespace InterfaceExtensions
{
static class AnnoyingExtensions
{
public static void PrintDataAndBeep(
this System.Collections.IEnumerable iterator)
{
foreach (var item in iterator)
{
Console.WriteLine(item);
Console.Beep();
}
}
}
}
Поскольку метод
PrintDataAndBeep()
может использоваться любым классом или структурой, реализующей интерфейс IEnumerable
, мы можем протестировать его с помощью такого кода:
using System;
using System.Collections.Generic;
using InterfaceExtensions;
Console.WriteLine("***** Extending Interface Compatible Types *****\n");
// System.Array реализует IEnumerable!
string[] data =
{ "Wow", "this", "is", "sort", "of", "annoying",
"but", "in", "a", "weird", "way", "fun!"};
data.PrintDataAndBeep();
Console.WriteLine();
// List реализует IEnumerable!
List myInts = new List() {10, 15, 20};
myInts.PrintDataAndBeep();
Console.ReadLine();
На этом исследование расширяющих методов C# завершено. Помните, что данное языковое средство полезно, когда необходимо расширить функциональность типа, но вы не хотите создавать подклассы (или не можете, если тип запечатан) в целях обеспечения полиморфизма. Как вы увидите позже, расширяющие методы играют ключевую роль в API-интерфейсах LINQ. На самом деле вы узнаете, что в API-интерфейсах LINQ одним из самых часто расширяемых элементов является класс или структура, реализующая обобщенную версию интерфейса
IEnumerable
.
До выхода версии C# 9.0 для применения оператора
foreach
с экземплярами класса в этом классе нужно было напрямую определять метод GetEnumerator()
. Начиная с версии C# 9.0, оператор foreach
исследует расширяющие методы класса и в случае, если обнаруживает метод GetEnumerator()
, то использует его для получения реализации IEnumerator
, относящейся к данному классу. Чтобы удостовериться в сказанном, добавьте новый проект консольного приложения по имени ForEachWithExtensionMethods
и поместите в него упрощенные версии классов Car
и Garage
из главы 8:
// Car.cs
using System;
namespace ForEachWithExtensionMethods
{
class Car
{
// Свойства класса Car.
public int CurrentSpeed {get; set;} = 0;
public string PetName {get; set;} = "";
// Конструкторы.
public Car() {}
public Car(string name, int speed)
{
CurrentSpeed = speed;
PetName = name;
}
// Выяснить, не перегрелся ли двигатель Car.
}
}
// Garage.cs
namespace ForEachWithExtensionMethods
{
class Garage
{
public Car[] CarsInGarage { get; set; }
// При запуске заполнить несколькими объектами Car.
public Garage()
{
CarsInGarage = new Car[4];
CarsInGarage[0] = new Car("Rusty", 30);
CarsInGarage[1] = new Car("Clunker", 55);
CarsInGarage[2] = new Car("Zippy", 30);
CarsInGarage[3] = new Car("Fred", 30);
}
}
}
Обратите внимание, что класс
Garage
не реализует интерфейс IEnumerable
и не имеет метода GetEnumerator()
. Метод GetEnumerator()
добавляется через показанный ниже класс GarageExtensions
:
namespace ForEachWithExtensionMethods
{
static class GarageExtensions
{
public static IEnumerator GetEnumerator(this Garage g)
=> g.CarsInGarage.GetEnumerator();
}
}
Код для тестирования этого нового средства будет таким же, как код, который применялся для тестирования метода
GetEnumerator()
в главе 8. Модифицируйте файл Program.cs
следующим образом:
using System;
using ForEachWithExtensionMethods;
Console.WriteLine("***** Support for Extension Method GetEnumerator *****\n");
Garage carLot = new Garage();
// Проход по всем объектам Car в коллекции?
foreach (Car c in carLot)
{
Console.WriteLine("{0} is going {1} MPH",
c.PetName, c.CurrentSpeed);
}
Вы увидите, что код работает, успешно выводя на консоль список объектов автомобилей и скоростей их движения:
***** Support for Extension Method GetEnumerator *****
Rusty is going 30 MPH
Clunker is going 55 MPH
Zippy is going 30 MPH
Fred is going 30 MPH
На заметку! Потенциальный недостаток нового средства заключается в том, что теперь с оператором
foreach
могут использоваться даже те классы, которые для этого не предназначались.
Программистам на объектно-ориентированных языках хорошо известны преимущества определения классов для представления состояния и функциональности заданного элемента, который требуется моделировать. Всякий раз, когда необходимо определить класс, предназначенный для многократного применения и предоставляющий обширную функциональность через набор методов, событий, свойств и специальных конструкторов, устоявшаяся практика предусматривает создание нового класса С#.
Тем не менее, возникают и другие ситуации, когда желательно определять класс просто в целях моделирования набора инкапсулированных (и каким-то образом связанных) элементов данных безо всяких ассоциированных методов, событий или другой специализированной функциональности. Кроме того, что если такой тип должен использоваться только небольшим набором методов внутри программы? Было бы довольно утомительно строить полное определение класса вроде показанного ниже, если хорошо известно, что класс будет применяться только в нескольких местах. Чтобы подчеркнуть данный момент, вот примерный план того, что может понадобиться делать, когда нужно создать "простой" тип данных, который следует обычной семантике на основе значений:
class SomeClass
{
// Определить набор закрытых переменных-членов...
// Создать свойство для каждой закрытой переменной-члена...
// Переопределить метод ToStringO для учета основных
// переменных-членов...
// Переопределить методы GetHashCode() и Equals() для работы
// с эквивалентностью на основе значений...
}
Как видите, задача не обязательно оказывается настолько простой. Вам потребуется не только написать большой объем кода, но еще и сопровождать дополнительный класс в системе. Для временных данных подобного рода было бы удобно формировать специальный тип на лету. Например, пусть необходимо построить специальный метод, который принимает какой-то набор входных параметров.Такие параметры нужно использовать для создания нового типа данных, который будет применяться внутри области действия метода. Вдобавок желательно иметь возможность быстрого вывода данных с помощью метода
ToString()
и работы с другими членами System.Object
. Всего сказанного можно достичь с помощью синтаксиса анонимных типов.
Анонимный тип определяется с использованием ключевого слова
va
r (см. главу 3) в сочетании с синтаксисом инициализации объектов (см. главу 5). Ключевое слово var
должно применяться из-за того, что компилятор будет автоматически генерировать новое определение класса на этапе компиляции (причем имя этого класса никогда не встретится в коде С#). Синтаксис инициализации применяется для сообщения компилятору о необходимости создания в новом типе закрытых поддерживающих полей и (допускающих только чтение) свойств.
В целях иллюстрации создайте новый проект консольного приложения по имени
AnonymousTypes
. Затем добавьте в класс Program
показанный ниже метод, который формирует новый тип на лету, используя данные входных параметров:
static void BuildAnonymousType( string make, string color, int currSp )
{
// Построить анонимный тип с применением входных аргументов.
var car = new { Make = make, Color = color, Speed = currSp };
// Обратите внимание, что теперь этот тип можно
// использовать для получения данных свойств!
Console.WriteLine("You have a {0} {1} going {2} MPH",
car.Color, car.Make, car.Speed);
// Анонимные типы имеют специальные реализации каждого
// виртуального метода System.Object. Например:
Console.WriteLine("ToString() == {0}", car.ToString());
}
Обратите внимание, что помимо помещения кода внутрь функции анонимный тип можно также создавать непосредственно в строке:
Console.WriteLine("***** Fun with Anonymous Types *****\n");
// Создать анонимный тип, представляющий автомобиль.
var myCar = new { Color = "Bright Pink", Make = "Saab",
CurrentSpeed = 55 };
// Вывести на консоль цвет и производителя.
Console.WriteLine("My car is a {0} {1}.", myCar.Color, myCar.Make);
// Вызвать вспомогательный метод для построения
// анонимного типа с указанием аргументов.
BuildAnonymousType("BMW", "Black", 90);
Console.ReadLine();
В настоящий момент достаточно понимать, что анонимные типы позволяют быстро моделировать "форму" данных с небольшими накладными расходами. Они являются лишь способом построения на лету нового типа данных, который поддерживает базовую инкапсуляцию через свойства и действует в соответствии с семантикой на основе значений. Чтобы уловить суть последнего утверждения, давайте посмотрим, каким образом компилятор C# строит анонимные типы на этапе компиляции, и в особенности — как он переопределяет члены
System.Object
.
Все анонимные типы автоматически наследуются от
System.Object
и потому поддерживают все члены, предоставленные этим базовым классом. В результате можно вызывать метод ToString()
, GetHashCode()
, Equals()
или GetType()
на неявно типизированном объекте myCar
. Предположим, что в классе Program
определен следующий статический вспомогательный метод:
static void ReflectOverAnonymousType(object obj)
{
Console.WriteLine("obj is an instance of: {0}",
obj.GetType().Name);
Console.WriteLine("Base class of {0} is {1}",
obj.GetType().Name, obj.GetType().BaseType);
Console.WriteLine("obj.ToString() == {0}", obj.ToString());
Console.WriteLine("obj.GetHashCode() == {0}",
obj.GetHashCode());
Console.WriteLine();
}
Пусть вы вызвали метод
ReflectOverAnonymousType()
, передав ему объект myCar
в качестве параметра:
Console.WriteLine("***** Fun with Anonymous Types *****\n");
// Создать анонимный тип, представляющий автомобиль.
var myCar = new {Color = "Bright Pink", Make = "Saab",
CurrentSpeed = 55};
// Выполнить рефлексию того, что сгенерировал компилятор.
ReflectOverAnonymousType(myCar);
...
Console.ReadLine();
Вывод будет выглядеть примерно так:
***** Fun with Anonymous Types *****
obj is an instance of: <>f__AnonymousType0`3
Base class of <>f__AnonymousType0`3 is System.Object
obj.ToString() = { Color = Bright Pink, Make = Saab, CurrentSpeed = 55 }
obj.GetHashCode() = -564053045
Первым делом обратите внимание в примере на то, что объект
myCar
имеет тип <>f__AnonymousType0`3
(в вашем выводе имя типа может быть другим). Помните, что имя, назначаемое типу, полностью определяется компилятором и не доступно в коде C# напрямую.
Пожалуй, наиболее важно здесь то, что каждая пара "имя-значение", определенная с использованием синтаксиса инициализации объектов, отображается на идентично именованное свойство, доступное только для чтения, и соответствующее закрытое поддерживающее поле, которое допускает только инициализацию. Приведенный ниже код C# приблизительно отражает сгенерированный компилятором класс, применяемый для представления объекта
myCar
(его можно просмотреть посредством утилиты ildasm.exe
):
private sealed class <>f__AnonymousType0'3'<'j__TPar',
'j__TPar', j__TPar>'
extends [System.Runtime][System.Object]
{
// Поля только для инициализации.
private initonly j__TPar i__Field;
private initonly j__TPar i__Field;
private initonly j__TPar i__Field;
// Стандартный конструктор.
public <>f__AnonymousType0(j__TPar Color,
j__TPar Make, j__TPar CurrentSpeed);
// Переопределенные методы.
public override bool Equals(object value);
public override int GetHashCode();
public override string ToString();
// Свойства только для чтения.
j__TPar Color { get; }
j__TPar CurrentSpeed { get; }
j__TPar Make { get; }
}
Все анонимные типы автоматически являются производными от
System.Object
и предоставляют переопределенные версии методов Equals()
, GetHashCode()
и ToString()
. Реализация ToString()
просто строит строку из пар "имя-значение". Вот пример:
public override string ToString()
{
StringBuilder builder = new StringBuilder();
builder.Append("{ Color = ");
builder.Append(this.i__Field);
builder.Append(", Make = ");
builder.Append(this.i__Field);
builder.Append(", CurrentSpeed = ");
builder.Append(this.i__Field);
builder.Append(" }");
return builder.ToString();
}
Реализация
GetHashCode()
вычисляет хеш-значение, используя каждую переменную-член анонимного типа в качестве входных данных для типа System.Collections.Generic.EqualityComparer
. С такой реализацией GetHashCode()
два анонимных типа будут выдавать одинаковые хеш-значения тогда (и только тогда), когда они обладают одним и тем же набором свойств, которым присвоены те же самые значения. Благодаря подобной реализации анонимные типы хорошо подходят для помещения внутрь контейнера Hashtable
.
Наряду с тем, что реализация переопределенных методов
ToString()
и GetHashCode()
прямолинейна, вас может интересовать, как был реализован метод Equals()
. Например, если определены две переменные "анонимных автомобилей" с одинаковыми наборами пар "имя-значение", то должны ли эти переменные считаться эквивалентными? Чтобы увидеть результат такого сравнения, дополните класс Program
следующим новым методом:
static void EqualityTest()
{
// Создать два анонимных класса с идентичными наборами
// пар "имя-значение".
var firstCar = new { Color = "Bright Pink", Make = "Saab",
CurrentSpeed = 55 };
var secondCar = new { Color = "Bright Pink", Make = "Saab",
CurrentSpeed = 55 };
// Считаются ли они эквивалентными, когда используется Equals()?
if (firstCar.Equals(secondCar))
{
Console.WriteLine("Same anonymous object!");
// Тот же самый анонимный объект
}
else
{
Console.WriteLine("Not the same anonymous object!");
// He тот же самый анонимный объект
}
// Можно ли проверить их эквивалентность с помощью операции ==?
if (firstCar == secondCar)
{
Console.WriteLine("Same anonymous object!");
// Тот же самый анонимный объект
}
else
{
Console.WriteLine("Not the same anonymous object!");
// He тот же самый анонимный объект
}
// Имеют ли эти объекты в основе один и тот же тип?
if (firstCar.GetType().Name == secondCar.GetType().Name)
{
Console.WriteLine("We are both the same type!");
// Оба объекта имеют тот же самый тип
}
else
{
Console.WriteLine("We are different types!");
// Объекты относятся к разным типам
}
// Отобразить все детали.
Console.WriteLine();
ReflectOverAnonymousType(firstCar);
ReflectOverAnonymousType(secondCar);
}
В результате вызова метода
EqualityTest()
получается несколько неожиданный вывод:
My car is a Bright Pink Saab.
You have a Black BMW going 90 MPH
ToString() == { Make = BMW, Color = Black, Speed = 90 }
Same anonymous object!
Not the same anonymous object!
We are both the same type!
obj is an instance of: <>f__AnonymousType0`3
Base class of <>f__AnonymousType0`3 is System.Object
obj.ToString() == { Color = Bright Pink, Make = Saab, CurrentSpeed = 55 }
obj.GetHashCode() == -925496951
obj is an instance of: <>f__AnonymousType0`3
Base class of <>f__AnonymousType0`3 is System.Object
obj.ToString() == { Color = Bright Pink, Make = Saab, CurrentSpeed = 55 }
obj.GetHashCode() == -925496951
Как видите, первая проверка, где вызывается
Equals()
, возвращает true
, и потому на консоль выводится сообщение Same anonymous object!
(Тот же самый анонимный объект). Причина в том, что сгенерированный компилятором метод Equals()
при проверке эквивалентности применяет семантику на основе значений (т.е. проверяет значения каждого поля сравниваемых объектов).
Тем не менее, вторая проверка, в которой используется операция
==
, приводит к выводу на консоль строки Not the same anonymous object!
(He тот же самый анонимный объект), что на первый взгляд выглядит несколько нелогично. Такой результат обусловлен тем, что анонимные типы не получают перегруженных версий операций проверки равенства (==
и !=
), поэтому при проверке эквивалентности объектов анонимных типов с применением операций равенства C# (вместо метода Equals()
) проверяются ссылки, а не значения, поддерживаемые объектами.
Наконец, в финальной проверке (где исследуется имя лежащего в основе типа) обнаруживается, что объекты анонимных типов являются экземплярами одного и того же типа класса, сгенерированного компилятором (
f__AnonymousType0`3
в данном примере), т.к. firstCar
и secondCar
имеют одинаковые наборы свойств (Color
, Make
и CurrentSpeed
).
Это иллюстрирует важный, но тонкий аспект: компилятор будет генерировать новое определение класса, только когда анонимный тип содержит уникальные имена свойств. Таким образом, если вы объявляете идентичные анонимные типы (в смысле имеющие те же самые имена свойств) внутри сборки, то компилятор генерирует единственное определение анонимного типа.
Можно создавать анонимные типы, которые состоят из других анонимных типов. В качестве примера предположим, что требуется смоделировать заказ на приобретение, который хранит метку времени, цену и сведения о приобретаемом автомобиле. Вот новый (чуть более сложный) анонимный тип, представляющий такую сущность:
// Создать анонимный тип, состоящий из еще одного анонимного типа.
var purchaseItem = new {
TimeBought = DateTime.Now,
ItemBought = new {Color = "Red", Make = "Saab", CurrentSpeed = 55},
Price = 34.000};
ReflectOverAnonymousType(purchaseItem);
Сейчас вы уже должны понимать синтаксис, используемый для определения анонимных типов, но возможно все еще интересуетесь, где (и когда) применять такое языковое средство. Выражаясь кратко, объявления анонимных типов следует использовать умеренно, обычно только в случае применения набора технологий LINQ (см. главу 13). С учетом описанных ниже многочисленных ограничений анонимных типов вы никогда не должны отказываться от использования строго типизированных классов и структур просто из-за того, что это возможно.
• Контроль над именами анонимных типов отсутствует.
• Анонимные типы всегда расширяют
System.Object
.
• Поля и свойства анонимного типа всегда допускают только чтение.
• Анонимные типы не могут поддерживать события, специальные методы, специальные операции или специальные переопределения.
• Анонимные типы всегда неявно запечатаны.
• Экземпляры анонимных типов всегда создаются с применением стандартных конструкторов.
Однако при программировании с использованием набора технологий LINQ вы обнаружите, что во многих случаях такой синтаксис оказывается удобным, когда нужно быстро смоделировать общую форму сущности, а не ее функциональность.
Последняя тема главы касается средства С#, которое в подавляющем большинстве проектов .NET Core применяется реже всех остальных.
На заметку! В последующих примерах предполагается наличие у вас определенных навыков манипулирования указателями в языке C++ . Если это не так, тогда можете спокойно пропустить данную тему. В большинстве приложений C# указатели не используются.
В главе 4 вы узнали, что в рамках платформы .NET Core определены две крупные категории данных: типы значений и ссылочные типы. По правде говоря, на самом деле есть еще и третья категория: типы указателей. Для работы с типами указателей доступны специфичные операции и ключевые слова (табл. 11.2), которые позволяют обойти схему управления памятью исполняющей среды .NET 5 и взять дело в свои руки.
Перед погружением в детали следует еще раз подчеркнуть, что вам очень редко, если вообще когда-нибудь, понадобится использовать типы указателей. Хотя C# позволяет опуститься на уровень манипуляций указателями, помните, что исполняющая среда .NET Core не имеет абсолютно никакого понятия о ваших намерениях. Соответственно, если вы неправильно управляете указателем, то сами и будете отвечать за последствия. С учетом этих предупреждений возникает вопрос: когда в принципе может возникнуть необходимость работы с типами указателей? Существуют две распространенные ситуации.
• Нужно оптимизировать избранные части приложения, напрямую манипулируя памятью за рамками ее управления со стороны исполняющей среды .NET 5.
• Необходимо вызывать методы из DLL-библиотеки, написанной на С, либо из сервера СОМ, которые требуют передачи типов указателей в качестве параметров. Но даже в таком случае часто можно обойтись без применения типов указателей, отдав предпочтение типу
System.IntPtr
и членам типа System.Runtime.InteropServices.Marshal
.
Если вы решили задействовать данное средство языка С#, тогда придется информировать компилятор C# о своих намерениях, разрешив проекту поддерживать "небезопасный код". Создайте новый проект консольного приложения по имени
UnsafeCode
и включите поддержку небезопасного кода, добавив в файл UnsafeCode.csproj
следующие строки:
true
Для установки этого свойства в Visual Studio предлагается графический пользовательский интерфейс. Откройте окно свойств проекта. В раскрывающемся списке Configuration (Конфигурация) выберите вариант All Configurations (Все конфигурации), перейдите на вкладку Build (Сборка) и отметьте флажок Allow unsafe code (Разрешить небезопасный код), как показано на рис. 11.1.
Для работы с указателями в C# должен быть специально объявлен блок "небезопасного кода" с использованием ключевого слова
unsafe
(любой код, который не помечен ключевым словом unsafe
, автоматически считается "безопасным"). Например, в следующем файле Program.cs
объявляется область небезопасного кода внутри операторов верхнего уровня:
using System;
using UnsafeCode;
Console.WriteLine("***** Calling method with unsafe code *****");
unsafe
{
// Здесь работаем с указателями!
}
// Здесь работа с указателями невозможна!
В дополнение к объявлению области небезопасного кода внутри метода можно строить "небезопасные" структуры, классы, члены типов и параметры. Ниже приведено несколько примеров (типы
Node
и Node2
в текущем проекте определять не нужно):
// Эта структура целиком является небезопасной и может
// использоваться только в небезопасном контексте.
unsafe struct Node
{
public int Value;
public Node* Left;
public Node* Right;
}
// Эта структура безопасна, но члены Node2* - нет.
// Формально извне небезопасного контекста можно
// обращаться к Value, но не к Left и Right.
public struct Node2
{
public int Value;
// Эти члены доступны только в небезопасном контексте!
public unsafe Node2* Left;
public unsafe Node2* Right;
}
Методы (статические либо уровня экземпляра) также могут быть помечены как небезопасные. Предположим, что какой-то статический метод будет использовать логику указателей. Чтобы обеспечить возможность вызова данного метода только из небезопасного контекста, его можно определить так:
static unsafe void SquareIntPointer(int* myIntPointer)
{
// Возвести значение в квадрат просто для тестирования.
*myIntPointer *= *myIntPointer;
}
Конфигурация метода требует, чтобы вызывающий код обращался к методу
SquareIntPointer()
следующим образом:
unsafe
{
int myInt = 10;
// Нормально, мы находимся в небезопасном контексте.
SquareIntPointer(&myInt);
Console.WriteLine("myInt: {0}", myInt);
}
int myInt2 = 5;
// Ошибка на этапе компиляции!
// Это должно делаться в небезопасном контексте!
SquareIntPointer(&myInt2);
Console.WriteLine("myInt: {0}", myInt2);
Если вы не хотите вынуждать вызывающий код помещать такой вызов внутрь небезопасного контекста, то можете поместить все операторы верхнего уровня в блок
unsafe
. При использовании в качестве точки входа метода Main()
можете пометить Main()
ключевым словом unsafe
. В таком случае приведенный ниже код скомпилируется:
static unsafe void Main(string[] args)
{
int myInt2 = 5;
SquareIntPointer(&myInt2);
Console.WriteLine("myInt: {0}", myInt2);
}
Запустив такую версию кода, вы получите следующий вывод:
myInt: 25
На заметку! Важно отметить, что термин "небезопасный" был выбран небезосновательно. Прямой доступ к стеку и работа с указателями может приводить к неожиданным проблемам с вашим приложением, а также с компьютером, на котором оно функционирует. Если вам приходится иметь дело с небезопасным кодом, тогда будьте крайне внимательны.
После установления небезопасного контекста можно строить указатели и типы данных с помощью операции
*
, а также получать адрес указываемых данных посредством операции &
. В отличие от С или C++ в языке C# операция *
применяется только к лежащему в основе типу, а не является префиксом имени каждой переменной указателя. Например, взгляните на показанный далее код, демонстрирующий правильный и неправильный способы объявления указателей на целочисленные переменные:
// Нет! В C# это некорректно!
int *pi, *pj;
// Да! Так поступают в С#.
int* pi, pj;
Рассмотрим следующий небезопасный метод:
static unsafe void PrintValueAndAddress()
{
int myInt;
// Определить указатель на int и присвоить ему адрес myInt.
int* ptrToMyInt = &myInt;
// Присвоить значение myInt, используя обращение через указатель.
*ptrToMyInt = 123;
// Вывести некоторые значения.
Console.WriteLine("Value of myInt {0}", myInt);
// значение myInt
Console.WriteLine("Address of myInt {0:X}", (int)&ptrToMyInt);
// адрес myInt
}
В результате запуска этого метода из блока
unsafe
вы получите такой вывод:
**** Print Value And Address ****
Value of myInt 123
Address of myInt 90F7E698
Разумеется, объявлять указатели на локальные переменные, чтобы просто присваивать им значения (как в предыдущем примере), никогда не понадобится и к тому же неудобно. В качестве более практичного примера небезопасного кода предположим, что необходимо построить функцию обмена с использованием арифметики указателей:
unsafe static void UnsafeSwap(int* i, int* j)
{
int temp = *i;
*i = *j;
*j = temp;
}
Очень похоже на язык С, не так ли? Тем не менее, учитывая предшествующую работу, вы должны знать, что можно было бы написать безопасную версию алгоритма обмена с применением ключевого слова
ref
языка С#:
static void SafeSwap(ref int i, ref int j)
{
int temp = i;
i = j;
j = temp;
}
Функциональность обеих версий метода идентична, доказывая тем самым, что прямые манипуляции указателями в C# не являются обязательными. Ниже показана логика вызова, использующая безопасные операторы верхнего уровня, но с небезопасным контекстом:
Console.WriteLine("***** Calling method with unsafe code *****");
// Значения, подлежащие обмену.
int i = 10, j = 20;
// "Безопасный" обмен значений местами.
Console.WriteLine("\n***** Safe swap *****");
Console.WriteLine("Values before safe swap: i = {0}, j = {1}", i, j);
SafeSwap(ref i, ref j);
Console.WriteLine("Values after safe swap: i = {0}, j = {1}", i, j);
// "Небезопасный" обмен значений местами.
Console.WriteLine("\n***** Unsafe swap *****");
Console.WriteLine("Values before unsafe swap: i = {0}, j = {1}", i, j);
unsafe { UnsafeSwap(&i, &j); }
Console.WriteLine("Values after unsafe swap: i = {0}, j = {1}", i, j);
Console.ReadLine();
Теперь предположим, что определена простая безопасная структура
Point
:
struct Point
{
public int x;
public int y;
public override string ToString() => $"({x}, {y})";
}
В случае объявления указателя на тип
Point
для доступа к открытым членам структуры понадобится применять операцию доступа к полям (имеющую вид ->
). Как упоминалось в табл. 11.2, она представляет собой небезопасную версию стандартной (безопасной) операции точки (.
). В сущности, используя операцию обращения к указателю (*
), можно разыменовывать указатель для применения операции точки. Взгляните на следующий небезопасный метод:
static unsafe void UsePointerToPoint()
{
// Доступ к членам через указатель.
Point;
Point* p = &point;
p->x = 100;
p->y = 200;
Console.WriteLine(p->ToString());
// Доступ к членам через разыменованный указатель.
Point point2;
Point* p2 = &point2;
(*p2).x = 100;
(*p2).y = 200;
Console.WriteLine((*p2).ToString());
}
В небезопасном контексте может возникнуть необходимость в объявлении локальной переменной, для которой память выделяется непосредственно в стеке вызовов (и потому она не обрабатывается сборщиком мусора .NET Core). Для этого в языке C# предусмотрено ключевое слово
stackalloc
, которое является эквивалентом функции _аllоса
библиотеки времени выполнения С. Вот простой пример:
static unsafe string UnsafeStackAlloc()
{
char* p = stackalloc char[52];
for (int k = 0; k < 52; k++)
{
p[k] = (char)(k + 65)k;
}
return new string(p);
}
В предыдущем примере вы видели, что выделение фрагмента памяти внутри небезопасного контекста может делаться с помощью ключевого слова
stackalloc
. Из-за природы операции stackalloc
выделенная память очищается, как только выделяющий ее метод возвращает управление (т.к. память распределена в стеке). Однако рассмотрим более сложный пример. Во время исследования операции ->
создавался тип значения по имени Point
. Как и все типы значений, выделяемая его экземплярам память исчезает из стека по окончании выполнения. Предположим, что тип Point
взамен определен как ссылочный:
class PointRef // <= Renamed and retyped.
{
public int x;
public int y;
public override string ToString() => $"({x}, {y})";
}
Как вам известно, если в вызывающем коде объявляется переменная типа
Point
, то память для нее выделяется в куче, поддерживающей сборку мусора. И тут возникает животрепещущий вопрос: а что если небезопасный контекст пожелает взаимодействовать с этим объектом (или любым другим объектом из кучи)? Учитывая, что сборка мусора может произойти в любое время, вы только вообразите, какие проблемы возникнут при обращении к членам Point
именно в тот момент, когда происходит реорганизация кучи! Теоретически может случиться так, что небезопасный контекст попытается взаимодействовать с членом, который больше не доступен или был перемещен в другое место кучи после ее очистки с учетом поколений (что является очевидной проблемой).
Для фиксации переменной ссылочного типа в памяти из небезопасного контекста язык C# предлагает ключевое слово
fixed
. Оператор fixed
устанавливает указатель на управляемый тип и "закрепляет" такую переменную на время выполнения кода. Без fixed
от указателей на управляемые переменные было бы мало толку, поскольку сборщик мусора может перемещать переменные в памяти непредсказуемым образом. (На самом деле компилятор C# даже не позволит установить указатель на управляемую переменную, если оператор fixed
отсутствует.)
Таким образом, если вы создали объект
Point
и хотите взаимодействовать с его членами, тогда должны написать следующий код (либо иначе получить ошибку на этапе компиляции):
unsafe static void UseAndPinPoint()
{
PointRef pt = new PointRef
{
x = 5,
y = 6
};
// Закрепить указатель pt на месте, чтобы он не мог
// быть перемещен или уничтожен сборщиком мусора.
fixed (int* p = &pt.x)
{
// Использовать здесь переменную int*!
}
// Указатель pt теперь не закреплен и готов
// к сборке мусора после завершения метода.
Console.WriteLine ("Point is: {0}", pt);
}
Выражаясь кратко, ключевое слово
fixed
позволяет строить оператор, который фиксирует ссылочную переменную в памяти, чтобы ее адрес оставался постоянным на протяжении работы оператора (или блока операторов). Каждый раз, когда вы взаимодействуете со ссылочным типом из контекста небезопасного кода, закрепление ссылки обязательно.
Последнее ключевое слово С#, связанное с небезопасным кодом —
sizeof
. Как и в C++, ключевое слово sizeof
в C# используется для получения размера в байтах встроенного типа данных, но не специального типа, разве только в небезопасном контексте. Например, показанный ниже метод не нуждается в объявлении "небезопасным", т.к. все аргументы ключевого слова sizeof
относятся к встроенным типам:
static void UseSizeOfOperator()
{
Console.WriteLine("The size of short is {0}.", sizeof(short));
Console.WriteLine("The size of int is {0}.", sizeof(int));
Console.WriteLine("The size of long is {0}.", sizeof(long));
}
Тем не менее, если вы хотите получить размер специальной структуры
Point
, то метод UseSizeOfOperator()
придется модифицировать (обратите внимание на добавление ключевого слова unsafe
):
unsafe static void UseSizeOfOperator()
{
...
unsafe {
Console.WriteLine("The size of Point is {0}.", sizeof(Point));
}
}
Итак, обзор нескольких более сложных средств языка программирования C# завершен. Напоследок снова необходимо отметить, что в большинстве проектов .NET эти средства могут вообще не понадобиться (особенно указатели). Тем не менее, как будет показано в последующих главах, некоторые средства действительно полезны (и даже обязательны) при работе с API-интерфейсами LINQ, в частности расширяющие методы и анонимные типы.
Целью главы было углубление знаний языка программирования С#. Первым делом мы исследовали разнообразные более сложные конструкции в типах (индексаторные методы, перегруженные операции и специальные процедуры преобразования).
Затем мы рассмотрели роль расширяющих методов и анонимных типов. Как вы увидите в главе 13, эти средства удобны при работе с API-интерфейсами LINQ (хотя при желании их можно применять в коде повсеместно). Вспомните, что анонимные методы позволяют быстро моделировать "форму" типа, в то время как расширяющие методы дают возможность добавлять новую функциональность к типам без необходимости в определении подклассов.
Финальная часть главы была посвящена небольшому набору менее известных ключевых слов (
sizeof
, unsafe
и т.п.); наряду с ними рассматривалась работа с низкоуровневыми типами указателей. Как было установлено в процессе исследования типов указателей, в большинстве приложений C# их никогда не придется использовать.
Вплоть до настоящего момента в большинстве разработанных приложений к операторам верхнего уровня внутри файла
Program.cs
добавлялись разнообразные порции кода, тем или иным способом отправляющие запросы к заданному объекту. Однако многие приложения требуют, чтобы объект имел возможность обращаться обратно к сущности, которая его создала, используя механизм обратного вызова. Хотя механизмы обратного вызова могут применяться в любом приложении, они особенно важны в графических пользовательских интерфейсах, где элементы управления (такие как кнопки) нуждаются в вызове внешних методов при надлежащих обстоятельствах (когда произведен щелчок на кнопке, курсор мыши наведен на поверхность кнопки и т.д.).
В рамках платформы .NET Core предпочтительным средством определения и реагирования на обратные вызовы в приложении является тип делегата. По существу тип делегата .NET Core — это безопасный в отношении типов объект, "указывающий" на метод или список методов, которые могут быть вызваны позднее. Тем не менее, в отличие от традиционного указателя на функцию C++ делегаты представляют собой классы, которые обладают встроенной поддержкой группового и асинхронного вызова методов.
На заметку! В предшествующих версиях .NET делегаты обеспечивали вызов асинхронных методов с помощью
BeginInvoke()/EndInvoke()
. Хотя компилятор по-прежнему генерирует методы BeginInvoke()/EndInvoke()
, в .NET Core они не поддерживаются. Причина в том, что шаблон с IAsyncResult
и BeginInvoke()/EndInvoke()
, используемый делегатами, был заменен асинхронным шаблоном на основе задач. Асинхронное выполнение подробно обсуждается в главе 15.
В текущей главе вы узнаете, каким образом создавать и манипулировать типами делегатов, а также использовать ключевое слово event языка С#, которое облегчает работу с типами делегатов. По ходу дела вы также изучите несколько языковых средств С#, ориентированных на делегаты и события, в том числе анонимные методы и групповые преобразования методов.
Глава завершается исследованием лямбда-выражений. С помощью лямбда-операции C# (
=>
) можно указывать блок операторов кода (и подлежащие передаче им параметры) везде, где требуется строго типизированный делегат. Как будет показано, лямбда-выражение — не более чем замаскированный анонимный метод и является упрощенным подходом к работе с делегатами. Вдобавок та же самая операция (в .NEТ Framework 4.6 и последующих версиях) может применяться для реализации метода или свойства, содержащего единственный оператор, посредством лаконичного синтаксиса.
Прежде чем формально определить делегаты, давайте ненадолго оглянемся назад. Исторически сложилось так, что в API-интерфейсе Windows часто использовались указатели на функции в стиле С для создания сущностей под названием функции обратного вызова или просто обратные вызовы. С помощью обратных вызовов программисты могли конфигурировать одну функцию так, чтобы она обращалась к другой функции в приложении (т.е. делала обратный вызов). С применением такого подхода разработчики Windows-приложений имели возможность обрабатывать щелчки на кнопках, перемещение курсора мыши, выбор пунктов меню и общие двусторонние коммуникации между двумя сущностями в памяти.
В .NET и .NET Core обратные вызовы выполняются в безопасной в отношении типов объектно-ориентированной манере с использованием делегатов. Делегат — это безопасный в отношении типов объект, указывающий на другой метод или возможно на список методов приложения, которые могут быть вызваны в более позднее время.
В частности, делегат поддерживает три важных порции информации:
• адрес метода, вызовы которого он делает:
• аргументы (если есть) вызываемого метода:
• возвращаемое значение (если есть) вызываемого метода.
На заметку! Делегаты .NET Core могут указывать либо на статические методы, либо на методы экземпляра.
После того как делегат создан и снабжен необходимой информацией, он может во время выполнения динамически вызывать метод или методы, на которые указывает.
Для определения типа делегата в языке C# применяется ключевое слово
delegate
. Имя типа делегата может быть любым желаемым. Однако сигнатура определяемого делегата должна совпадать с сигнатурой метода или методов, на которые он будет указывать. Например, приведенный ниже тип делегата (по имени BinaryOp
) может указывать на любой метод, который возвращает целое число и принимает два целых числа в качестве входных параметров (позже в главе вы самостоятельно построите такой делегат, а пока он представлен лишь кратко):
// Этот делегат может указывать на любой метод, который принимает
// два целочисленных значения и возвращает целочисленное значение.
public delegate int BinaryOp(int x, int y);
Когда компилятор C# обрабатывает тип делегата, он автоматически генерирует запечатанный (
sealed
) класс, производный от System.MulticastDelegate
. Этот класс (в сочетании со своим базовым классом System.Delegate
) предоставляет необходимую инфраструктуру для делегата, которая позволяет хранить список методов, подлежащих вызову в будущем. Например, если вы изучите делегат BinaryOp
с помощью утилиты ildasm.exe
, то обнаружите показанные ниже детали (вскоре вы построите полный пример):
// -------------------------------------------------------
// TypDefName: SimpleDelegate.BinaryOp
// Extends : System.MulticastDelegate
// Method #1
// -------------------------------------------------------
// MethodName: .ctor
// ReturnType: Void
// 2 Arguments
// Argument #1: Object
// Argument #2: I
// Method #2
// -------------------------------------------------------
// MethodName: Invoke
// ReturnType: I4
// 2 Arguments
// Argument #1: I4
// Argument #2: I4
// 2 Parameters
// (1) ParamToken : Name : x flags: [none]
// (2) ParamToken : Name : y flags: [none] //
// Method #3
// -------------------------------------------------------
// MethodName: BeginInvoke
// ReturnType: Class System.IAsyncResult
// 4 Arguments
// Argument #1: I4
// Argument #2: I4
// Argument #3: Class System.AsyncCallback
// Argument #4: Object
// 4 Parameters
// (1) ParamToken : Name : x flags: [none]
// (2) ParamToken : Name : y flags: [none]
// (3) ParamToken : Name : callback flags: [none]
// (4) ParamToken : Name : object flags: [none]
//
// Method #4
// -------------------------------------------------------
// MethodName: EndInvoke
// ReturnType: I4 (int32)
// 1 Arguments
// Argument #1: Class System.IAsyncResult
// 1 Parameters
// (1) ParamToken : Name : result flags: [none]
Как видите, в сгенерированном компилятором классе
BinaryOp
определены три открытых метода. Главным методом в .NET Core является Invoke()
, т.к. он используется для вызова каждого метода, поддерживаемого объектом делегата, в синхронной манере; это означает, что вызывающий код должен ожидать завершения вызова, прежде чем продолжить свою работу. Довольно странно, но синхронный метод Invoke()
может не нуждаться в явном вызове внутри вашего кода С#. Вскоре будет показано, что Invoke()
вызывается "за кулисами", когда вы применяете соответствующий синтаксис С#.
На заметку! Несмотря на то что методы
BeginInvoke()
и EndInvoke()
генерируются, они не поддерживаются при запуске вашего кода под управлением .NET Core. Это может разочаровывать, поскольку в случае их использования вы получите ошибку не на этапе компиляции, а во время выполнения.
Так благодаря чему же компилятор знает, как определять метод
Invoke()
? Для понимания процесса ниже приведен код сгенерированного компилятором класса BinaryOp
(полужирным курсивом выделены элементы, указанные в определении типа делегата):
sealed class BinaryOp : System.MulticastDelegate
{
public int Invoke(int x, int y);
...
}
Первым делом обратите внимание, что параметры и возвращаемый тип для метода
Invoke()
в точности соответствуют определению делегата BinaryOp
.
Давайте рассмотрим еще один пример. Предположим, что определен тип делегата, который может указывать на любой метод, возвращающий значение
string
и принимающий три входных параметра типа System.Boolean
:
public delegate string MyDelegate (bool a, bool b, bool c);
На этот раз сгенерированный компилятором класс можно представить так:
sealed class MyDelegate : System.MulticastDelegate
{
public string Invoke(bool a, bool b, bool c);
...
}
Делегаты могут также "указывать" на методы, которые содержат любое количество параметров
out
и ref
(а также параметры типа массивов, помеченные с помощью ключевого слова params
). Пусть имеется следующий тип делегата:
public delegate string MyOtherDelegate(out bool a, ref bool b, int c);
Сигнатура метода
Invoke()
выглядит вполне ожидаемо.
Подводя итоги, отметим, что определение типа делегата C# дает в результате запечатанный класс со сгенерированным компилятором методом, в котором типы параметров и возвращаемые типы основаны на объявлении делегата. Базовый шаблон может быть приближенно описан с помощью следующего псевдокода:
// Это лишь псевдокод!
public sealed class ИмяДелегата : System.MulticastDelegate
{
public возвращаемоеЗначениеДелегата
Invoke(всеВходныеRefиOutПараметрыДелегата);
}
Итак, когда вы строите тип с применением ключевого слова
delegate
, то неявно объявляете тип класса, производного от System.MulticastDelegate
. Данный класс предоставляет своим наследникам доступ к списку, который содержит адреса методов, поддерживаемых типом делегата, а также несколько дополнительных методов (и перегруженных операций) для взаимодействия со списком вызовов. Ниже приведены избранные методы класса System.MulticastDelegate
:
public abstract class MulticastDelegate : Delegate
{
// Возвращает список методов, на которые "указывает" делегат.
public sealed override Delegate[] GetInvocationList();
// Перегруженные операции.
public static bool operator ==
(MulticastDelegate d1, MulticastDelegate d2);
public static bool operator !=
(MulticastDelegate d1, MulticastDelegate d2);
// Используются внутренне для управления списком методов,
// поддерживаемых делегатом.
private IntPtr _invocationCount;
private object _invocationList;
}
Класс
System.MulticastDelegate
получает дополнительную функциональность от своего родительского класса System.Delegate
. Вот фрагмент его определения:
public abstract class Delegate : ICloneable, ISerializable
{
// Методы для взаимодействия со списком функций.
public static Delegate Combine(params Delegate[] delegates);
public static Delegate Combine(Delegate a, Delegate b);
public static Delegate Remove(
Delegate source, Delegate value);
public static Delegate RemoveAll(
Delegate source, Delegate value);
// Перегруженные операции.
public static bool operator ==(Delegate d1, Delegate d2);
public static bool operator !=(Delegate d1, Delegate d2);
// Свойства, открывающие доступ к цели делегата.
public MethodInfo Method { get; }
public object Target { get; }
}
Имейте в виду, что вы никогда не сможете напрямую наследовать от таких базовых классов в своем коде (попытка наследования приводит к ошибке на этапе компиляции). Тем не менее, когда вы используете ключевое слово
delegate
, то тем самым неявно создаете класс, который "является" MulticastDelegate
. В табл. 12.1 описаны основные члены, общие для всех типов делегатов.
На первый взгляд делегаты могут показаться несколько запутанными. Рассмотрим для начала простой проект консольного приложения (по имени
SimpleDelegate
), в котором применяется определенный ранее тип делегата BinaryOp
. Ниже показан полный код с последующим анализом:
// SimpleMath.cs
namespace SimpleDelegate
{
// Этот класс содержит методы, на которые
// будет указывать BinaryOp.
public class SimpleMath
{
public static int Add(int x, int y) => x + y;
public static int Subtract(int x, int y) => x - y;
}
}
// Program.cs
using System;
using SimpleDelegate;
Console.WriteLine("***** Simple Delegate Example *****\n");
// Создать объект делегата BinaryOp, который
// "указывает" на SimpleMath.Add().
BinaryOp b = new BinaryOp(SimpleMath.Add);
// Вызвать метод Add() косвенно с использованием объекта делегата.
Console.WriteLine("10 + 10 is {0}", b(10, 10));
Console.ReadLine();
// Дополнительные определения типов должны находиться
// в конце операторов верхнего уровня.
// Этот делегат может указывать на любой метод,
// принимающий два целых числа и возвращающий целое число.
public delegate int BinaryOp(int x, int y);
На заметку! Вспомните из главы 3, что дополнительные определения типов (делегат
BinaryOp
в этом примере) должны располагаться после всех операторов верхнего уровня.
И снова обратите внимание на формат объявления типа делегата
BinaryOp
; он определяет, что объекты делегата BinaryOp
могут указывать на любой метод, принимающий два целочисленных значения и возвращающий целочисленное значение (действительное имя метода, на который он указывает, к делу не относится). Здесь мы создали класс по имени SimpleMath
, определяющий два статических метода, которые соответствуют шаблону, определяемому делегатом BinaryOp
.
Когда вы хотите присвоить целевой метод заданному объекту делегата, просто передайте имя нужного метода конструктору делегата:
// Создать объект делегата BinaryOp, который
// "указывает" на SimpleMath.Add().
BinaryOp b = new BinaryOp(SimpleMath.Add);
На данной стадии метод, на который указывает делегат, можно вызывать с использованием синтаксиса, выглядящего подобным прямому вызову функции:
// На самом деле здесь вызывается метод Invoke()!
Console.WriteLine("10 + 10 is {0}", b(10, 10));
"За кулисами" исполняющая среда вызывает сгенерированный компилятором метод
Invoke()
на вашем производном от MulticastDelegate
классе. В этом можно удостовериться, открыв сборку в утилите ildasm.exe
и просмотрев код CIL внутри метода Main()
:
.method private hidebysig static void Main(string[] args) cil managed
{
...
callvirt instance int32 BinaryOp::Invoke(int32, int32)
}
Язык C# вовсе не требует явного вызова метода
Invoke()
внутри вашего кода. Поскольку BinaryOp
может указывать на методы, которые принимают два аргумента, следующий оператор тоже допустим:
Console.WriteLine("10 + 10 is {0}", b.Invoke(10, 10));
Вспомните, что делегаты .NET Core безопасны в отношении типов. Следовательно, если вы попытаетесь передать делегату метод, который не соответствует его шаблону, то получите ошибку на этапе компиляции. В целях иллюстрации предположим, что в классе
SimpleMath
теперь определен дополнительный метод по имени SquareNumber()
, принимающий единственный целочисленный аргумент:
public class SimpleMath
{
public static int SquareNumber(int a) => a * a;
}
Учитывая, что делегат
BinaryOp
может указывать только на методы, которые принимают два целочисленных значения и возвращают целочисленное значение, представленный ниже код некорректен и приведет к ошибке на этапе компиляции:
// Ошибка на этапе компиляции! Метод не соответствует шаблону делегата!
BinaryOp b2 = new BinaryOp(SimpleMath.SquareNumber);
Давайте усложним текущий пример, создав в классе
Program
статический метод (по имени DisplayDelegatelnfо()
). Он будет выводить на консоль имена методов, поддерживаемых объектом делегата, а также имя класса, определяющего метод. Для этого организуется итерация по массиву System.Delegate
, возвращенному методом GetlnvocationList()
, с обращением к свойствам Target
и Method
каждого объекта:
static void DisplayDelegateInfo(Delegate delObj)
{
// Вывести имена всех членов в списке вызовов делегата.
foreach (Delegate d in delObj.GetInvocationList())
{
Console.WriteLine("Method Name: {0}", d.Method); // имя метода
Console.WriteLine("Type Name: {0}", d.Target); // имя типа
}
}
Предполагая, что в метод
Main()
добавлен вызов нового вспомогательного метода:
BinaryOp b = new BinaryOp(SimpleMath.Add);
DisplayDelegateInfo(b);
вывод приложения будет таким:
***** Simple Delegate Example *****
Method Name: Int32 Add(Int32, Int32)
Type Name:
10 + 10 is 20
Обратите внимание, что при обращении к свойству
Target
имя целевого класса (SimpleMath
) в настоящий момент не отображается. Причина в том, что делегат BinaryOp
указывает на статический метод, и потому объект для ссылки попросту отсутствует! Однако если сделать методы Add()
и Substract()
нестатическими (удалив ключевое слово static
из их объявлений), тогда можно будет создавать экземпляр класса SimpleMat
h и указывать методы для вызова с применением ссылки на объект:
using System;
using SimpleDelegate;
Console.WriteLine("***** Simple Delegate Example *****\n");
// Делегаты могут также указывать на методы экземпляра.
SimpleMath m = new SimpleMath();
BinaryOp b = new BinaryOp(m.Add);
// Вывести сведения об объекте.
DisplayDelegateInfo(b);
Console.WriteLine("10 + 10 is {0}", b(10, 10));
Console.ReadLine();
В данном случае вывод будет выглядеть следующим образом:
***** Simple Delegate Example *****
Method Name: Int32 Add(Int32, Int32)
Type Name: SimpleDelegate.SimpleMath
10 + 10 is 20
Очевидно, что предыдущий пример
SimpleDelegate
был чисто иллюстративным по своей природе, т.к. нет особых причин создавать делегат просто для того, чтобы сложить два числа. Рассмотрим более реалистичный пример, в котором делегаты применяются для определения класса Car
, обладающего способностью информировать внешние сущности о текущем состоянии двигателя. В таком случае нужно выполнить перечисленные ниже действия.
1. Определить новый тип делегата, который будет использоваться для отправки уведомлений вызывающему коду.
2. Объявить переменную-член этого типа делегата в классе
Car
.
3. Создать в классе
Car
вспомогательную функцию, которая позволяет вызывающему коду указывать метод для обратного вызова.
4. Реализовать метод
Accelerate()
для обращения к списку вызовов делегата в подходящих обстоятельствах.
Для начала создайте новый проект консольного приложения по имени
CarDelegate
. Определите в нем новый класс Car
, начальный код которого показан ниже:
using System;
using System.Linq;
namespace CarDelegate
{
public class Car
{
// Внутренние данные состояния.
public int CurrentSpeed { get; set; }
public int MaxSpeed { get; set; } = 100;
public string PetName { get; set; }
// Исправен ли автомобиль?
private bool _carIsDead;
// Конструкторы класса.
public Car() {}
public Car(string name, int maxSp, int currSp)
{
CurrentSpeed = currSp;
MaxSpeed = maxSp;
PetName = name;
}
}
}
А теперь модифицируйте его, выполнив первые три действия из числа указанных выше:
public class Car
{
...
// 1. Определить тип делегата.
public delegate void CarEngineHandler(string msgForCaller);
// 2. Определить переменную-член этого типа делегата.
private CarEngineHandler _listOfHandlers;
// 3. Добавить регистрационную функцию для вызывающего кода.
public void RegisterWithCarEngine(CarEngineHandler methodToCall)
{
_listOfHandlers = methodToCall;
}
}
В приведенном примере обратите внимание на то, что типы делегатов определяются прямо внутри области действия класса
Car
; безусловно, это необязательно, но помогает закрепить идею о том, что делегат естественным образом работает с таким отдельным классом. Тип делегата CarEngineHandler
может указывать на любой метод, который принимает значение string
как параметр и имеет void
в качестве возвращаемого типа.
Кроме того, была объявлена закрытая переменная-член делегата (
_listOfHandlers
) и вспомогательная функция (RegisterWithCarEngine()
), которая позволяет вызывающему коду добавлять метод в список вызовов делегата.
На заметку! Строго говоря, переменную-член типа делегата можно было бы определить как
public
, избежав тем самым необходимости в создании дополнительных методов регистрации. Тем не менее, за счет определения этой переменной-члена типа делегата как private
усиливается инкапсуляция и обеспечивается решение, более безопасное в отношении типов. Позже в главе при рассмотрении ключевого слова event языка C# мы еще вернемся к анализу рисков объявления переменных-членов с типами делегатов как public.
Теперь необходимо создать метод
Accelerate()
. Вспомните, что цель в том, чтобы позволить объекту Car
отправлять связанные с двигателем сообщения любому подписавшемуся прослушивателю. Вот необходимое обновление:
// 4. Реализовать метод Accelerate() для обращения к списку
// вызовов делегата в подходящих обстоятельствах.
public void Accelerate(int delta)
{
/// Если этот автомобиль сломан, то отправить сообщение об этом.
if (_carIsDead)
{
_listOfHandlers?.Invoke("Sorry, this car is dead...");
}
else
{
CurrentSpeed += delta;
// Автомобиль почти сломан?
if (10 == (MaxSpeed - CurrentSpeed))
{
_listOfHandlers?.Invoke("Careful buddy! Gonna blow!");
}
if (CurrentSpeed >= MaxSpeed)
{
_carIsDead = true;
}
else
{
Console.WriteLine("CurrentSpeed = {0}", CurrentSpeed);
}
}
}
Обратите внимание, что при попытке вызова методов, поддерживаемых переменной-членом
_listOfHandlers
, используется синтаксис распространения null
. Причина в том, что создание таких объектов посредством вызова вспомогательного метода RegisterWithCarEngine()
является задачей вызывающего кода. Если вызывающий код не вызывал RegisterWithCarEngine()
, а мы попытаемся обратиться к списку вызовов делегата, то получим исключение NullReferenceException
во время выполнения. Теперь, когда инфраструктура делегатов готова, внесите в файл Program.cs
следующие изменения:
using System;
using CarDelegate;
Console.WriteLine("** Delegates as event enablers **\n");
// Создать объект Car.
Car c1 = new Car("SlugBug", 100, 10);
// Сообщить объекту Car, какой метод вызывать,
// когда он пожелает отправить сообщение.
c1.RegisterWithCarEngine(
new Car.CarEngineHandler(OnCarEngineEvent));
// Увеличить скорость (это инициирует события).
Console.WriteLine("***** Speeding up *****");
for (int i = 0; i < 6; i++)
{
c1.Accelerate(20);
}
Console.ReadLine();
// Цель для входящих сообщений.
static void OnCarEngineEvent(string msg)
{
Console.WriteLine("\n*** Message From Car Object ***");
Console.WriteLine("=> {0}", msg);
Console.WriteLine("********************\n");
}
Код начинается с создания нового объекта
Car
. Поскольку вас интересуют события, связанные с двигателем, следующий шаг заключается в вызове специальной регистрационной функции RegisterWithCarEngine()
. Вспомните, что метод RegisterWithCarEngine()
ожидает получения экземпляра вложенного делегата CarEngineHandler
, и как в случае любого делегата, в параметре конструктора передается метод, на который он должен указывать. Трюк здесь в том, что интересующий метод находится в классе Program
! Обратите также внимание, что метод OnCarEngineEvent()
полностью соответствует связанному делегату, потому что принимает string
и возвращает void
. Ниже показан вывод приведенного примера:
***** Delegates as event enablers *****
***** Speeding up *****
CurrentSpeed = 30
CurrentSpeed = 50
CurrentSpeed = 70
***** Message From Car Object *****
=> Careful buddy! Gonna blow!
***********************************
CurrentSpeed = 90
***** Message From Car Object *****
=> Sorry, this car is dead...
***********************************
Вспомните, что делегаты .NET Core обладают встроенной возможностью группового вызова. Другими словами, объект делегата может поддерживать целый список методов для вызова, а не просто единственный метод. Для добавления нескольких методов к объекту делегата вместо прямого присваивания применяется перегруженная операция
+=
. Чтобы включить групповой вызов в классе Car
, можно модифицировать метод RegisterWithCarEngine()
:
public class Car
{
// Добавление поддержки группового вызова.
// Обратите внимание на использование операции +=,
// а не обычной операции присваивания (=).
public void RegisterWithCarEngine(
CarEngineHandler methodToCall)
{
_listOfHandlers += methodToCall;
}
...
}
Когда операция
+=
используется с объектом делегата, компилятор преобразует ее в вызов статического метода Delegate.Combine()
. На самом деле можно было бы вызывать Delegate.Combine()
напрямую, однако операция +=
предлагает более простую альтернативу. Хотя нет никакой необходимости в модификации текущего метода RegisterWithCarEngine()
, ниже представлен пример применения Delegate.Combine()
вместо операции +=
:
public void RegisterWithCarEngine( CarEngineHandler methodToCall )
{
if (_listOfHandlers == null)
{
_listOfHandlers = methodToCall;
}
else
{
_listOfHandlers =
Delegate.Combine(_listOfHandlers, methodToCall)
as CarEngineHandler;
}
}
В любом случае вызывающий код теперь может регистрировать множественные цели для одного и того же обратного вызова. Второй обработчик выводит входное сообщение в верхнем регистре просто ради отображения:
Console.WriteLine("***** Delegates as event enablers *****\n");
// Создать объект Car.
Car c1 = new Car("SlugBug", 100, 10);
// Зарегистрировать несколько обработчиков событий.
c1.RegisterWithCarEngine(
new Car.CarEngineHandler(OnCarEngineEvent));
c1.RegisterWithCarEngine(
new Car.CarEngineHandler(OnCarEngineEvent2));
// Увеличить скорость (это инициирует события).
Console.WriteLine("***** Speeding up *****");
for (int i = 0; i < 6; i++)
{
c1.Accelerate(20);
}
Console.ReadLine();
// Теперь есть ДВА метода, которые будут
// вызываться Car при отправке уведомлений.
static void OnCarEngineEvent(string msg)
{
Console.WriteLine("\n*** Message From Car Object ***");
Console.WriteLine("=> {0}", msg);
Console.WriteLine("*********************************\n");
}
static void OnCarEngineEvent2(string msg)
{
Console.WriteLine("=> {0}", msg.ToUpper());
}
В классе
Delegate
также определен статический метод Remove()
, который позволяет вызывающему коду динамически удалять отдельные методы из списка вызовов объекта делегата. В итоге у вызывающего кода появляется возможность легко "отменять подписку" на заданное уведомление во время выполнения. Хотя метод Delegate.Remove()
допускается вызывать в коде напрямую, разработчики C# могут использовать в качестве удобного сокращения операцию -=
. Давайте добавим в класс Car
новый метод, который позволяет вызывающему коду исключать метод из списка вызовов:
public class Car
{
...
public void UnRegisterWithCarEngine(CarEngineHandler methodToCall)
{
_listOfHandlers -= methodToCall;
}
}
При таком обновлении класса
Car
прекратить получение уведомлений от второго обработчика можно за счет изменения вызывающего кода следующим образом:
Console.WriteLine("***** Delegates as event enablers *****\n");
// Создать объект Car.
Car c1 = new Car("SlugBug", 100, 10);
c1.RegisterWithCarEngine(
new Car.CarEngineHandler(OnCarEngineEvent));
// На этот раз сохранить объект делегата, чтобы позже
// можно было отменить регистрацию.
Car.CarEngineHandler handler2 =
new Car.CarEngineHandler(OnCarEngineEvent2);
c1.RegisterWithCarEngine(handler2);
// Увеличить скорость (это инициирует события).
Console.WriteLine("***** Speeding up *****");
for (int i = 0; i < 6; i++)
{
c1.Accelerate(20);
}
// Отменить регистрацию второго обработчика.
c1.UnRegisterWithCarEngine(handler2);
// Сообщения в верхнем регистре больше не выводятся.
Console.WriteLine("***** Speeding up *****");
for (int i = 0; i < 6; i++)
{
c1.Accelerate(20);
}
Console.ReadLine();
Отличие этого кода в том, что здесь создается объект
Car.CarEngineHandler
, который сохраняется в локальной переменной, чтобы впоследствии можно было отменить подписку на получение уведомлений. Таким образом, при увеличении скорости объекта Car
во второй раз версия входного сообщения в верхнем регистре больше выводиться не будет, поскольку данная цель исключена из списка вызовов делегата.
В предыдущем примере
CarDelegate
явно создавались экземпляры класса делегата Car.CarEngineHandler
для регистрации и отмены регистрации на получение уведомлений:
Console.WriteLine("***** Delegates as event enablers *****\n");
Car c1 = new Car("SlugBug", 100, 10);
c1.RegisterWithCarEngine(new Car.CarEngineHandler(OnCarEngineEvent));
Car.CarEngineHandler handler2 =
new Car.CarEngineHandler(OnCarEngineEvent2);
c1.RegisterWithCarEngine(handler2);
...
Конечно, если необходимо вызывать любые унаследованные члены класса
MulticastDelegate
или Delegate
, то проще всего вручную создать переменную делегата. Однако в большинстве случаев иметь дело с внутренним устройством объекта делегата не требуется. Объект делегата обычно придется применять только для передачи имени метода в параметре конструктора.
Для простоты в языке C# предлагается сокращение, называемое групповым преобразованием методов. Это средство позволяет указывать вместо объекта делегата прямое имя метода, когда вызываются методы, которые принимают делегаты в качестве аргументов.
На заметку! Позже в главе вы увидите, что синтаксис группового преобразования методов можно также использовать для упрощения регистрации событий С#.
В целях иллюстрации внесите в файл
Program.cs
показанные ниже изменения, где групповое преобразование методов применяется для регистрации и отмены регистрации подписки на уведомления:
...
Console.WriteLine("***** Method Group Conversion *****\n");
Car c2 = new Car();
// Зарегистрировать простое имя метода.
c2.RegisterWithCarEngine(OnCarEngineEvent);
Console.WriteLine("***** Speeding up *****");
for (int i = 0; i < 6; i++)
{
c2.Accelerate(20);
}
// Отменить регистрацию простого имени метода.
c2.UnRegisterWithCarEngine(OnCarEngineEvent);
// Уведомления больше не поступают!
for (int i = 0; i < 6; i++)
{
c2.Accelerate(20);
}
Console.ReadLine();
Обратите внимание, что мы не создаем напрямую ассоциированный объект делегата, а просто указываем метод, который соответствует ожидаемой сигнатуре делегата (в данном случае метод, возвращающий
void
и принимающий единственный аргумент string
). Имейте в виду, что компилятор C# по-прежнему обеспечивает безопасность в отношении типов. Таким образом, если метод OnCarEngineEvent()
не принимает string
и не возвращает void
, тогда возникнет ошибка на этапе компиляции.
В главе 10 упоминалось о том, что язык C# позволяет определять обобщенные типы делегатов. Например, предположим, что необходимо определить тип делегата, который может вызывать любой метод, возвращающий
void
и принимающий единственный параметр. Если передаваемый аргумент может изменяться, то это легко смоделировать с использованием параметра типа. Взгляните на следующий код внутри нового проекта консольного приложения по имени GenericDelegate
:
Console.WriteLine("***** Generic Delegates *****\n");
// Зарегистрировать цели.
MyGenericDelegate strTarget =
new MyGenericDelegate(StringTarget);
strTarget("Some string data");
// Использовать синтаксис группового преобразования методов
MyGenericDelegate intTarget = IntTarget;
intTarget(9);
Console.ReadLine();
static void StringTarget(string arg)
{
Console.WriteLine("arg in uppercase is: {0}", arg.ToUpper());
}
static void IntTarget(int arg)
{
Console.WriteLine("++arg is: {0}", ++arg);
}
// Этот обобщенный делегат может вызывать любой метод, который
// возвращает void и принимает единственный параметр типа Т.
public delegate void MyGenericDelegate(T arg);
Как видите, в типе делегата
MyGenericDelegate
определен единственный параметр, представляющий аргумент для передачи цели делегата. При создании экземпляра этого типа должно быть указано значение параметра типа наряду с именем метода, который делегат может вызывать. Таким образом, если указать тип string
, тогда целевому методу будет отправляться строковое значение:
// Создать экземпляр MyGenericDelegate
// с указанием string в качестве параметра типа.
MyGenericDelegate strTarget = StringTarget;
strTarget("Some string data");
С учетом формата объекта
strTarget
метод StringTarget
теперь должен принимать в качестве параметра единственную строку:
static void StringTarget(string arg)
{
Console.WriteLine(
"arg in uppercase is: {0}", arg.ToUpper());
}
В настоящей главе вы уже видели, что когда нужно применять делегаты для обратных вызовов в приложениях, обычно должны быть выполнены описанные далее шаги.
1. Определить специальный делегат, соответствующий формату метода, на который он указывает.
2. Создать экземпляр специального делегата, передав имя метода в качестве аргумента конструктора.
3. Косвенно обратиться к методу через вызов
Invoke()
на объекте делегата.
В случае принятия такого подхода в итоге, как правило, получается несколько специальных делегатов, которые могут никогда не использоваться за рамками текущей задачи (например,
MyGenericDelegate
, CarEngineHandler
и т.д.). Хотя вполне может быть и так, что для проекта требуется специальный уникально именованный делегат, в других ситуациях точное имя типа делегата роли не играет. Во многих случаях просто необходим "какой-нибудь делегат", который принимает набор аргументов и возможно возвращает значение, отличное от void
. В таких ситуациях можно применять встроенные в инфраструктуру делегаты Action<>
и Func<>
. Чтобы удостовериться в их полезности, создайте новый проект консольного приложения по имени ActionAndFuncDelegates
.
Обобщенный делегат
Action<>
определен в пространствах имен System
. Его можно использовать для "указания" на метод, который принимает вплоть до 16 аргументов (чего должно быть вполне достаточно!) и возвращает void
. Вспомните, что поскольку Action<>
является обобщенным делегатом, понадобится также указывать типы всех параметров.
Модифицируйте класс
Program
, определив в нем новый статический метод, который принимает (скажем) три уникальных параметра:
// Это цель для делегата Action<>.
static void DisplayMessage(string msg, ConsoleColor txtColor,
int printCount)
{
// Установить цвет текста консоли.
ConsoleColor previous = Console.ForegroundColor;
Console.ForegroundColor = txtColor;
for (int i = 0; i < printCount; i++)
{
Console.WriteLine(msg);
}
// Восстановить цвет.
Console.ForegroundColor = previous;
}
Теперь вместо построения специального делегата вручную для передачи потока программы методу
DisplayMessage()
вы можете применять готовый делегат Action<>
:
Console.WriteLine("***** Fun with Action and Func *****");
// Использовать делегат Action<> для указания на метод DisplayMessage().
Action actionTarget =
DisplayMessage;
actionTarget("Action Message!", ConsoleColor.Yellow, 5);
Console.ReadLine();
Как видите, при использовании делегата
Action<>
не нужно беспокоиться об определении специального типа делегата. Тем не менее, как уже упоминалось, тип делегата Action<>
позволяет указывать только на методы, возвращающие void
. Если необходимо указывать на метод, имеющий возвращаемое значение (и нет желания заниматься написанием собственного типа делегата), тогда можно применять тип делегата Func<>
.
Обобщенный делегат
Funс<>
способен указывать на методы, которые (подобно Action<>
) принимают вплоть до 16 параметров и имеют специальное возвращаемое значение. В целях иллюстрации добавьте в класс Program
новый метод:
// Цель для делегата Func<>.
static int Add(int x, int y)
{
return x + y;
}
Ранее в главе был построен специальный делегат
BinaryOp
для "указания" на методы сложения и вычитания. Теперь задачу можно упростить за счет использования версии Func<>
, которая принимает всего три параметра типа. Учтите, что последний параметр в Func<>
всегда представляет возвращаемое значение метода. Чтобы закрепить данный момент, предположим, что в классе Program
также определен следующий метод:
static string SumToString(int x, int y)
{
return (x + y).ToString();
}
Вызовите эти методы:
Func funcTarget = Add;
int result = funcTarget.Invoke(40, 40);
Console.WriteLine("40 + 40 = {0}", result);
Func funcTarget2 = SumToString;
string sum = funcTarget2(90, 300);
Console.WriteLine(sum);
С учетом того, что делегаты
Action<>
и Func<>
могут устранить шаг по ручному определению специального делегата, вас может интересовать, должны ли они применяться всегда. Подобно большинству аспектов программирования ответ таков: в зависимости от ситуации. Во многих случаях Action<>
и Func<>
будут предпочтительным вариантом. Однако если необходим делегат со специальным именем, которое, как вам кажется, помогает лучше отразить предметную область, то построение специального делегата сводится к единственному оператору кода. В оставшихся материалах книги вы увидите оба подхода.
На заметку! Делегаты
Action<>
и Func<>
интенсивно используются во многих важных API-интерфейсах .NET Core, включая инфраструктуру параллельного программирования и LINQ (помимо прочих).
Итак, первоначальный экскурс в типы делегатов окончен. Теперь давайте перейдем к обсуждению связанной темы — ключевого слова
event
языка С#.
Делегаты — довольно интересные конструкции в том плане, что позволяют объектам, находящимся в памяти, участвовать в двустороннем взаимодействии. Тем не менее, прямая работа с делегатами может приводить к написанию стереотипного кода (определение делегата, определение необходимых переменных-членов, создание специальных методов регистрации и отмены регистрации для предохранения инкапсуляции и т.д.).
Более того, во время применения делегатов непосредственным образом как механизма обратного вызова в приложениях, если вы не определите переменную-член типа делегата в классе как закрытую, тогда вызывающий код будет иметь прямой доступ к объектам делегатов. В таком случае вызывающий код может присвоить переменной-члену новый объект делегата (фактически удаляя текущий список функций, которые подлежат вызову) и, что даже хуже, вызывающий код сможет напрямую обращаться к списку вызовов делегата. В целях демонстрации создайте новый проект консольного приложения по имени
PublicDelegateProblem
и добавьте следующую переделанную (и упрощенную) версию класса Car
из предыдущего примера CarDelegate
:
namespace PublicDelegateproblem
{
public class Car
{
public delegate void CarEngineHandler(string msgForCaller);
// Теперь это член public!
public CarEngineHandler ListOfHandlers;
// Просто вызвать уведомление Exploded.
public void Accelerate(int delta)
{
if (ListOfHandlers != null)
{
ListOfHandlers("Sorry, this car is dead...");
}
}
}
}
Обратите внимание, что у вас больше нет закрытых переменных-членов с типами делегатов, инкапсулированных с помощью специальных методов регистрации. Поскольку эти члены на самом деле открытые, вызывающий код может получить доступ прямо к переменной-члену
ListOfHandlers
, присвоить ей новые объекты CarEngineHandler
и вызвать делегат по своему желанию:
using System;
using PublicDelegateProblem;
Console.WriteLine("***** Agh! No Encapsulation! *****\n");
// Создать объект Car.
Car myCar = new Car();
// Есть прямой доступ к делегату!
myCar.ListOfHandlers = CallWhenExploded;
myCar.Accelerate(10);
// Теперь можно присвоить полностью новый объект...
// что в лучшем случае сбивает с толку.
myCar.ListOfHandlers = CallHereToo;
myCar.Accelerate(10);
// Вызывающий код может также напрямую вызывать делегат!
myCar.ListOfHandlers.Invoke("hee, hee, hee...");
Console.ReadLine();
static void CallWhenExploded(string msg)
{
Console.WriteLine(msg);
}
static void CallHereToo(string msg)
{
Console.WriteLine(msg);
}
Открытие доступа к членам типа делегата нарушает инкапсуляцию, что не только затруднит сопровождение кода (и отладку), но также сделает приложение уязвимым в плане безопасности! Ниже показан вывод текущего примера:
***** Agh! No Encapsulation! *****
Sorry, this car is dead...
Sorry, this car is dead...
hee, hee, hee...
Очевидно, что вы не захотите предоставлять другим приложениям возможность изменять то, на что указывает делегат, или вызывать его члены без вашего разрешения. С учетом сказанного общепринятая практика предусматривает объявление переменных-членов, имеющих типы делегатов, как закрытых.
В качестве сокращения, избавляющего от необходимости создавать специальные методы для добавления и удаления методов из списка вызовов делегата, в языке C# предлагается ключевое слово
event
. В результате обработки компилятором ключевого слова event вы автоматически получаете методы регистрации и отмены регистрации, а также все необходимые переменные-члены для типов делегатов. Такие переменные-члены с типами делегатов всегда объявляются как закрытые и потому они не доступны напрямую из объекта, инициирующего событие. В итоге ключевое слово event
может использоваться для упрощения отправки специальным классом уведомлений внешним объектам.
Определение события представляет собой двухэтапный процесс. Во-первых, понадобится определить тип делегата (или задействовать существующий тип), который будет хранить список методов, подлежащих вызову при возникновении события. Во-вторых, необходимо объявить событие (с применением ключевого слова
event
) в терминах связанного типа делегата.
Чтобы продемонстрировать использование ключевого слова event, создайте новый проект консольного приложения по имени
CarEvents
. В этой версии класса Car
будут определены два события под названиями AboutToBlow
и Exploded
, которые ассоциированы с единственным типом делегата по имени CarEngineHandler
. Ниже показаны начальные изменения, внесенные в класс Car
:
using System;
namespace CarEvents
{
public class Car
{
...
// Этот делегат работает в сочетании с событиями Car.
public delegate void CarEngineHandler(string msgForCaller);
// Этот объект Car может отправлять следующие события:
public event CarEngineHandler Exploded;
public event CarEngineHandler AboutToBlow;
...
}
}
Отправка события вызывающему коду сводится просто к указанию события по имени наряду со всеми обязательными параметрами, как определено ассоциированным делегатом. Чтобы удостовериться в том, что вызывающий код действительно зарегистрировал событие, перед вызовом набора методов делегата событие следует проверить на равенство
null
. Ниже приведена новая версия метода Accelerate()
класса Car
:
public void Accelerate(int delta)
{
// Если автомобиль сломан, то инициировать событие Exploded.
if (_carIsDead)
{
Exploded?.Invoke("Sorry, this car is dead...");
}
else
{
CurrentSpeed += delta;
// Почти сломан?
if (10 == MaxSpeed - CurrentSpeed)
{
AboutToBlow?.Invoke("Careful buddy! Gonna blow!");
}
// Все еще в порядке!
if (CurrentSpeed >= MaxSpeed)
{
_carIsDead = true;
}
else
{
Console.WriteLine("CurrentSpeed = {0}", CurrentSpeed);
}
}
}
Итак, класс
Car
был сконфигурирован для отправки двух специальных событий без необходимости в определении специальных функций регистрации или в объявлении переменных-членов, имеющих типы делегатов. Применение нового объекта вы увидите очень скоро, но сначала давайте чуть подробнее рассмотрим архитектуру событий.
Когда компилятор C# обрабатывает ключевое слово event, он генерирует два скрытых метода, один с префиксом
add_
, а другой с префиксом remove_
. За префиксом следует имя события С#. Например, событие Exploded
дает в результате два скрытых метода с именами add_Exploded()
и remove_Exploded()
. Если заглянуть в код CIL метода add_AboutToBlow()
, то можно обнаружить вызов метода Delegate.Combine()
. Взгляните на частичный код CIL:
.method public hidebysig specialname instance void add_AboutToBlow(
class [System.Runtime]System.EventHandler`1
CarEventArgs> 'value') cil
managed
{
...
IL_000b: call class [System.Runtime]System.Delegate
[System.Runtime]System.
Delegate::Combine(class [System.Runtime]System.Delegate,
class [System.Runtime]System.
Delegate)
...
} // end of method Car::add_AboutToBlow
Как и можно было ожидать, метод
remove_AboutToBlow()
будет вызывать Delegate.Remove()
:
public hidebysig specialname instance void remove_AboutToBlow (
class [System.Runtime]System.EventHandler`1
'value') cil
managed
{
...
IL_000b: call class [System.Runtime]System.Delegate
[System.Runtime]System.
Delegate::Remove(class [System.Runtime]System.Delegate,
class [System.Runtime]System.
Delegate)
...
}
Наконец, в коде CIL, представляющем само событие, используются директивы
.addon
и .removeon
для отображения на имена корректных методов add_XXX()
и remove_XXX()
, подлежащих вызову:
.event class [System.Runtime]System.EventHandler`1
AboutToBlow
{
.addon instance void CarEvents.Car::add_AboutToBlow(
class [System.Runtime]System.EventHandler`1
)
.removeon instance void CarEvents.Car::remove_AboutToBlow(
class [System.Runtime]System.EventHandler`1
)
} // end of event Car::AboutToBlow
Теперь, когда вы понимаете, каким образом строить класс, способный отправлять события C# (и знаете, что события — всего лишь способ сэкономить время на наборе кода), следующий крупный вопрос связан с организацией прослушивания входящих событий на стороне вызывающего кода.
События C# также упрощают действие по регистрации обработчиков событий на стороне вызывающего кода. Вместо того чтобы указывать специальные вспомогательные методы, вызывающий код просто применяет операции
+=
и -=
напрямую (что приводит к внутренним вызовам методов add_XXX()
или remove_XXX()
). При регистрации события руководствуйтесь показанным ниже шаблоном:
// ИмяОбъекта.ИмяСобытия +=
// new СвязанныйДелегат(функцияДляВызова);
Car.CarEngineHandler d =
new Car.CarEngineHandler(CarExplodedEventHandler);
myCar.Exploded += d;
Отключить от источника событий можно с помощью операции
-=
в соответствии со следующим шаблоном:
// ИмяОбъекта.ИмяСобытия - =
// СвязанныйДелегат(функцияДляВызова);
myCar.Exploded -= d;
Кроме того, с событиями можно использовать синтаксис группового преобразования методов:
Car.CarEngineHandler d = CarExplodedEventHandler;
myCar.Exploded += d;
При наличии таких весьма предсказуемых шаблонов переделайте вызывающий код, применив на этот раз синтаксис регистрации событий С#:
Console.WriteLine("***** Fun with Events *****\n");
Car c1 = new Car("SlugBug", 100, 10);
// Зарегистрировать обработчики событий.
c1.AboutToBlow += CarIsAlmostDoomed;
c1.AboutToBlow += CarAboutToBlow;
Car.CarEngineHandler d = CarExploded;
c1.Exploded += d;
Console.WriteLine("***** Speeding up *****");
for (int i = 0; i < 6; i++)
{
c1.Accelerate(20);
}
// Удалить метод CarExploded() из списка вызовов.
c1.Exploded -= d;
Console.WriteLine("\n***** Speeding up *****");
for (int i = 0; i < 6; i++)
{
c1.Accelerate(20);
}
Console.ReadLine();
static void CarAboutToBlow(string msg)
{
Console.WriteLine(msg);
}
static void CarIsAlmostDoomed(string msg)
{
Console.WriteLine("=> Critical Message from Car: {0}", msg);
}
static void CarExploded(string msg)
{
Console.WriteLine(msg);
}
Среда Visual Studio предлагает помощь в процессе регистрации обработчиков событий. В случае применения синтаксиса
+=
при регистрации событий открывается окно IntelliSense, приглашающее нажать клавишу <ТаЬ> для автоматического завершения связанного экземпляра делегата (рис. 12.1), что достигается с использованием синтаксиса групповых преобразований методов.
После нажатия клавиши <ТаЬ> будет сгенерирован новый метод, как показано на рис. 12.2.
Обратите внимание, что код заглушки имеет корректный формат цели делегата (кроме того, метод объявлен как
static
, т.к. событие было зарегистрировано внутри статического метода):
static void NewCar_AboutToBlow(string msg)
{
throw new NotImplementedException();
}
Средство IntelliSense доступно для всех событий .NET Core, ваших событий и событий из библиотек базовых классов.Такая возможность IDE-среды значительно экономит время, избавляя от необходимости выяснять с помощью справочной системы подходящий тип делегата для применения с заданным событием и формат целевого метода делегата.
По правде говоря, в текущую итерацию класса
Car
можно было бы внести последнее усовершенствование, которое отражает рекомендованный Microsoft шаблон событий. Если вы начнете исследовать события, отправляемые определенным типом из библиотек базовых классов, то обнаружите, что первый параметр лежащего в основе делегата имеет тип System.Object
, в то время как второй — тип, производный от System.EventArgs
.
Параметр
System.Object
представляет ссылку на объект, который отправляет событие (такой как Car
), а второй параметр — информацию, относящуюся к обрабатываемому событию. Базовый класс System.EventArgs
представляет событие, которое не сопровождается какой-либо специальной информацией:
public class EventArgs
{
public static readonly EventArgs Empty;
public EventArgs();
}
Для простых событий экземпляр
EventArgs
можно передать напрямую. Тем не менее, когда нужно передавать специальные данные, вы должны построить подходящий класс, производный от EventArgs
. В этом примере предположим, что есть класс по имени CarEventArgs
, который поддерживает строковое представление сообщения, отправленного получателю:
using System;
namespace CarEvents
{
public class CarEventArgs : EventArgs
{
public readonly string msg;
public CarEventArgs(string message)
{
msg = message;
}
}
}
Теперь можно модифицировать тип делегата
CarEngineHandler
, как показано ниже (события не изменятся):
public class Car
{
public delegate void CarEngineHandler(object sender, CarEventArgs e);
...
}
Здесь при инициировании событий внутри метода
Accelerate()
необходимо использовать ссылку на текущий объект Car
(посредством ключевого слова this
) и экземпляр типа CarEventArgs
. Например, рассмотрим следующее обновление:
public void Accelerate(int delta)
{
// Если этот автомобиль сломан, то инициировать событие Exploded.
if (carIsDead)
{
Exploded?.Invoke(this, new CarEventArgs("Sorry, this car is dead..."));
}
...
}
На вызывающей стороне понадобится лишь модифицировать обработчики событий для приема входных параметров и получения сообщения через поле, доступное только для чтения. Вот пример:
static void CarAboutToBlow(object sender, CarEventArgs e)
{
Console.WriteLine($"{sender} says: {e.msg}");
}
Если получатель желает взаимодействовать с объектом, отправившим событие, тогда можно выполнить явное приведение
System.Object
. Такая ссылка позволит вызывать любой открытый метод объекта, который отправил уведомление:
static void CarAboutToBlow(object sender, CarEventArgs e)
{
// Просто для подстраховки перед приведением
// произвести проверку во время выполнения.
if (sender is Car c)
{
Console.WriteLine(
$"Critical Message from {c.PetName}: {e.msg}");
}
}
С учетом того, что очень многие специальные делегаты принимают экземпляр
object
в первом параметре и экземпляр производного от EventArgs
класса во втором, предыдущий пример можно дополнительно упростить за счет применения обобщенного типа EventHandler
, где Т
— специальный тип, производный от EventArgs
. Рассмотрим следующую модификацию типа Car
(обратите внимание, что определять специальный тип делегата больше не нужно):
public class Car
{
...
public event EventHandler Exploded;
public event EventHandler AboutToBlow;
}
Затем в вызывающем коде тип
EventHandler
можно использовать везде, где ранее указывался CarEngineHandler
(или снова применять групповое преобразование методов):
Console.WriteLine("***** Prim and Proper Events *****\n");
// Создать объект Car обычным образом.
Car c1 = new Car("SlugBug", 100, 10);
// Зарегистрировать обработчики событий.
c1.AboutToBlow += CarIsAlmostDoomed;
c1.AboutToBlow += CarAboutToBlow;
EventHandler d = CarExploded;
c1.Exploded += d;
...
Итак, к настоящему моменту вы узнали основные аспекты работы с делегатами и событиями в С#. Хотя этого вполне достаточно для решения практически любых задач, связанных с обратными вызовами, в завершение главы мы рассмотрим несколько финальных упрощений, в частности анонимные методы и лямбда-выражения.
Как было показано ранее, когда вызывающий код желает прослушивать входящие события, он должен определить специальный метод в классе (или структуре), который соответствует сигнатуре ассоциированного делегата. Ниже приведен пример:
SomeType t = new SomeType();
// Предположим, что SomeDeletage может указывать на методы,
// которые не принимают аргументов и возвращают void.
t.SomeEvent += new SomeDelegate(MyEventHandler);
// Обычно вызывается только объектом SomeDelegate.
static void MyEventHandler()
{
// Делать что-нибудь при возникновении события.
}
Однако если подумать, то такие методы, как
MyEventHandler()
, редко предназначены для вызова из любой другой части программы кроме делегата. С точки зрения продуктивности вручную определять отдельный метод для вызова объектом делегата несколько хлопотно (хотя и вполне допустимо).
Для решения указанной проблемы событие можно ассоциировать прямо с блоком операторов кода во время регистрации события. Формально такой код называется анонимным методом. Чтобы ознакомиться с синтаксисом, создайте новый проект консольного приложения по имени
AnonymousMethods
, после чего скопируйте в него файлы Car.cs
и CarEventArgs.cs
из проекта CarEvents
(не забыв изменить пространство имен на AnonymousMethods
). Модифицируйте код в файле Program.cs
, как показано ниже, для обработки событий, посылаемых из класса Car
, с использованием анонимных методов вместо специальных именованных обработчиков событий:
using System;
using AnonymousMethods;
Console.WriteLine("***** Anonymous Methods *****\n");
Car c1 = new Car("SlugBug", 100, 10);
// Зарегистрировать обработчики событий как анонимные методы.
c1.AboutToBlow += delegate
{
Console.WriteLine("Eek! Going too fast!");
};
c1.AboutToBlow += delegate(object sender, CarEventArgs e)
{
Console.WriteLine("Message from Car: {0}", e.msg);
};
c1.Exploded += delegate(object sender, CarEventArgs e)
{
Console.WriteLine("Fatal Message from Car: {0}", e.msg);
};
// В конце концов, этот код будет инициировать события.
for (int i = 0; i < 6; i++)
{
c1.Accelerate(20);
}
Console.ReadLine();
На заметку! После финальной фигурной скобки в анонимном методе должна быть помещена точка с запятой, иначе возникнет ошибка на этапе компиляции.
И снова легко заметить, что специальные статические обработчики событий вроде
CarAboutToBlow()
или CarExploded()
в вызывающем коде больше не определяются. Взамен с помощью синтаксиса +=
определяются встроенные неименованные (т.е. анонимные) методы, к которым вызывающий код будет обращаться во время обработки события. Базовый синтаксис анонимного метода представлен следующим псевдокодом:
НекоторыйТип t = new НекоторыйТип();
t.НекотороеСобытие += delegate (необязательноУказываемыеАргументыДелегата)
{ /* операторы */ };
Обратите внимание, что при обработке первого события
AboutToBlow
внутри предыдущего примера кода аргументы, передаваемые из делегата, не указывались:
c1.AboutToBlow += delegate
{
Console.WriteLine("Eek! Going too fast!");
};
Строго говоря, вы не обязаны принимать входные аргументы, отправленные специфическим событием. Но если вы хотите задействовать эти входные аргументы, тогда понадобится указать параметры, прототипированные типом делегата (как показано во второй обработке событий
AboutToBlow
и Exploded
). Например:
c1.AboutToBlow += delegate(object sender, CarEventArgs e)
{
Console.WriteLine("Critical Message from Car: {0}", e.msg);
};
Анонимные методы интересны тем, что способны обращаться к локальным переменным метода, где они определены. Формально такие переменные называются внешними переменными анонимного метода. Ниже перечислены важные моменты, касающиеся взаимодействия между областью действия анонимного метода и областью действия метода, в котором он определен.
• Анонимный метод не имеет доступа к параметрам
ref
и out
определяющего метода.
• Анонимный метод не может иметь локальную переменную, имя которой совпадает с именем локальной переменной внешнего метода.
• Анонимный метод может обращаться к переменным экземпляра (или статическим переменным) из области действия внешнего класса.
• Анонимный метод может объявлять локальную переменную с тем же именем, что и у переменной-члена внешнего класса (локальные переменные имеют отдельную область действия и скрывают переменные-члены из внешнего класса).
Предположим, что в операторах верхнего уровня определена локальная переменная по имени
aboutToBlowCounter
типа int
. Внутри анонимных методов, которые обрабатывают событие AboutToBlow
, выполните увеличение значения aboutToBlowCounter
на 1 и вывод результата на консоль перед завершением операторов:
Console.WriteLine("***** Anonymous Methods *****\n");
int aboutToBlowCounter = 0;
// Создать объект Car обычным образом.
Car c1 = new Car("SlugBug", 100, 10);
// Зарегистрировать обработчики событий как анонимные методы.
c1.AboutToBlow += delegate
{
aboutToBlowCounter++;
Console.WriteLine("Eek! Going too fast!");
};
c1.AboutToBlow += delegate(object sender, CarEventArgs e)
{
aboutToBlowCounter++;
Console.WriteLine("Critical Message from Car: {0}", e.msg);
};
...
// В конце концов, это будет инициировать события.
for (int i = 0; i < 6; i++)
{
c1.Accelerate(20);
}
Console.WriteLine("AboutToBlow event was fired {0} times.",
aboutToBlowCounter);
Console.ReadLine();
После запуска модифицированного кода вы обнаружите, что финальный вывод
Console.WriteLine()
сообщает о двукратном инициировании события AboutToBlow
.
В предыдущем примере демонстрировались анонимные методы, которые взаимодействовали с переменными, объявленными вне области действия самих методов. Хотя возможно именно это входило в ваши намерения, прием нарушает инкапсуляцию и может привести к нежелательным побочным эффектам в программе. Вспомните из главы 4, что локальные функции могут быть изолированы от содержащего их кода за счет их настройки как статических, например:
static int AddWrapperWithStatic(int x, int y)
{
// Выполнить проверку достоверности
return Add(x,y);
static int Add(int x, int y)
{
return x + y;
}
}
В версии C# 9.0 анонимные методы также могут быть помечены как статические с целью предохранения инкапсуляции и гарантирования того, что они не привнесут какие-либо побочные эффекты в код, где они содержатся. Вот как выглядит модифицированный анонимный метод:
c1.AboutToBlow += static delegate
{
// Этот код приводит к ошибке на этапе компиляции,
// потому что анонимный метод помечен как статический
aboutToBlowCounter++;
Console.WriteLine("Eek! Going too fast!");
};
Предыдущий код не скомпилируется из-за попытки анонимного метода получить доступ к переменной, объявленной вне области его действия.
Отбрасывание, представленное в главе 3, в версии C# 9.0 было обновлено с целью применения в качестве входных параметров, но с одной уловкой. Поскольку символ подчеркивания (
_
) в предшествующих версиях C# считался законным идентификатором переменной, в анонимном методе должно присутствовать два и более подчеркиваний, чтобы они трактовались как отбрасывание.
Например, в следующем коде создается делегат
Func<>
, который принимает два целых числа и возвращает еще одно целое число. Приведенная реализация игнорирует любые переданные переменные и возвращает значение 42
:
Console.WriteLine("******** Discards with Anonymous Methods ********");
Func constant = delegate (int _, int _) {return 42;};
Console.WriteLine("constant(3,4)={0}",constant(3,4));
Чтобы завершить знакомство с архитектурой событий .NET Core, необходимо исследовать лямбда-выражения. Как объяснялось ранее в главе, язык C# поддерживает возможность обработки событий "встраиваемым образом", позволяя назначать блок операторов кода прямо событию с применением анонимных методов вместо построения отдельного метода, который должен вызываться делегатом. Лямбда-выражения всего лишь лаконичный способ записи анонимных методов, который в конечном итоге упрощает работу с типами делегатов .NET Core.
В целях подготовки фундамента для изучения лямбда-выражений создайте новый проект консольного приложения по имени
LambdaExpressions
. Для начала взгляните на метод FindAll()
обобщенного класса List
. Данный метод можно вызывать, когда нужно извлечь подмножество элементов из коллекции; вот его прототип:
// Метод класса System.Collections.Generic.List.
public List FindAll(Predicate match)
Как видите, метод
FindAll()
возвращает новый объект List
, который представляет подмножество данных. Также обратите внимание, что единственным параметром FindAll()
является обобщенный делегат типа System.Predicate
, способный указывать на любой метод, который возвращает bool
и принимает единственный параметр:
// Этот делегат используется методом FindAll()
// для извлечения подмножества.
public delegate bool Predicate(T obj);
Когда вызывается
FindAll()
, каждый элемент в List
передается методу, указанному объектом Predicate
. Реализация упомянутого метода будет выполнять некоторые вычисления для проверки соответствия элемента данных заданному критерию, возвращая в результате true
или false
. Если метод возвращает true
, то текущий элемент будет добавлен в новый объект List
, который представляет интересующее подмножество.
Прежде чем мы посмотрим, как лямбда-выражения могут упростить работу с методом
FindAll()
, давайте решим задачу длинным способом, используя объекты делегатов непосредственно. Добавьте в класс Program
метод (TraditionalDelegateSyntax()
), который взаимодействует с типом System.Predicate
для обнаружения четных чисел в списке List
целочисленных значений:
using System;
using System.Collections.Generic;
using LambdaExpressions;
Console.WriteLine("***** Fun with Lambdas *****\n");
TraditionalDelegateSyntax();
Console.ReadLine();
static void TraditionalDelegateSyntax()
{
// Создать список целочисленных значений.
List list = new List();
list.AddRange(new int[] { 20, 1, 4, 8, 9, 44 });
// Вызвать FindAll() с применением традиционного синтаксиса делегатов.
Predicate callback = IsEvenNumber;
List evenNumbers = list.FindAll(callback);
Console.WriteLine("Here are your even numbers:");
foreach (int evenNumber in evenNumbers)
{
Console.Write("{0}\t", evenNumber);
}
Console.WriteLine();
}
// Цель для делегата Predicate<>.
static bool IsEvenNumber(int i)
{
// Это четное число?
return (i % 2) == 0;
}
Здесь имеется метод (
IsEvenNumber()
), который отвечает за проверку входного целочисленного параметра на предмет четности или нечетности с применением операции получения остатка от деления (%
) языка С#. Запуск приложения приводит к выводу на консоль чисел 20, 4, 8 и 44.
Наряду с тем, что такой традиционный подход к работе с делегатами ведет себя ожидаемым образом,
IsEvenNumber()
вызывается только при ограниченных обстоятельствах — в частности, когда вызывается метод FindAll()
, который возлагает на нас обязанность по полному определению метода. Если взамен использовать анонимный метод, то можно превратить это в локальную функцию и код станет значительно чище. Добавьте в класс Program
следующий новый метод:
static void AnonymousMethodSyntax()
{
// Создать список целочисленных значений.
List list = new List();
list.AddRange(new int[] { 20, 1, 4, 8, 9, 44 });
// Теперь использовать анонимный метод.
List evenNumbers =
list.FindAll(delegate(int i) { return (i % 2) == 0; } );
// Вывести четные числа
Console.WriteLine("Here are your even numbers:");
foreach (int evenNumber in evenNumbers)
{
Console.Write("{0}\t", evenNumber);
}
Console.WriteLine();
}
В данном случае вместо прямого создания объекта делегата
Predicate
и последующего написания отдельного метода есть возможность определить метод как анонимный. Несмотря на шаг в правильном направлении, вам по-прежнему придется применять ключевое слово delegate
(или строго типизированный класс Predicate
) и обеспечивать точное соответствие списка параметров:
List evenNumbers = list.FindAll(
delegate(int i)
{
return (i % 2) == 0;
}
);
Для еще большего упрощения вызова метода
FindAll()
могут использоваться лямбда-выражения. Во время применения синтаксиса лямбда-выражений вообще не приходится иметь дело с лежащим в основе объектом делегата. Взгляните на показанный далее новый метод в классе Program
:
static void LambdaExpressionSyntax()
{
// Создать список целочисленных значений.
List list = new List();
list.AddRange(new int[] { 20, 1, 4, 8, 9, 44 });
// Теперь использовать лямбда-выражение С #.
List evenNumbers = list.FindAll(i => (i % 2) == 0);
// Вывести четные числа.
Console.WriteLine("Here are your even numbers:");
foreach (int evenNumber in evenNumbers)
{
Console.Write("{0}\t", evenNumber);
}
Console.WriteLine();
}
Обратите внимание на довольно странный оператор кода, передаваемый методу
FindAll()
, который на самом деле и представляет собой лямбда-выражение. В такой версии примера нет вообще никаких следов делегата Predicate
(или ключевого слова delegate
, если на то пошло). Должно указываться только лямбда-выражение:
i => (i % 2) == 0
Перед разбором синтаксиса запомните, что лямбда-выражения могут использоваться везде, где должен применяться анонимный метод или строго типизированный делегат (обычно с клавиатурным набором гораздо меньшего объема). "За кулисами" компилятор C# транслирует лямбда-выражение в стандартный анонимный метод, использующий тип делегата
Predicate
(в чем можно удостовериться с помощью утилиты ildasm.exe
). Скажем, следующий оператор кода:
// Это лямбда-выражение...
List evenNumbers = list.FindAll(i => (i % 2) == 0);
компилируется в приблизительно такой код С#:
// ...становится следующим анонимным методом.
List evenNumbers = list.FindAll(delegate (int i)
{
return (i % 2) == 0;
});
Лямбда-выражение начинается со списка параметров, за которым следует лексема
=>
(лексема C# для лямбда-операции позаимствована из области лямбда-исчисления), а за ней — набор операторов (или одиночный оператор), который будет обрабатывать передаваемые аргументы. На самом высоком уровне лямбда-выражение можно представить следующим образом:
АргументыДляОбработки => ОбрабатывающиеОператоры
То, что находится внутри метода
LambdaExpressionSyntax()
, понимается так:
// i — список параметров.
// (i % 2) == 0 - набор операторов для обработки i
List evenNumbers = list.FindAll(i => (i % 2) == 0);
Параметры лямбда-выражения могут быть явно или неявно типизированными. В настоящий момент тип данных, представляющий параметр
i
(целочисленное значение), определяется неявно. Компилятор в состоянии понять, что i
является целочисленным значением, на основе области действия всего лямбда-выражения и лежащего в основе делегата. Тем не менее, определять тип каждого параметра в лямбда-выражении можно также и явно, помещая тип данных и имя переменной в пару круглых скобок, как показано ниже:
// Теперь установим тип параметров явно.
List evenNumbers = list.FindAll((int i) => (i % 2) == 0);
Как вы уже видели, если лямбда-выражение имеет одиночный неявно типизированный параметр, то круглые скобки в списке параметров могут быть опущены. Если вы желаете соблюдать согласованность относительно применения параметров лямбда-выражений, тогда можете всегда заключать в скобки список параметров:
List evenNumbers = list.FindAll((i) => (i % 2) == 0);
Наконец, обратите внимание, что в текущий момент выражение не заключено в круглые скобки (естественно, вычисление остатка от деления помещено в скобки, чтобы гарантировать его выполнение перед проверкой на равенство). В лямбда-выражениях разрешено заключать оператор в круглые скобки:
// Поместить в скобки и выражение.
List evenNumbers = list.FindAll((i) => ((i % 2) == 0));
После ознакомления с разными способами построения лямбда-выражения давайте выясним, как его можно читать в понятных человеку терминах. Оставив чистую математику в стороне, можно привести следующее объяснение:
// Список параметров (в данном случае единственное целочисленное
// значение по имени i) будет обработан выражением (i % 2) == 0.
List evenNumbers = list.FindAll((i) => ((i % 2) == 0));
Первое рассмотренное лямбда-выражение включало единственный оператор, который в итоге вычислялся в булевское значение. Однако, как вы знаете, многие цели делегатов должны выполнять несколько операторов кода. По этой причине язык C# позволяет строить лямбда-выражения, состоящие из множества операторов, указывая блок кода в стандартных фигурных скобках. Взгляните на приведенную далее модификацию метода
LambdaExpressionSyntax()
:
static void LambdaExpressionSyntax()
{
// Создать список целочисленных значений.
List list = new List();
list.AddRange(new int[] { 20, 1, 4, 8, 9, 44 });
// Обработать каждый аргумент внутри группы операторов кода.
List evenNumbers = list.FindAll((i) =>
{
// текущее значение i
Console.WriteLine("value of i is currently: {0}", i);
bool isEven = ((i % 2) == 0);
return isEven;
});
// Вывести четные числа
Console.WriteLine("Here are your even numbers:");
foreach (int evenNumber in evenNumbers)
{
Console.Write("{0}\t", evenNumber);
}
Console.WriteLine();
}
В данном случае список параметров (опять состоящий из единственного целочисленного значения
i
) обрабатывается набором операторов кода. Помимо вызова метода Console.WriteLine()
оператор вычисления остатка от деления разбит на два оператора ради повышения читабельности. Предположим, что каждый из рассмотренных выше методов вызывается внутри операторов верхнего уровня:
Console.WriteLine("***** Fun with Lambdas *****\n");
TraditionalDelegateSyntax();
AnonymousMethodSyntax();
Console.WriteLine();
LambdaExpressionSyntax();
Console.ReadLine();
Запуск приложения дает следующий вывод:
***** Fun with Lambdas *****
Here are your even numbers:
20 4 8 44
Here are your even numbers:
20 4 8 44
value of i is currently: 20
value of i is currently: 1
value of i is currently: 4
value of i is currently: 8
value of i is currently: 9
value of i is currently: 44
Here are your even numbers:
20 4 8 44
Показанные ранее лямбда-выражения обрабатывали единственный параметр. Тем не менее, это вовсе не обязательно, т.к. лямбда-выражения могут обрабатывать множество аргументов (или ни одного). Для демонстрации первого сценария с множеством аргументов добавьте показанную ниже версию класса
SimpleMath
:
public class SimpleMath
{
public delegate void MathMessage(string msg, int result);
private MathMessage _mmDelegate;
public void SetMathHandler(MathMessage target)
{
_mmDelegate = target;
}
public void Add(int x, int y)
{
_mmDelegate?.Invoke("Adding has completed!", x + y);
}
}
Обратите внимание, что делегат
MathMessage
ожидает два параметра. Чтобы представить их в виде лямбда-выражения, операторы верхнего уровня можно записать так:
// Зарегистрировать делегат как лямбда-выражение.
SimpleMath m = new SimpleMath();
m.SetMathHandler((msg, result) =>
{Console.WriteLine("Message: {0}, Result: {1}", msg, result);});
// Это приведет к выполнению лямбда-выражения.
m.Add(10, 10);
Console.ReadLine();
Здесь задействовано выведение типа, поскольку для простоты два параметра не были строго типизированы. Однако метод
SetMathHandler()
можно было бы вызвать следующим образом:
m.SetMathHandler((string msg, int result) =>
{Console.WriteLine("Message: {0}, Result: {1}", msg, result);});
Наконец, если лямбда-выражение применяется для взаимодействия с делегатом, который вообще не принимает параметров, то можно указать в качестве параметра пару пустых круглых скобок. Таким образом, предполагая, что определен приведенный далее тип делегата:
public delegate string VerySimpleDelegate();
вот как можно было бы обработать результат вызова:
// Выводит на консоль строку "Enjoy your string!".
VerySimpleDelegate d =
new VerySimpleDelegate( () => {return "Enjoy your string!";} );
Console.WriteLine(d());
Используя новый синтаксис выражений, предыдущую строку можно записать следующим образом:
VerySimpleDelegate d2 =
new VerySimpleDelegate(() => "Enjoy your string!");
и даже сократить ее до такого вида:
VerySimpleDelegate d3 = () => "Enjoy your string!";
Поскольку лямбда-выражения являются сокращенной формой записи для делегатов, должно быть понятно, что они тоже поддерживают ключевое слово
static
(начиная с версии C# 9.0) и отбрасывание (рассматривается в следующем разделе). Добавьте к операторам верхнего уровня такой код:
var outerVariable = 0;
Func DoWork = (x,y) =>
{
outerVariable++;
return true;
};
DoWork(3,4);
Console.WriteLine("Outer variable now = {0}", outerVariable);
В результате выполнения этого кода получается следующий вывод:
***** Fun with Lambdas *****
Outer variable now = 1
Если вы сделаете лямбда-выражение статическим, тогда на этапе компиляции возникнет ошибка, т.к. выражение пытается модифицировать переменную, объявленную во внешней области действия:
var outerVariable = 0;
Func DoWork = static (x,y) =>
{
// Ошибка на этапе компиляции по причине доступа
// к внешней переменной.
// outerVariable++;
return true;
};
Как и в случае делегатов (начиная с версии C# 9.0), входные переменные лямбда-выражения можно заменять отбрасыванием, когда они не нужны. Здесь применяется та же самая уловка, что и в делегатах. Поскольку символ подчеркивания (
_
) в предшествующих версиях C# считался законным идентификатором переменной, в лямбда-выражении должно присутствовать два и более подчеркиваний, чтобы они трактовались как отбрасывание:
var outerVariable = 0;
Func DoWork = (x,y) =>
{
outerVariable++;
return true;
};
DoWork(_,_);
Console.WriteLine("Outer variable now = {0}", outerVariable);
С учетом того, что основной целью лямбда-выражений является предоставление способа ясного и компактного определения анонимных методов (косвенно упрощая работу с делегатами), давайте модернизируем проект
CarEvents
, созданный ранее в главе. Ниже приведена упрощенная версия класса Program
из упомянутого проекта, в которой для перехвата всех событий, поступающих от объекта Car
, применяется синтаксис лямбда-выражений (вместо простых делегатов):
using System;
using CarEventsWithLambdas;
Console.WriteLine("***** More Fun with Lambdas *****\n");
// Создать объект Car обычным образом.
Car c1 = new Car("SlugBug", 100, 10);
// Привязаться к событиям с помощью лямбда-выражений.
c1.AboutToBlow += (sender, e)
=> { Console.WriteLine(e.msg);};
c1.Exploded += (sender, e) => { Console.WriteLine(e.msg); };
// Увеличить скорость (это инициирует события).
Console.WriteLine("\n***** Speeding up *****");
for (int i = 0; i < 6; i++)
{
c1.Accelerate(20);
}
Console.ReadLine();
Понимая лямбда-выражения и зная, как они работают, вам должно стать намного яснее, каким образом внутренне функционируют члены, сжатые до выражений. В главе 4 упоминалось, что в версии C# 6 появилась возможность использовать операцию
=>
для упрощения некоторых реализаций членов. В частности, если есть метод или свойство (в дополнение к специальной операции или процедуре преобразования, как было показано в главе 11), реализация которого содержит единственную строку кода, тогда определять область действия посредством фигурных скобок необязательно. Взамен можно задействовать лямбда-операцию и написать член, сжатый до выражения. В версии C# 7 такой синтаксис можно применять для конструкторов и финализаторов классов (раскрываемых в главе 9), а также для средств доступа get
и set
к свойствам.
Тем не менее, имейте в виду, что новый сокращенный синтаксис может применяться где угодно, даже когда код не имеет никакого отношения к делегатам или событиям. Таким образом, например, если вы строите элементарный класс для сложения двух чисел, то могли бы написать следующий код:
class SimpleMath
{
public int Add(int x, int y)
{
return x + y;
}
public void PrintSum(int x, int y)
{
Console.WriteLine(x + y);
}
}
В качестве альтернативы теперь код может выглядеть так:
class SimpleMath
{
public int Add(int x, int y) => x + y;
public void PrintSum(int x, int y) => Console.WriteLine(x + y);
}
В идеале к этому моменту вы должны уловить суть лямбда-выражений и понимать, что они предлагают "функциональный способ" работы с анонимными методами и типами делегатов. Хотя на привыкание к лямбда-операции (
=>
) может уйти некоторое время, просто запомните, что лямбда-выражение сводится к следующей форме:
АргументыДляОбработки =>
{
ОбрабатывающиеОператоры
}
Или, если операция
=>
используется для реализации члена типа с единственным оператором, то это будет выглядеть так:
ЧленТипа => ЕдинственныйОператорКода
Полезно отметить, что лямбда-выражения широко задействованы также в модели программирования LINQ, помогая упростить кодирование. Исследование LINQ начинается в главе 13.
В настоящей главе вы получили представление о нескольких способах организации двустороннего взаимодействия для множества объектов. Во-первых, было рассмотрено ключевое слово
delegate
, которое применяется для косвенного конструирования класса, производного от System.MulticastDelegate
. Вы узнали, что объект делегата поддерживает список методов для вызова тогда, когда ему об этом будет указано.
Во-вторых, вы ознакомились с ключевым словом event, которое в сочетании с типом делегата может упростить процесс отправки уведомлений ожидающим объектам. Как можно заметить в результирующем коде CIL, модель событий .NET отображается на скрытые обращения к типам
System.Delegate/System.MulticastDelegate
.
В данном отношении ключевое слово event является совершенно необязательным, т.к. оно просто позволяет сэкономить на наборе кода. Кроме того, вы видели, что
null
-условная операция C# 6.0 упрощает безопасное инициирование событий для любой заинтересованной стороны.
В-третьих, в главе также рассматривалось средство языка С#, которое называется анонимными методами. С помощью такой синтаксической конструкции можно явно ассоциировать блок операторов кода с заданным событием. Было показано, что анонимные методы вполне могут игнорировать параметры, переданные событием, и имеют доступ к "внешним переменным" определяющего их метода. Вы также освоили упрощенный подход к регистрации событий с применением групповых преобразований методов.
Наконец, в-четвертых, вы взглянули на лямбда-операцию (
=>
) языка С#. Как было показано, этот синтаксис представляет собой сокращенный способ для записи анонимных методов, когда набор аргументов может быть передан на обработку группе операторов. Любой метод внутри платформы .NET Core, который принимает объект делегата в качестве аргумента, может быть заменен связанным лямбда-выражением, что обычно несколько упрощает кодовую базу.
Независимо от типа приложения, которое вы создаете с использованием платформы .NET Core, во время выполнения ваша программа непременно будет нуждаться в доступе к данным какой-нибудь формы. Разумеется, данные могут находиться в многочисленных местах, включая файлы XML, реляционные базы данных, коллекции в памяти и элементарные массивы. Исторически сложилось так, что в зависимости от места хранения данных программистам приходилось применять разные и несвязанные друг с другом API-интерфейсы. Набор технологий LINQ (Language Integrated Query — язык интегрированных запросов), появившийся в версии .NET 3.5, предоставил краткий, симметричный и строго типизированный способ доступа к широкому разнообразию хранилищ данных. В настоящей главе изучение LINQ начинается с исследования LINQ to Objects.
Прежде чем погрузиться в LINQ to Objects, в первой части главы предлагается обзор основных программных конструкций языка С#, которые делают возможным существование LINQ. По мере чтения главы вы убедитесь, насколько полезны (а иногда и обязательны) такие средства, как неявно типизированные переменные, синтаксис инициализации объектов, лямбда-выражения, расширяющие методы и анонимные типы.
После пересмотра поддерживающей инфраструктуры в оставшемся материале главы будет представлена модель программирования LINQ и объяснена ее роль в рамках платформы .NET. Вы узнаете, для чего предназначены операции и выражения запросов, позволяющие определять операторы, которые будут опрашивать источник данных с целью выдачи требуемого результирующего набора. Попутно будут строиться многочисленные примеры LINQ, взаимодействующие с данными в массивах и коллекциях различного типа (обобщенных и необобщенных), а также исследоваться сборки, пространства имен и типы, которые представляют API-интерфейс LINQ to Objects.
На заметку! Информация, приведенная в главе, послужит фундаментом для освоения материала последующих глав книги, включая Parallel LINQ (глава 15) и Entity Framework Core (главы 22 и 23) .
С высокоуровневой точки зрения LINQ можно трактовать как строго типизированный язык запросов, встроенный непосредственно в грамматику самого языка С#. Используя LINQ, можно создавать любое количество выражений, которые выглядят и ведут себя подобно SQL-запросам к базе данных. Однако запрос LINQ может применяться к любым хранилищам данных, включая хранилища, которые не имеют ничего общего с подлинными реляционными базами данных.
На заметку! Хотя запросы LINQ внешне похожи на запросы SQL, их синтаксис не идентичен. В действительности многие запросы LINQ имеют формат, прямо противоположный формату подобного запроса к базе данных! Если вы попытаетесь отобразить LINQ непосредственно на SQL, то определенно запутаетесь. Чтобы подобного не произошло, рекомендуется воспринимать запросы LINQ как уникальные операторы, которые просто случайно оказались похожими на SQL.
Когда LINQ впервые был представлен в составе платформы .NET 3.5, языки C# и VB уже были расширены множеством новых программных конструкций для поддержки набора технологий LINQ. В частности, язык C# использует следующие связанные с LINQ средства:
• неявно типизированные локальные переменные:
• синтаксис инициализации объектов и коллекций;
• лямбда-выражения;
• расширяющие методы ;
• анонимные типы.
Перечисленные средства уже детально рассматривались в других главах книги. Тем не менее, чтобы освежить все в памяти, давайте быстро вспомним о каждом средстве по очереди, удостоверившись в правильном их понимании.
На заметку! Из-за того, что в последующих разделах приводится обзор материала, рассматриваемого где-то в других местах книги, проект кода C# здесь не предусмотрен.
В главе 3 вы узнали о ключевом слове
var
языка С#. Оно позволяет определять локальную переменную без явного указания типа данных. Однако такая переменная будет строго типизированной, потому что компилятор определит ее корректный тип данных на основе начального присваивания. Вспомните показанный ниже код примера из главы 3:
static void DeclareImplicitVars()
{
// Неявно типизированные локальные переменные.
var myInt = 0;
var myBool = true;
var myString = "Time, marches on...";
// Вывести имена лежащих в основе типов.
Console.WriteLine("myInt is a: {0}", myInt.GetType().Name);
Console.WriteLine("myBool is a: {0}",
myBool.GetType().Name);
Console.WriteLine("myString is a: {0}",
myString.GetType().Name);
}
Это языковое средство удобно и зачастую обязательно, когда применяется LINQ. Как вы увидите на протяжении главы, многие запросы LINQ возвращают последовательность типов данных, которые не будут известны вплоть до этапа компиляции. Учитывая, что лежащий в основе тип данных не известен до того, как приложение скомпилируется, вполне очевидно, что явно объявить такую переменную невозможно!
В главе 5 объяснялась роль синтаксиса инициализации объектов, который позволяет создавать переменную типа класса или структуры и устанавливать любое количество ее открытых свойств за один прием. В результате получается компактный (и по-прежнему легко читаемый) синтаксис, который может использоваться для подготовки объектов к потреблению. Также вспомните из главы 10, что язык C# поддерживает похожий синтаксис инициализации коллекций объектов. Взгляните на следующий фрагмент кода, где синтаксис инициализации коллекций применяется для наполнения
List
объектами Rectangle
, каждый из которых состоит из пары объектов Point
, представляющих точку с координатами (х, у):
List myListOfRects = new List
{
new Rectangle {TopLeft = new Point { X = 10, Y = 10 },
BottomRight = new Point { X = 200, Y = 200}},
new Rectangle {TopLeft = new Point { X = 2, Y = 2 },
BottomRight = new Point { X = 100, Y = 100}},
new Rectangle {TopLeft = new Point { X = 5, Y = 5 },
BottomRight = new Point { X = 90, Y = 75}}
};
Несмотря на то что использовать синтаксис инициализации коллекций или объектов совершенно не обязательно, с его помощью можно получить более компактную кодовую базу. Кроме того, этот синтаксис в сочетании с неявной типизацией локальных переменных позволяет объявлять анонимный тип, что удобно при создании проекций LINQ. О проекциях LINQ речь пойдет позже в главе.
Лямбда-операция C# (
=>
) подробно рассматривалась в главе 12. Вспомните, что данная операция позволяет строить лямбда-выражение, которое может применяться в любой момент при вызове метода, требующего строго типизированный делегат в качестве аргумента. Лямбда-выражения значительно упрощают работу с делегатами, т.к. сокращают объем кода, который должен быть написан вручную. Лямбда-выражения могут быть представлены следующим образом:
АргументыДляОбработки =>
{
ОбрабатывающиеОператоры
}
В главе 12 было показано, как взаимодействовать с методом
FindAll()
обобщенного класса List
с использованием трех разных подходов. После работы с низкоуровневым делегатом Predicate
и анонимным методом C# мы пришли к приведенной ниже (исключительно компактной) версии, в которой использовалось лямбда-выражение:
static void LambdaExpressionSyntax()
{
// Создать список целочисленных значений.
List list = new List();
list.AddRange(new int[] { 20, 1, 4, 8, 9, 44 });
// Теперь использовать лямбда-выражение С#.
List evenNumbers = list.FindAll(i => (i % 2) == 0);
// Вывести четные числа
Console.WriteLine("Here are your even numbers:");
foreach (int evenNumber in evenNumbers)
{
Console.Write("{0}\t", evenNumber);
}
Console.WriteLine();
}
Лямбда-выражения будут удобны при работе с объектной моделью, лежащей в основе LINQ. Как вы вскоре выясните, операции запросов LINQ в C# — просто сокращенная запись для вызова методов класса по имени
System.Linq.Enumerable
. Эти методы обычно всегда требуют передачи в качестве параметров делегатов (в частности, делегата Funс<>
), которые применяются для обработки данных с целью выдачи корректного результирующего набора. За счет использования лямбда-выражений можно упростить код и позволить компилятору вывести нужный делегат.
Расширяющие методы C# позволяют оснащать существующие классы новой функциональностью без необходимости в создании подклассов. Кроме того, расширяющие методы дают возможность добавлять новую функциональность к запечатанным классам и структурам, которые в принципе не допускают построения подклассов. Вспомните из главы 11, что когда создается расширяющий метод, первый его параметр снабжается ключевым словом
this
и помечает расширяемый тип. Также вспомните, что расширяющие методы должны всегда определяться внутри статического класса, а потому объявляться с применением ключевого слова static
. Вот пример:
namespace MyExtensions
{
static class ObjectExtensions
{
// Определить расширяющий метод для System.Object.
public static void DisplayDefiningAssembly(
this object obj)
{
Console.WriteLine("{0} lives here:\n\t->{1}\n", obj.GetType().Name,
Assembly.GetAssembly(obj.GetType()));
}
}
}
Чтобы использовать такое расширение, приложение должно импортировать пространство имен, определяющее расширение (и возможно добавить ссылку на внешнюю сборку). Затем можно приступать к написанию кода:
// Поскольку все типы расширяют System.Object, все
// классы и структуры могут использовать это расширение.
int myInt = 12345678;
myInt.DisplayDefiningAssembly();
System.Data.DataSet d = new System.Data.DataSet();
d.DisplayDefiningAssembly();
При работе c LINQ вам редко (если вообще когда-либо) потребуется вручную строить собственные расширяющие методы. Тем не менее, создавая выражения запросов LINQ, вы на самом деле будете применять многочисленные расширяющие методы, уже определенные разработчиками из Microsoft. Фактически каждая операция запроса LINQ в C# представляет собой сокращенную запись для ручного вызова лежащего в основе расширяющего метода, который обычно определен в служебном классе
System.Linq.Enumerable
.
Последним средством языка С#, описание которого здесь кратко повторяется, являются анонимные типы, рассмотренные в главе 11. Данное средство может использоваться для быстрого моделирования "формы" данных, разрешая компилятору генерировать на этапе компиляции новое определение класса, которое основано на предоставленном наборе пар "имя-значение". Вспомните, что результирующий тип составляется с применением семантики на основе значений, а каждый виртуальный метод
System.Object
будет соответствующим образом переопределен. Чтобы определить анонимный тип, понадобится объявить неявно типизированную переменную и указать форму данных с использованием синтаксиса инициализации объектов:
// Создать анонимный тип, состоящий из еще одного анонимного типа.
var purchaseItem = new {
TimeBought = DateTime.Now,
ItemBought =
new {Color = "Red", Make = "Saab", CurrentSpeed = 55},
Price = 34.000};
Анонимные типы часто применяются в LINQ, когда необходимо проецировать в новые формы данных на лету. Например, предположим, что есть коллекция объектов
Person
, и вы хотите использовать LINQ для получения информации о возрасте и номере карточки социального страхования в каждом объекте. Применяя проецироавние LINQ, можно предоставить компилятору возможность генерации нового анонимного типа, который содержит интересующую информацию.
На этом краткий обзор средств языка С#, которые позволяют LINQ делать свою работу, завершен. Однако важно понимать, зачем вообще нужен язык LINQ. Любой разработчик программного обеспечения согласится с утверждением, что значительное время при программировании тратится на получение и манипулирование данными. Когда говорят о "данных", на ум немедленно приходит информация, хранящаяся внутри реляционных баз данных. Тем не менее, другими популярными местоположениями для данных являются документы XML или простые текстовые файлы.
Данные могут находиться в многочисленных местах помимо указанных двух распространенных хранилищ информации. Например, пусть имеется массив или обобщенный тип
List
, содержащий 300 целых чисел, и требуется получить подмножество, которое удовлетворяет заданному критерию (например, только четные или нечетные числа, только простые числа, только неповторяющиеся числа больше 50). Или, возможно, при использовании API-интерфейсов рефлексии необходимо получить в массиве элементов Туре только описания метаданных для каждого класса, производного от какого-то родительского класса. На самом деле данные находятся повсюду.
До появления версии .NET 3.5 взаимодействие с отдельной разновидностью данных требовало от программистов применения совершенно несходных API-интерфейсов. В табл. 13.1 описаны некоторые популярные API-интерфейсы, используемые для доступа к разнообразным типам данных (наверняка вы в состоянии привести и другие примеры).
Разумеется, с такими подходами к манипулированию данными не связано ничего плохого. В сущности, вы можете (и будете) работать напрямую с ADO.NET, пространствами имен XML, службами рефлексии и разнообразными типами коллекций. Однако основная проблема заключается в том, что каждый из API-интерфейсов подобного рода является "самостоятельным островком", трудно интегрируемым с другими. Правда, можно (например) сохранить объект
DataSet
из ADO.NET в документ XML и затем манипулировать им посредством пространств имен System.xml
, но все равно манипуляции данными остаются довольно асимметричными.
В рамках API-интерфейса LINQ была предпринята попытка предложить программистам согласованный, симметричный способ получения и манипулирования "данными" в широком смысле этого понятия. Применяя LINQ, прямо внутри языка программирования C# можно создавать конструкции, которые называются выражениями запросов. Такие выражения запросов основаны на многочисленных операциях запросов, которые намеренно сделаны похожими внешне и по поведению (но не идентичными) на выражения SQL.
Тем не менее, трюк заключается в том, что выражение запроса может использоваться для взаимодействия с разнообразными типами данных — даже с теми, которые не имеют ничего общего с реляционными базами данных. Строго говоря, LINQ представляет собой термин, в целом описывающий сам подход доступа к данным. Однако в зависимости от того, где применяются запросы LINQ, вы встретите разные обозначения вроде перечисленных ниже.
• LINQ to Objects. Этот термин относится к действию по применению запросов LINQ к массивам и коллекциям.
• LINQ to XML. Этот термин относится к действию по использованию LINQ для манипулирования и запрашивания документов XML.
• LINQ to Entities. Этот аспект LINQ позволяет использовать запросы LINQ внутри API-интерфейса ADO.NET Entity Framework (EF) Core.
• Parallel LINQ (PLINQ). Этот аспект делает возможной параллельную обработку данных, возвращаемых из запроса LINQ.
В настоящее время LINQ является неотъемлемой частью библиотек базовых классов .NET Core, управляемых языков и самой среды Visual Studio.
Важно также отметить, что выражение запроса LINQ (в отличие от традиционного оператора SQL) строго типизировано. Следовательно, компилятор C# следит за этим и гарантирует, что выражения оформлены корректно с точки зрения синтаксиса. Инструменты вроде Visual Studio могут применять метаданные для поддержки удобных средств, таких как IntelliSense, автозавершение и т.д.
Для работы с LINQ to Objects вы должны обеспечить импортирование пространства имен
System.Linq
в каждом файле кода С#, который содержит запросы LINQ. В противном случае возникнут проблемы. Удостоверьтесь, что в каждом файле кода, где используется LINQ, присутствует следующий оператор using
:
using System.Linq;
Чтобы начать исследование LINQ to Objects, давайте построим приложение, которое будет применять запросы LINQ к разнообразным объектам типа массива. Создайте новый проект консольного приложения под названием
LinqOverArray
и определите в классе Program
статический вспомогательный метод по имени QueryOverStrings()
. Внутри метода создайте массив типа string
, содержащий несколько произвольных элементов (скажем, названий видеоигр). Удостоверьтесь в том, что хотя бы два элемента содержат числовые значения и несколько элементов включают внутренние пробелы:
static void QueryOverStrings()
{
// Предположим, что есть массив строк.
string[] currentVideoGames = {"Morrowind", "Uncharted 2",
"Fallout 3", "Daxter", "System
Shock 2"};
}
Теперь модифицируйте файл
Program.cs
с целью вызова метода QueryOver Strings()
:
Console.WriteLine("***** Fun with LINQ to Objects *****\n");
QueryOverStrings();
Console.ReadLine();
При работе с любым массивом данных часто приходится извлекать из него подмножество элементов на основе определенного критерия. Возможно, требуется получить только элементы, которые содержат число (например, "System Shock 2", "Uncharted 2" и "Fallout 3"), содержат заданное количество символов либо не содержат встроенных пробелов (скажем, "Morrowind" или "Daxter"). В то время как такие задачи определенно можно решать с использованием членов типа
System.Array
, прикладывая приличные усилия, выражения запросов LINQ значительно упрощают процесс.
Исходя из предположения, что из массива нужно получить только элементы, содержащие внутри себя пробел, и представить их в алфавитном порядке, можно построить следующее выражение запроса LINQ:
static void QueryOverStrings()
{
// Предположим, что имеется массив строк.
string[] currentVideoGames = {"Morrowind", "Uncharted 2",
"Fallout 3", "Daxter", "System
Shock 2"};
// Построить выражение запроса для нахождения
// элементов массива, которые содержат пробелы.
IEnumerable subset =
from g in currentVideoGames
where g.Contains(" ")
orderby g
select g;
// Вывести результаты.
foreach (string s in subset)
{
Console.WriteLine("Item: {0}", s);
}
}
Обратите внимание, что в созданном здесь выражении запроса применяются операции
from
, in
, where
, orderby
и select
языка LINQ. Формальности синтаксиса выражений запросов будут подробно излагаться далее в главе. Тем не менее, даже сейчас вы в состоянии прочесть данный оператор примерно так: "предоставить мне элементы из currentVideoGames
, содержащие пробелы, в алфавитном порядке".
Каждому элементу, который соответствует критерию поиска, назначается имя
g
(от "game"), но подошло бы любое допустимое имя переменной С#:
IEnumerable subset =
from game in currentVideoGames
where game.Contains(" ")
orderby game
select game;
Возвращенная последовательность сохраняется в переменной по имени
subset
, которая имеет тип, реализующий обобщенную версию интерфейса IEnumerable
, где Т
— тип System.String
(в конце концов, вы запрашиваете массив элементов string
). После получения результирующего набора его элементы затем просто выводятся на консоль с использованием стандартной конструкции foreach
. Запустив приложение, вы получите следующий вывод:
***** Fun with LINQ to Objects *****
Item: Fallout 3
Item: System Shock 2
Item: Uncharted 2
Применяемый ранее (и далее в главе) синтаксис LINQ называется выражениями запросов LINQ, которые представляют собой формат, похожий на SQL, но слегка отличающийся от него. Существует еще один синтаксис с расширяющими методами, который будет использоваться в большинстве примеров в настоящей книге. Создайте новый метод по имени
QueryOverStringsWithExtensionMethods()
со следующим кодом:
static void QueryOverStringsWithExtensionMethods()
{
// Пусть имеется массив строк.
string[] currentVideoGames = {"Morrowind", "Uncharted 2",
"Fallout 3", "Daxter", "System Shock 2"};
// Построить выражение запроса для поиска
// в массиве элементов, содержащих пробелы.
IEnumerable subset =
currentVideoGames.Where(g => g.Contains(" "))
.OrderBy(g => g).Select(g => g);
// Вывести результаты.
foreach (string s in subset)
{
Console.WriteLine("Item: {0}", s);
}
}
Код здесь тот же, что и в предыдущем методе, кроме строк, выделенных полужирным. В них демонстрируется применение синтаксиса расширяющих методов, в котором для определения операций внутри каждого метода используются лямбда-выражения. Например, лямбда-выражение в методе
Where()
определяет условие (содержит ли значение пробел). Как и в синтаксисе выражений запросов, используемая для идентификации значения буква произвольна; в примере применяется v
для видеоигр (video game).
Хотя результаты аналогичны (метод дает такой же вывод, как и предыдущий метод, использующий выражение запроса), вскоре вы увидите, что тип результирующего набора несколько отличается. В большинстве (если фактически не во всех) сценариях подобное отличие не приводит к каким-либо проблемам и форматы могут применяться взаимозаменяемо.
Конечно, применение LINQ никогда не бывает обязательным. При желании идентичный результирующий набор можно получить без участия LINQ с помощью таких программных конструкций, как операторы
if
и циклы for
. Ниже приведен метод, который выдает тот же самый результат, что и QueryOverStrings()
, но в намного более многословной манере:
static void QueryOverStringsLongHand()
{
// Предположим, что имеется массив строк.
string[] currentVideoGames = {"Morrowind", "Uncharted 2",
"Fallout 3", "Daxter", "System Shock 2"};
string[] gamesWithSpaces = new string[5];
for (int i = 0; i < currentVideoGames.Length; i++)
{
if (currentVideoGames[i].Contains(" "))
{
gamesWithSpaces[i] = currentVideoGames[i];
}
}
// Отсортировать набор.
Array.Sort(gamesWithSpaces);
// Вывести результаты.
foreach (string s in gamesWithSpaces)
{
if( s != null)
{
Console.WriteLine("Item: {0}", s);
}
}
Console.WriteLine();
}
Несмотря на возможные пути улучшения метода
QueryOverStringsLongHand()
, факт остается фактом — запросы LINQ способны радикально упростить процесс извлечения новых подмножеств данных из источника. Вместо построения вложенных циклов, сложной логики if/else
, временных типов данных и т.п. компилятор С# сделает всю черновую работу, как только вы создадите подходящий запрос LINQ.
А теперь определите в классе
Program
дополнительный вспомогательный метод по имени ReflectOverQueryResults()
, который выводит на консоль разнообразные детали о результирующем наборе LINQ (обратите внимание на параметр типа System.Object
, позволяющий учитывать множество типов результирующих наборов):
static void ReflectOverQueryResults(object resultSet,
string queryType = "Query Expressions")
{
Console.WriteLine($"***** Info about your query using {queryType} *****");
// Вывести тип результирующего набора
Console.WriteLine("resultSet is of type: {0}", resultSet.GetType().Name);
// Вывести местоположение результирующего набора
Console.WriteLine("resultSet location: {0}",
resultSet.GetType().Assembly.GetName().Name);
}
Модифицируйте код метода
QueryOverStrings()
следующим образом:
// Построить выражение запроса для поиска
// в массиве элементов, содержащих пробел.
IEnumerable subset =
from g in currentVideoGames
where g.Contains(" ")
orderby g
select g;
ReflectOverQueryResults(subset);
// Вывести результаты.
foreach (string s in subset)
{
Console.WriteLine("Item: {0}", s);
}
Запустив приложение, легко заметить, что переменная
subset
в действительности представляет собой экземпляр обобщенного типа OrderedEnumerable
(представленного в коде CIL как OrderedEnumerable`2
), который является внутренним абстрактным типом, находящимся в сборке System.Linq.dll
:
***** Info about your query using Query Expressions*****
resultSet is of type: OrderedEnumerable`2
resultSet location: System.Linq
Внесите такое же изменение в код метода
QueryOverStringsWithExtensionMethods()
, но передав во втором параметре строку "Extension Methods
":
// Построить выражение запроса для поиска
// в массиве элементов, содержащих пробел.
IEnumerable subset =
currentVideoGames
.Where(g => g.Contains(" "))
.OrderBy(g => g)
.Select(g => g);
ReflectOverQueryResults(subset,"Extension Methods");
// Вывести результаты.
foreach (string s in subset)
{
Console.WriteLine("Item: {0}", s);
}
После запуска приложения выяснится, что переменная
subset
является экземпляром типа SelectIPartitionIterator
. Но если удалить из запроса конструкцию Select(g=>g)
, то subset
снова станет экземпляром типа OrderedEnumerable
. Что все это значит? Для подавляющего большинства разработчиков немногое (если вообще что-либо). Оба типа являются производными от IEnumerable
, проход по ним осуществляется одинаковым образом и они оба способны создавать список или массив своих значений.
***** Info about your query using Extension Methods *****
resultSet is of type: SelectIPartitionIterator`2
resultSet location: System.Linq
Хотя в приведенной программе относительно легко выяснить, что результирующий набор может быть интерпретирован как перечисление объектов
string
(например, IEnumerable
), тот факт, что подмножество на самом деле имеет тип OrderedEnumerable
, не настолько ясен.
Поскольку результирующие наборы LINQ могут быть представлены с применением порядочного количества типов из разнообразных пространств имен LINQ, было бы утомительно определять подходящий тип для хранения результирующего набора. Причина в том, что во многих случаях лежащий в основе тип не очевиден и даже напрямую не доступен в коде (и как вы увидите, в ряде ситуаций тип генерируется на этапе компиляции).
Чтобы еще больше подчеркнуть данное обстоятельство, ниже показан дополнительный вспомогательный метод, определенный внутри класса
Program
:
static void QueryOverInts()
{
int[] numbers = {10, 20, 30, 40, 1, 2, 3, 8};
// Вывести только элементы меньше 10.
IEnumerable subset = from i in numbers where i < 10 select i;
foreach (int i in subset)
{
Console.WriteLine("Item: {0}", i);
}
ReflectOverQueryResults(subset);
}
В рассматриваемом случае переменная
subset
имеет совершенно другой внутренний тип. На этот раз тип, реализующий интерфейс IEnumerable
, представляет собой низкоуровневый класс по имени WhereArrayIterator
:
Item: 1
Item: 2
Item: 3
Item: 8
***** Info about your query *****
resultSet is of type: WhereArrayIterator`1
resultSet location: System.Linq
Учитывая, что точный тип запроса LINQ не вполне очевиден, в первых примерах результаты запросов были представлены как переменная
IEnumerable
, где Т
— тип данных в возвращенной последовательности (string
, int
и т.д.). Тем не менее, ситуация по-прежнему довольно запутана. Чтобы еще больше все усложнить, стоит упомянуть, что поскольку интерфейс IEnumerable
расширяет необобщенный IEnumerable
, получать результат запроса LINQ допускается и так:
System.Collections.IEnumerable subset =
from i in numbers
where i < 10
select i;
К счастью, неявная типизация при работе с запросами LINQ значительно проясняет картину:
static void QueryOverInts()
{
int[] numbers = {10, 20, 30, 40, 1, 2, 3, 8};
// Здесь используется неявная типизация...
var subset = from i in numbers where i < 10 select i;
// ...и здесь тоже.
foreach (var i in subset)
{
Console.WriteLine("Item: {0} ", i);
}
ReflectOverQueryResults(subset);
}
В качестве эмпирического правила: при захвате результатов запроса LINQ всегда необходимо использовать неявную типизацию. Однако помните, что (в большинстве случаев) действительное возвращаемое значение имеет тип, реализующий интерфейс
IEnumerable
.
Какой точно тип кроется за ним (
OrderedEnumerable
, WhereArrayIterator
и т.п.), к делу не относится, и определять его вовсе не обязательно. Как было показано в предыдущем примере кода, для прохода по извлеченным данным можно просто применить ключевое слово var внутри конструкции foreach
.
Несмотря на то что в текущем примере совершенно не требуется напрямую писать какие-то расширяющие методы, на самом деле они благополучно используются на заднем плане. Выражения запросов LINQ могут применяться для прохода по содержимому контейнеров данных, которые реализуют обобщенный интерфейс
IEnumerable
. Тем не менее, класс System.Array
(используемый для представления массива строк и массива целых чисел) не реализует этот контракт:
// Похоже, что тип System.Array не реализует
// корректную инфраструктуру для выражений запросов!
public abstract class Array : ICloneable, IList,
IStructuralComparable, IStructuralEquatable
{
...
}
Хотя класс
System.Array
не реализует напрямую интерфейс IEnumerable
, он косвенно получает необходимую функциональность данного типа (а также многие другие члены, связанные с LINQ) через статический тип класса System.Linq.Enumerable
.
В служебном классе
System.Linq.Enumerable
определено множество обобщенных расширяющих методов (таких как Aggregate()
, First()
, Мах<Т>()
и т.д.), которые класс System.Array
(и другие типы) получают в свое распоряжение на заднем плане. Таким образом, если вы примените операцию точки к локальной переменной currentVideoGames
, то обнаружите большое количество членов, которые отсутствуют в формальном определении System.Array
.
Еще один важный момент, касающийся выражений запросов LINQ, заключается в том, что фактически они не оцениваются до тех пор, пока не начнется итерация по результирующей последовательности. Формально это называется отложенным выполнением. Преимущество такого подхода связано с возможностью применения одного и того же запроса LINQ многократно к тому же самому контейнеру и полной гарантией получения актуальных результатов. Взгляните на следующее обновление метода
QueryOverlnts()
:
static void QueryOverInts()
{
int[] numbers = { 10, 20, 30, 40, 1, 2, 3, 8 };
// Получить числа меньше 10.
var subset = from i in numbers where i < 10 select i;
// Оператор LINQ здесь оценивается!
foreach (var i in subset)
{
Console.WriteLine("{0} < 10", i);
}
Console.WriteLine();
// Изменить некоторые данные в массиве.
numbers[0] = 4;
// Снова производится оценка!
foreach (var j in subset)
{
Console.WriteLine("{0} < 10", j);
}
Console.WriteLine();
ReflectOverQueryResults(subset);
}
На заметку! Когда оператор LINQ выбирает одиночный элемент (с использованием
First()/FirstOrDefault()
, Single()/SingleOrDefault()
или любого метода агрегирования), запрос выполняется немедленно. Методы First()
, FirstOrDefault()
, Single()
и SingleOrDefault
будут описаны в следующем разделе. Методы агрегирования раскрываются позже в главе.
Ниже показан вывод, полученный в результате запуска программы. Обратите внимание, что во второй итерации по запрошенной последовательности появился дополнительный член, т.к. для первого элемента массива было установлено значение меньше 10:
1 < 10
2 < 10
3 < 10
8 < 10
4 < 10
1 < 10
2 < 10
3 < 10
8 < 10
Среда Visual Studio обладает одной полезной особенностью: если вы поместите точку останова перед оценкой запроса LINQ, то получите возможность просматривать содержимое во время сеанса отладки. Просто наведите курсор мыши на переменную результирующего набора LINQ (
subset
на рис. 13.1) и вам будет предложено выполнить запрос, развернув узел Results View (Представление результатов).
Когда требуется оценить выражение LINQ, выдающее последовательность, за пределами логики
foreach
, можно вызывать любое количество расширяющих методов, определенных в типе Enumerable
, таких как ТоArray<Т>()
, ToDictionary()
и ToList()
. Все методы приводят к выполнению запроса LINQ в момент их вызова для получения снимка данных. Затем полученным снимком данных можно манипулировать независимым образом.
Кроме того, запрос выполняется немедленно в случае поиска только одного элемента. Метод
First()
возвращает первый элемент последовательности (и должен всегда применяться с конструкцией orderby
). Метод FirstOrDefault()
возвращает стандартное значение для типа элемента в последовательности, если возвращать нечего, например, когда исходная последовательность пуста или конструкция where
отбросила все элементы. Метод Single()
также возвращает первый элемент последовательности (на основе orderby
или согласно порядку следования элементов, если конструкция orderby
отсутствует). Подобно аналогично именованному эквиваленту метод SingleOrDefault()
возвращает стандартное значение для типа элемента в последовательности, если последовательность пуста (или конструкция where
отбросила все элементы). Отличие между методами First(OrDefault)
и Single(OrDefault)
заключается в том, что Single(OrDefault)
сгенерирует исключение, если из запроса будет возвращено более одного элемента.
static void ImmediateExecution()
{
Console.WriteLine();
Console.WriteLine("Immediate Execution");
int[] numbers = { 10, 20, 30, 40, 1, 2, 3, 8 };
// Получить первый элемент в порядке последовательности
int number = (from i in numbers select i).First();
Console.WriteLine("First is {0}", number);
// Получить первый элемент в порядке запроса
number = (from i in numbers orderby i select i).First();
Console.WriteLine("First is {0}", number);
// Получить один элемент, который соответствует запросу
number = (from i in numbers where i > 30 select i).Single();
Console.WriteLine("Single is {0}", number);
try
{
// В случае возвращения более одного элемента генерируется исключение
number = (from i in numbers where i > 10 select i).Single();
}
catch (Exception ex)
{
Console.WriteLine("An exception occurred: {0}", ex.Message);
}
// Получить данные НЕМЕДЛЕННО как int[].
int[] subsetAsIntArray =
(from i in numbers where i < 10 select i).ToArray();
// Получить данные НЕМЕДЛЕННО как List.
List subsetAsListOfInts =
(from i in numbers where i < 10 select i).ToList();
}
Обратите внимание, что для вызова методов
Enumerable
выражение LINQ целиком помещено в круглые скобки с целью приведения к корректному внутреннему типу (каким бы он ни был).
Вспомните из главы 10, что если компилятор C# в состоянии однозначно определить параметр типа обобщенного элемента, то вы не обязаны указывать этот параметр типа. Следовательно,
ТоArray<Т>()
(или ToList()
) можно было бы вызвать так:
int[] subsetAsIntArray =
(from i in numbers where i < 10 select i).ToArray();
Полезность немедленного выполнения очевидна, когда нужно возвратить результаты запроса LINQ внешнему вызывающему коду, что и будет темой следующего раздела главы.
Внутри класса (или структуры) можно определить поле, значением которого будет результат запроса LINQ. Однако для этого нельзя использовать неявную типизацию (т.к. ключевое слово
var
не может применяться к полям), и целью запроса LINQ не могут быть данные уровня экземпляра, а потому он должен быть статическим. С учетом указанных ограничений необходимость в написании кода следующего вида будет возникать редко:
class LINQBasedFieldsAreClunky
{
private static string[] currentVideoGames =
{"Morrowind", "Uncharted 2",
"Fallout 3", "Daxter", "System Shock 2"};
// Здесь нельзя использовать неявную типизацию!
// Тип subset должен быть известен!
private IEnumerable subset =
from g in currentVideoGames
where g.Contains(" ")
orderby g
select g;
public void PrintGames()
{
foreach (var item in subset)
{
Console.WriteLine(item);
}
}
}
Запросы LINQ часто определяются внутри области действия метода или свойства. Кроме того, для упрощения программирования результирующий набор будет храниться в неявно типизированной локальной переменной, использующей ключевое слово
var
. Вспомните из главы 3, что неявно типизированные переменные не могут применяться для определения параметров, возвращаемых значений, а также полей класса или структуры.
Итак, вполне вероятно, вас интересует, каким образом возвратить результат запроса внешнему коду. Ответ: в зависимости от обстоятельств. Если у вас есть результирующий набор, состоящий из строго типизированных данных, такой как массив строк или список
List
объектов Car
, тогда вы могли бы отказаться от использования ключевого слова var
и указать подходящий тип IEnumerable
либо IEnumerable
(т.к. IEnumerable
расширяет IEnumerable
). Ниже приведен пример класса Program
в новом проекте консольного приложения по имени LinqRetValues
:
using System;
using System.Collections.Generic;
using System.Linq;
Console.WriteLine("***** LINQ Return Values *****\n");
IEnumerable subset = GetStringSubset();
foreach (string item in subset)
{
Console.WriteLine(item);
}
Console.ReadLine();
static IEnumerable GetStringSubset()
{
string[] colors = {"Light Red", "Green", "Yellow", "Dark Red", "Red", "Purple"};
// Обратите внимание, что subset является
// совместимым с IEnumerable объектом.
IEnumerable theRedColors =
from c in colors where c.Contains("Red") select c;
return theRedColors;
}
Результат выглядит вполне ожидаемо:
Light Red
Dark Red
Red
Рассмотренный пример работает ожидаемым образом только потому, что возвращаемое значение
GetStringSubset()
и запрос LINQ внутри этого метода были строго типизированными. Если применить ключевое слово var
для определения переменной subset
, то возвращать значение будет разрешено, только если метод по-прежнему прототипирован с возвращаемым типом IEnumerable
(и если неявно типизированная локальная переменная на самом деле совместима с указанным возвращаемым типом).
Поскольку оперировать с типом
IEnumerable
несколько неудобно, можно задействовать немедленное выполнение. Скажем, вместо возвращения IEnumerable
можно было бы возвратить просто string[]
при условии трансформации последовательности в строго типизированный массив. Именно такое действие выполняет новый метод класса Program
:
static string[] GetStringSubsetAsArray()
{
string[] colors = {"Light Red", "Green",
"Yellow", "Dark Red", "Red", "Purple"};
var theRedColors = from c in colors where c.Contains("Red") select c;
// Отобразить результаты в массив.
return theRedColors.ToArray();
}
В таком случае вызывающий код совершенно не знает, что полученный им результат поступил от запроса LINQ, и просто работает с массивом строк вполне ожидаемым образом. Вот пример:
foreach (string item in GetStringSubsetAsArray())
{
Console.WriteLine(item);
}
Немедленное выполнение также важно при попытке возвратить вызывающему коду результаты проецирования LINQ. Мы исследуем эту тему чуть позже в главе. А сейчас давайте посмотрим, как применять запросы LINQ к обобщенным и необобщенным объектам коллекций.
Помимо извлечения результатов из простого массива данных выражения запросов LINQ могут также манипулировать данными внутри классов из пространства имен
System.Collections.Generic
, таких как List
. Создайте новый проект консольного приложения по имени ListOverCollections
и определите базовый класс Car
, который поддерживает текущую скорость, цвет, производителя и дружественное имя:
namespace LinqOverCollections
{
class Car
{
public string PetName {get; set;} = "";
public string Color {get; set;} = "";
public int Speed {get; set;}
public string Make {get; set;} = "";
}
}
Теперь определите внутри операторов верхнего уровня локальную переменную типа
List
для хранения элементов типа Car
и с помощью синтаксиса инициализации объектов заполните список несколькими новыми объектами Car
:
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using LinqOverCollections;
Console.WriteLine("***** LINQ over Generic Collections *****\n");
// Создать список List<> объектов Car.
List myCars = new List() {
new Car{ PetName = "Henry", Color = "Silver", Speed = 100, Make = "BMW"},
new Car{ PetName = "Daisy", Color = "Tan", Speed = 90, Make = "BMW"},
new Car{ PetName = "Mary", Color = "Black", Speed = 55, Make = "VW"},
new Car{ PetName = "Clunker", Color = "Rust", Speed = 5, Make = "Yugo"},
new Car{ PetName = "Melvin", Color = "White", Speed = 43, Make = "Ford"}
};
Console.ReadLine();
Применение запроса LINQ к обобщенному контейнеру ничем не отличается от такого же действия в отношении простого массива, потому что LINQ to Objects может использоваться с любым типом, реализующим интерфейс
IEnumerable
. На этот раз цель заключается в построении выражения запроса для выборки из списка myCars
только тех объектов Car
, у которых значение скорости больше 55
.
После получения подмножества на консоль будет выведено имя каждого объекта
Car
за счет обращения к его свойству PetName
. Предположим, что определен следующий вспомогательный метод (принимающий параметр List
), который вызывается в операторах верхнего уровня:
static void GetFastCars(List myCars)
{
// Найти в List<> все объекты Car, у которых значение Speed больше 55.
var fastCars = from c in myCars where c.Speed > 55 select c;
foreach (var car in fastCars)
{
Console.WriteLine("{0} is going too fast!", car.PetName);
}
}
Обратите внимание, что выражение запроса захватывает из
List
только те элементы, у которых значение Speed
больше 55
. Запустив приложение, вы увидите, что критерию поиска отвечают только два элемента — Нenry
и Daisy
.
Чтобы построить более сложный запрос, можно искать только автомобили марки BMW со значением
Speed
больше 90
. Для этого нужно просто создать составной булевский оператор с применением операции &&
языка С#:
static void GetFastBMWs(List myCars)
{
// Найти быстрые автомобили BMW!
var fastCars = from c in myCars
where c.Speed > 90 && c.Make == "BMW" select c;
foreach (var car in fastCars)
{
Console.WriteLine("{0} is going too fast!", car.PetName);
}
}
Теперь выводится только одно имя
Henry
.
Вспомните, что операции запросов LINQ спроектированы для работы с любым типом, реализующим интерфейс
IEnumerable
(как напрямую, так и через расширяющие методы). Учитывая то, что класс System.Array
оснащен всей необходимой инфраструктурой, может оказаться сюрпризом, что унаследованные (необобщенные) контейнеры в пространстве имен System.Collections
такой поддержкой не обладают. К счастью, итерация по данным, содержащимся внутри необобщенных коллекций, по-прежнему возможна с использованием обобщенного расширяющего метода Enumerable.OfТуре<Т>()
.
При вызове метода
OfТуре<Т>()
на объекте необобщенной коллекции (наподобие ArrayList
) нужно просто указать тип элемента внутри контейнера, чтобы извлечь совместимый с IEnumerable
объект. Сохранить этот элемент данных в коде можно посредством неявно типизированной переменной.
Взгляните на показанный ниже новый метод, который заполняет
ArrayList
набором объектов Car
(не забудьте импортировать пространство имен System.Collections
в начале файла Program.cs
):
static void LINQOverArrayList()
{
Console.WriteLine("***** LINQ over ArrayList *****");
// Необобщенная коллекция объектов Car.
ArrayList myCars = new ArrayList() {
new Car{ PetName = "Henry", Color = "Silver", Speed = 100, Make = "BMW"},
new Car{ PetName = "Daisy", Color = "Tan", Speed = 90, Make = "BMW"},
new Car{ PetName = "Mary", Color = "Black", Speed = 55, Make = "VW"},
new Car{ PetName = "Clunker", Color = "Rust", Speed = 5, Make = "Yugo"},
new Car{ PetName = "Melvin", Color = "White", Speed = 43, Make = "Ford"}
};
// Трансформировать ArrayList в тип, совместимый c IEnumerable.
var myCarsEnum = myCars.OfType();
// Создать выражение запроса, нацеленное на совместимый с IEnumerable тип.
var fastCars = from c in myCarsEnum where c.Speed > 55 select c;
foreach (var car in fastCars)
{
Console.WriteLine("{0} is going too fast!", car.PetName);
}
}
Аналогично предшествующим примерам этот метод, вызванный в операторах верхнего уровня, отобразит только имена
Henry
и Daisy
, основываясь на формате запроса LINQ.
Как вы уже знаете, необобщенные типы способны содержать любые комбинации элементов, поскольку члены этих контейнеров (вроде
ArrayList
) прототипированы для приема System.Object
. Например, предположим, что ArrayList
содержит разные элементы, часть которых являются числовыми. Получить подмножество, состоящее только из числовых данных, можно с помощью метода OfТуре<Т>()
, т.к. во время итерации он отфильтрует элементы, тип которых отличается от заданного:
static void OfTypeAsFilter()
{
// Извлечь из ArrayList целочисленные значения.
ArrayList myStuff = new ArrayList();
myStuff.AddRange(new object[] { 10, 400, 8, false, new Car(), "string data" });
var myInts = myStuff.OfType();
// Выводит 10, 400 и 8.
foreach (int i in myInts)
{
Console.WriteLine("Int value: {0}", i);
}
}
К настоящему моменту вы уже умеете применять запросы LINQ к массивам, а также обобщенным и необобщенным коллекциям. Контейнеры подобного рода содержат элементарные типы C# (целочисленные и строковые данные) и специальные классы. Следующей задачей будет изучение многочисленных дополнительных операций LINQ, которые могут использоваться для построения более сложных и полезных запросов.
В языке C# предопределено порядочное число операций запросов. Некоторые часто применяемые из них перечислены в табл. 13.2. В дополнение к неполному списку операций, приведенному в табл. 13.3, класс
System.Linq.Enumerable
предлагает набор методов, которые не имеют прямого сокращенного обозначения в виде операций запросов С#, а доступны как расширяющие методы. Эти обобщенные методы можно вызывать для трансформации результирующего набора разными способами (Reverse<>()
, ToArray<>()
, ToList<>()
и т.д.). Некоторые из них применяются для извлечения одиночных элементов из результирующего набора, другие выполняют разнообразные операции над множествами (Distinct<>()
, Union<>()
, Intersect<>()
и т.п.), а есть еще те, что агрегируют результаты (Count<>()
, Sum<>()
, Min<>()
, Мах<>()
и т.д.).
Чтобы приступить к исследованию более замысловатых запросов LINQ, создайте новый проект консольного приложения по имени
FunWithLinqExpressions
и затем определите массив или коллекцию некоторых выборочных данных. В проекте FunWithLinqExpressions
вы будете создавать массив объектов типа ProductInfo
, определенного следующим образом:
namespace FunWithLinqExpressions
{
class ProductInfo
{
public string Name {get; set;} = "";
public string Description {get; set;} = "";
public int NumberInStock {get; set;} = 0;
public override string ToString()
=> $"Name={Name}, Description={Description},
Number in Stock={NumberInStock}";
}
}
Теперь заполните массив объектами
ProductInfo
в вызывающем коде:
Console.WriteLine("***** Fun with Query Expressions *****\n");
// Этот массив будет основой для тестирования...
ProductInfo[] itemsInStock = new[] {
new ProductInfo{ Name = "Mac's Coffee",
Description = "Coffee with TEETH", NumberInStock = 24},
new ProductInfo{ Name = "Milk Maid Milk",
Description = "Milk cow's love", NumberInStock = 100},
new ProductInfo{ Name = "Pure Silk Tofu",
Description = "Bland as Possible", NumberInStock = 120},
new ProductInfo{ Name = "Crunchy Pops",
Description = "Cheezy, peppery goodness",
NumberInStock = 2},
new ProductInfo{ Name = "RipOff Water",
Description = "From the tap to your wallet",
NumberInStock = 100},
new ProductInfo{ Name = "Classic Valpo Pizza",
Description = "Everyone loves
pizza!", NumberInStock = 73}
};
// Здесь мы будем вызывать разнообразные методы!
Console.ReadLine();
Поскольку синтаксическая корректность выражения запроса LINQ проверяется на этапе компиляции, вы должны помнить, что порядок следования операций критически важен. В простейшем виде каждый запрос LINQ строится с использованием операций
from
, in
и select
. Вот базовый шаблон, который нужно соблюдать:
var результат =
from сопоставляемыйЭлемент in контейнер
select сопоставляемыйЭлемент;
Элемент после операции
from
представляет элемент, соответствующий критерию запроса LINQ; именовать его можно по своему усмотрению. Элемент после операции in представляет контейнер данных, в котором производится поиск (массив, коллекция, документ XML и т.д.).
Рассмотрим простой запрос, не делающий ничего кроме извлечения каждого элемента контейнера (по поведению похожий на SQL-оператор
SELECT *
в базе данных):
static void SelectEverything(ProductInfo[] products)
{
// Получить все!
Console.WriteLine("All product details:");
var allProducts = from p in products select p;
foreach (var prod in allProducts)
{
Console.WriteLine(prod.ToString());
}
}
По правде говоря, это выражение запроса не особенно полезно, т.к. оно выдает подмножество, идентичное содержимому входного параметра. При желании можно извлечь только значения
Name
каждого товара, применив следующий синтаксис выборки:
static void ListProductNames(ProductInfo[] products)
{
// Теперь получить только наименования товаров.
Console.WriteLine("Only product names:");
var names = from p in products select p.Name;
foreach (var n in names)
{
Console.WriteLine("Name: {0}", n);
}
}
Чтобы получить определенное подмножество из контейнера, можно использовать операцию
where
. Общий шаблон запроса становится таким:
var результат =
from элемент in контейнер
where булевскоеВыражение
select элемент;
Обратите внимание, что операция where ожидает выражение, результатом вычисления которого является булевское значение. Например, чтобы извлечь из аргумента
ProductInfo[]
только товарные позиции, складские запасы которых составляют более 25 единиц, можно написать следующий код:
static void GetOverstock(ProductInfo[] products)
{
Console.WriteLine("The overstock items!");
// Получить только товары со складским запасом более 25 единиц.
var overstock =
from p
in products
where p.NumberInStock > 25
select p;
foreach (ProductInfo c in overstock)
{
Console.WriteLine(c.ToString());
}
}
Как демонстрировалось ранее в главе, при указании конструкции
where
разрешено применять любые операции C# для построения сложных выражений. Например, вспомните запрос, который извлекал только автомобили марки BMW, движущиеся со скоростью минимум 90 миль в час:
// Получить автомобили BMW, движущиеся со скоростью минимум 90 миль в час.
var onlyFastBMWs =
from c
in myCars
where c.Make == "BMW" && c.Speed >= 100
select c;
Новые формы данных также можно проецировать из существующего источника данных. Давайте предположим, что необходимо принять входной параметр
ProductInfo[]
и получить результирующий набор, который учитывает только имя и описание каждого товара. Для этого понадобится определить оператор select
, динамически выдающий новый анонимный тип:
static void GetNamesAndDescriptions(ProductInfo[] products)
{
Console.WriteLine("Names and Descriptions:");
var nameDesc =
from p
in products
select new { p.Name, p.Description };
foreach (var item in nameDesc)
{
// Можно было бы также использовать свойства Name
// и Description напрямую.
Console.WriteLine(item.ToString());
}
}
Не забывайте, что когда запрос LINQ использует проекцию, нет никакого способа узнать лежащий в ее основе тип данных, т.к. он определяется на этапе компиляции. В подобных случаях ключевое слово
var
является обязательным. Кроме того, вспомните о невозможности создания методов с неявно типизированными возвращаемыми значениями. Таким образом, следующий метод не скомпилируется:
static var GetProjectedSubset(ProductInfo[] products)
{
var nameDesc =
from p in products select new { p.Name, p.Description };
return nameDesc; // Так поступать нельзя!
}
В случае необходимости возвращения спроецированных данных вызывающему коду один из подходов предусматривает трансформацию результата запроса в объект
System.Array
с применением расширяющего метода ТоArray()
. Следовательно, модифицировав выражение запроса, как показано ниже:
// Теперь возвращаемым значением является объект Array.
static Array GetProjectedSubset(ProductInfo[] products)
{
var nameDesc =
from p in products select new { p.Name, p.Description };
// Отобразить набор анонимных объектов на объект Array.
return nameDesc.ToArray();
}
метод
GetProjectedSubset()
можно вызвать и обработать возвращенные им данные:
Array objs = GetProjectedSubset(itemsInStock);
foreach (object o in objs)
{
Console.WriteLine(o); // Вызывает метод ToString()
// на каждом анонимном объекте.
}
Как видите, здесь должен использоваться буквальный объект
System.Array
, а применять синтаксис объявления массива C# невозможно, учитывая, что лежащий в основе проекции тип неизвестен, поскольку речь идет об анонимном классе, который сгенерирован компилятором. Кроме того, параметр типа для обобщенного метода ToArray<Т>()
не указывается, потому что он тоже не известен вплоть до этапа компиляции.
Очевидная проблема связана с утратой строгой типизации, т.к. каждый элемент в объекте
Array
считается относящимся к типу Object
. Тем не менее, когда нужно возвратить результирующий набор LINQ, который является результатом операции проецирования в анонимный тип, трансформация данных в тип Array
(или другой подходящий контейнер через другие члены типа Enumerable
) обязательна.
В дополнение к проецированию в анонимные типы результаты запроса LINQ можно проецировать в другой конкретный тип, что позволяет применять статическую типизацию и реализацию
IEnumerable
как результирующий набор. Для начала создайте уменьшенную версию класса ProductInfo
:
namespace FunWithLinqExpressions
{
class ProductInfoSmall
{
public string Name {get; set;} = "";
public string Description {get; set;} = "";
public override string ToString()
=> $"Name={Name}, Description={Description}";
}
}
Следующее изменение касается проецирования результатов запроса в коллекцию объектов
ProductInfoSmall
, а не анонимных типов. Добавьте в класс ProductInfoSmall
следующий метод:
static void GetNamesAndDescriptionsTyped(
ProductInfo[] products)
{
Console.WriteLine("Names and Descriptions:");
IEnumerable nameDesc =
from p
in products
select new ProductInfoSmall
{ Name=p.Name, Description=p.Description };
foreach (ProductInfoSmall item in nameDesc)
{
Console.WriteLine(item.ToString());
}
}
При проецировании LINQ у вас есть выбор, какой метод использовать (в анонимные или в строго типизированные объекты). Решение, которое вы примете, полностью зависит от имеющихся бизнес-требований.
Во время проецирования новых пакетов данных у вас может возникнуть необходимость выяснить количество элементов, возвращаемых внутри последовательности. Для определения числа элементов, которые возвращаются из выражения запроса LINQ, можно применять расширяющий метод
Count()
класса Enumerable
. Например, следующий метод будет искать в локальном массиве все объекты string
, которые имеют длину, превышающую шесть символов, и выводить их количество:
static void GetCountFromQuery()
{
string[] currentVideoGames = {"Morrowind", "Uncharted 2",
"Fallout 3", "Daxter", "System
Shock 2"};
// Получить количество элементов из запроса.
int numb =
(from g in currentVideoGames where g.Length > 6 select g).Count();
// Вывести количество элементов.
Console.WriteLine("{0} items honor the LINQ query.", numb);
}
Изменить порядок следования элементов в результирующем наборе на противоположный довольно легко с помощью расширяющего метода
Reverse()
класса Enumerable
. Например, в показанном далее методе выбираются все элементы из входного параметра ProductInfo[]
в обратном порядке:
static void ReverseEverything(ProductInfo[] products)
{
Console.WriteLine("Product in reverse:");
var allProducts = from p in products select p;
foreach (var prod in allProducts.Reverse())
{
Console.WriteLine(prod.ToString());
}
}
В начальных примерах настоящей главы вы видели, что в выражении запроса может использоваться операция
orderby
для сортировки элементов в подмножестве по заданному значению. По умолчанию принят порядок по возрастанию, поэтому строки сортируются в алфавитном порядке, числовые значения — от меньшего к большему и т.д. Если вы хотите просматривать результаты в порядке по убыванию, просто включите в выражение запроса операцию descending
. Взгляните на следующий метод:
static void AlphabetizeProductNames(ProductInfo[] products)
{
// Получить названия товаров в алфавитном порядке.
var subset = from p in products orderby p.Name select p;
Console.WriteLine("Ordered by Name:");
foreach (var p in subset)
{
Console.WriteLine(p.ToString());
}
}
Хотя порядок по возрастанию является стандартным, свои намерения можно прояснить, явно указав операцию
ascending
:
var subset = from p in products orderby p.Name ascending select p;
Для получения элементов в порядке убывания служит операция
descending
:
var subset = from p in products orderby p.Name descending select p;
Класс
Enumerable
поддерживает набор расширяющих методов, которые позволяют применять два (или более) запроса LINQ в качестве основы для нахождения объединений, разностей, конкатенаций и пересечений данных. Первым мы рассмотрим расширяющий метод Except()
. Он возвращает результирующий набор LINQ, содержащий разность между двумя контейнерами, которой в этом случае является значение Yugo
:
static void DisplayDiff()
{
List myCars =
new List {"Yugo", "Aztec", "BMW"};
List yourCars =
new List{"BMW", "Saab", "Aztec" };
var carDiff =
(from c in myCars select c)
.Except(from c2 in yourCars select c2);
Console.WriteLine("Here is what you don't have, but I do:");
foreach (string s in carDiff)
{
Console.WriteLine(s); // Выводит Yugo.
}
}
Метод
Intersect()
возвращает результирующий набор, который содержит общие элементы данных в наборе контейнеров. Например, следующий метод возвращает последовательность из Aztec
и BMW
:
static void DisplayIntersection()
{
List myCars = new List { "Yugo", "Aztec", "BMW" };
List yourCars = new List { "BMW", "Saab", "Aztec" };
// Получить общие члены.
var carIntersect =
(from c in myCars select c)
.Intersect(from c2 in yourCars select c2);
Console.WriteLine("Here is what we have in common:");
foreach (string s in carIntersect)
{
Console.WriteLine(s); // Выводит Aztec и BMW.
}
}
Метод
Union()
возвращает результирующий набор, который включает все члены множества запросов LINQ. Подобно любому объединению, даже если общий член встречается более одного раза, повторяющихся значений в результирующем наборе не будет. Следовательно, показанный ниже метод выведет на консоль значения Yugo
, Aztec
, BMW
и Saab
:
static void DisplayUnion()
{
List myCars =
new List { "Yugo", "Aztec", "BMW" };
List yourCars =
new List { "BMW", "Saab", "Aztec" };
// Получить объединение двух контейнеров.
var carUnion =
(from c in myCars select c)
.Union(from c2 in yourCars select c2);
Console.WriteLine("Here is everything:");
foreach (string s in carUnion)
{
Console.WriteLine(s); // Выводит все общие члены.
}
}
Наконец, расширяющий метод
Concat()
возвращает результирующий набор, который является прямой конкатенацией результирующих наборов LINQ. Например, следующий метод выводит на консоль результаты Yugo
, Aztec
, BMW
, Saab
и Aztec
:
static void DisplayConcat()
{
List myCars =
new List { "Yugo", "Aztec", "BMW" };
List yourCars =
new List { "BMW", "Saab", "Aztec" };
var carConcat =
(from c in myCars select c)
.Concat(from c2 in yourCars select c2);
// Выводит Yugo Aztec BMW BMW Saab Aztec.
foreach (string s in carConcat)
{
Console.WriteLine(s);
}
}
При вызове расширяющего метода
Concat()
в результате очень легко получить избыточные элементы, и зачастую это может быть именно тем, что нужно. Однако в других случаях может понадобиться удалить дублированные элементы данных. Для этого необходимо просто вызвать расширяющий метод Distinct()
:
static void DisplayConcatNoDups()
{
List myCars =
new List { "Yugo", "Aztec", "BMW" };
List yourCars =
new List { "BMW", "Saab", "Aztec" };
var carConcat =
(from c in myCars select c)
.Concat(from c2 in yourCars select c2);
// Выводит Yugo Aztec BMW Saab.
foreach (string s in carConcat.Distinct())
{
Console.WriteLine(s);
}
}
Запросы LINQ могут также проектироваться для выполнения над результирующим набором разнообразных операций агрегирования. Одним из примеров может служить расширяющий метод
Count()
. Другие возможности включают получение среднего, максимального, минимального или суммы значений с использованием членов Average()
, Мах()
, Min()
либо Sum()
класса Enumerable
. Вот простой пример:
Here is a simple example:static void AggregateOps()
{
double[] winterTemps = { 2.0, -21.3, 8, -4, 0, 8.2 };
// Разнообразные примеры агрегации.
// Выводит максимальную температуру:
Console.WriteLine("Max temp: {0}",
(from t in winterTemps select t).Max());
// Выводит минимальную температуру:
Console.WriteLine("Min temp: {0}",
(from t in winterTemps select t).Min());
// Выводит среднюю температуру:
Console.WriteLine("Average temp: {0}",
(from t in winterTemps select t).Average());
// Выводит сумму всех температур:
Console.WriteLine("Sum of all temps: {0}",
(from t in winterTemps select t).Sum());
}
Приведенные примеры должны предоставить достаточный объем сведений, чтобы вы освоились с процессом построения выражений запросов LINQ. Хотя существуют дополнительные операции, которые пока еще не рассматривались, вы увидите примеры позже в книге, когда речь пойдет о связанных технологиях LINQ. В завершение вводного экскурса в LINQ оставшиеся материалы главы посвящены подробностям отношений между операциями запросов LINQ и лежащей в основе объектной моделью.
К настоящему моменту вы уже знакомы с процессом построения выражений запросов с применением разнообразных операций запросов C# (таких как
from
, in
, where
, orderby
и select
). Вдобавок вы узнали, что определенная функциональность API-интерфейса LINQ to Objects доступна только через вызов расширяющих методов класса Enumerable
. В действительности при компиляции запросов LINQ компилятор C# транслирует все операции LINQ в вызовы методов класса Enumerable
.
Огромное количество методов класса
Enumerable
прототипированы для приема делегатов в качестве аргументов. Многие методы требуют обобщенный делегат по имени Funс<>
, который был описан во время рассмотрения обобщенных делегатов в главе 10. Взгляните на метод Where()
класса Enumerable
, вызываемый автоматически в случае использования операции where
:
// Перегруженные версии метода Enumerable.Where().
// Обратите внимание, что второй параметр имеет тип System.Func<>.
public static IEnumerable Where(
this IEnumerable source,
System.Func predicate)
public static IEnumerable Where(
this IEnumerable source,
System.Func predicate)
Делегат
Func<>
представляет шаблон фиксированной функции с набором до 16 аргументов и возвращаемым значением. Если вы исследуете этот тип в браузере объектов Visual Studio, то заметите разнообразные формы делегата Func<>
. Например:
// Различные формы делегата Func<>.
public delegate TResult Func
(T1 arg1, T2 arg2, T3 arg3, T4 arg4)
public delegate TResult Func(T1 arg1, T2 arg2, T3 arg3)
public delegate TResult Func(T1 arg1, T2 arg2)
public delegate TResult Func(T1 arg1)
public delegate TResult Func()
Учитывая, что многие члены класса
System.Linq.Enumerable
при вызове ожидают получить делегат, можно вручную создать новый тип делегата и написать для него необходимые целевые методы, применить анонимный метод C# или определить подходящее лямбда-выражение. Независимо от выбранного подхода конечный результат будет одним и тем же.
Хотя использование операций запросов LINQ является, несомненно, самым простым способом построения запросов LINQ, давайте взглянем на все возможные подходы, чтобы увидеть связь между операциями запросов C# и лежащим в основе типом
Enumerable
.
Для начала создадим новый проект консольного приложения по имени
LinqUsingEnumerable
. В классе Program
будут определены статические вспомогательные методы (вызываемые внутри операторов верхнего уровня) для иллюстрации разнообразных подходов к построению выражений запросов LINQ.
Первый метод,
QueryStringsWithOperators()
, предлагает наиболее прямолинейный способ создания выражений запросов и идентичен коду примера LinqOverArray
, который приводился ранее в главе:
using System.Linq;
static void QueryStringWithOperators()
{
Console.WriteLine("***** Using Query Operators *****");
string[] currentVideoGames = {"Morrowind", "Uncharted 2",
"Fallout 3", "Daxter", "System
Shock 2"};
var subset = from game in currentVideoGames
where game.Contains(" ") orderby game select game;
foreach (string s in subset)
{
Console.WriteLine("Item: {0}", s);
}
}
Очевидное преимущество использования операций запросов C# при построении выражений запросов заключается в том, что делегаты
Funс<>
и вызовы методов Enumerable
остаются вне поля зрения и внимания, т.к. выполнение необходимой трансляции возлагается на компилятор С#. Бесспорно, создание выражений LINQ с применением различных операций запросов (from
, in
, where
или orderby
) является наиболее распространенным и простым подходом.
Имейте в виду, что применяемые здесь операции запросов LINQ представляют собой сокращенные версии вызова расширяющих методов, определенных в типе
Enumerable
. Рассмотрим показанный ниже метод QueryStringsWithEnumerableAndLambdas()
, который обрабатывает локальный массив строк, но на этот раз в нем напрямую используются расширяющие методы Enumerable
:
static void QueryStringsWithEnumerableAndLambdas()
{
Console.WriteLine("***** Using Enumerable / Lambda Expressions *****");
string[] currentVideoGames = {"Morrowind", "Uncharted 2",
"Fallout 3", "Daxter", "System
Shock 2"};
// Построить выражение запроса с использованием расширяющих методов,
// предоставленных типу Array через тип Enumerable.
var subset = currentVideoGames
.Where(game => game.Contains(" "))
.OrderBy(game => game).Select(game => game);
// Вывести результаты.
foreach (var game in subset)
{
Console.WriteLine("Item: {0}", game);
}
Console.WriteLine();
}
Здесь сначала вызывается расширяющий метод
Where()
на строковом массиве currentVideoGames
. Вспомните, что класс Array
получает данный метод от класса Enumerable
. Метод Enumerable.Where()
требует параметра типа делегата System.Func
. Первый параметр типа упомянутого делегата представляет совместимые с интерфейсом IEnumerable
данные для обработки (массив строк в рассматриваемом случае), а второй — результирующие данные метода, которые получаются от единственного оператора, вставленного в лямбда-выражение.
Возвращаемое значение метода
Where()
в приведенном примере кода скрыто от глаз, но "за кулисами" работа происходит с типом OrderedEnumerable
. На объекте указанного типа вызывается обобщенный метод OrderBy()
, который также принимает параметр типа делегата Func<>
. Теперь производится передача всех элементов по очереди посредством подходящего лямбда-выражения. Результатом вызова OrderBy()
является новая упорядоченная последовательность первоначальных данных.
И, наконец, осуществляется вызов метода
Select()
на последовательности, возвращенной OrderBy()
, который в итоге дает финальный набор данных, сохраняемый в неявно типизированной переменной по имени subset
.
Конечно, такой "длинный" запрос LINQ несколько сложнее для восприятия, чем предыдущий пример с операциями запросов LINQ. Без сомнения, часть сложности связана с объединением в цепочку вызовов посредством операции точки. Вот тот же самый запрос с выделением каждого шага в отдельный фрагмент (разбивать запрос на части можно разными способами):
static void QueryStringsWithEnumerableAndLambdas2()
{
Console.WriteLine("***** Using Enumerable / Lambda Expressions *****");
string[] currentVideoGames = {"Morrowind", "Uncharted 2",
"Fallout 3", "Daxter", "System
Shock 2"};
// Разбить на части.
var gamesWithSpaces =
currentVideoGames.Where(game => game.Contains(" "));
var orderedGames = gamesWithSpaces.OrderBy(game => game);
var subset = orderedGames.Select(game => game);
foreach (var game in subset)
{
Console.WriteLine("Item: {0}", game);
}
Console.WriteLine();
}
Как видите, построение выражения запроса LINQ с применением методов класса
Enumerable
напрямую приводит к намного более многословному запросу, чем в случае использования операций запросов С#. Кроме того, поскольку методы Enumerable
требуют передачи делегатов в качестве параметров, обычно необходимо писать лямбда-выражения, чтобы обеспечить обработку входных данных внутренней целью делегата.
Учитывая, что лямбда-выражения C# — это просто сокращенный способ работы с анонимными методами, рассмотрим третье выражение запроса внутри вспомогательного метода
QueryStringsWithAnonymousMethods()
:
static void QueryStringsWithAnonymousMethods()
{
Console.WriteLine("***** Using Anonymous Methods *****");
string[] currentVideoGames = {"Morrowind", "Uncharted 2",
"Fallout 3", "Daxter", "System
Shock 2"};
// Построить необходимые делегаты Func<>
// с использованием анонимных методов.
Func searchFilter =
delegate(string game) { return game.Contains(" "); };
Func itemToProcess = delegate(string s) { return s; };
// Передать делегаты в методы класса Enumerable.
var subset =
currentVideoGames.Where(searchFilter).OrderBy(itemToProcess).
Select(itemToProcess);
// Вывести результаты.
foreach (var game in subset)
{
Console.WriteLine("Item: {0}", game);
}
Console.WriteLine();
}
Такой вариант выражения запроса оказывается еще более многословным из-за создания вручную делегатов
Func<>
, применяемых методами Where()
, OrderBy()
и Select()
класса Enumerable
. Положительная сторона данного подхода связана с тем, что синтаксис анонимных методов позволяет заключить всю обработку, выполняемую делегатами, в единственное определение метода. Тем не менее, этот метод функционально эквивалентен методам QueryStringsWithEnumerableAndLambdas()
и QueryStringsWithOperators()
, созданным в предшествующих разделах.
Наконец, если вы хотите строить выражение запроса с применением многословного подхода, то можете отказаться от использования синтаксиса лямбда-выражений и анонимных методов и напрямую создавать цели делегатов для каждого типа
Func<>
. Ниже показана финальная версия выражения запроса, смоделированная внутри нового типа класса по имени VeryComplexQueryExpression
:
class VeryComplexQueryExpression
{
public static void QueryStringsWithRawDelegates()
{
Console.WriteLine("***** Using Raw Delegates *****");
string[] currentVideoGames = {"Morrowind", "Uncharted 2",
"Fallout 3", "Daxter", "System
Shock 2"};
// Построить необходимые делегаты Func<>.
Func searchFilter =
new Func(Filter);
Func itemToProcess =
new Func(ProcessItem);
// Передать делегаты в методы класса Enumerable.
var subset =
currentVideoGames
.Where(searchFilter)
.OrderBy(itemToProcess)
.Select(itemToProcess);
// Вывести результаты.
foreach (var game in subset)
{
Console.WriteLine("Item: {0}", game);
}
Console.WriteLine();
}
// Цели делегатов.
public static bool Filter(string game)
{
return game.Contains(" ");
}
public static string ProcessItem(string game)
{
return game;
}
}
Чтобы протестировать такую версию логики обработки строк, метод
QueryStringsWithRawDelegates()
понадобится вызвать внутри операторов верхнего уровня в классе Program
:
VeryComplexQueryExpression.QueryStringsWithRawDelegates();
Если теперь запустить приложение, чтобы опробовать все возможные подходы, вывод окажется идентичным независимо от выбранного пути. Запомните перечисленные ниже моменты относительно выражений запросов и их внутреннего представления.
• Выражения запросов создаются с применением разнообразных операций запросов С# .
• Операции запросов — это просто сокращенное обозначение для вызова расширяющих методов, определенных в типе
System.Linq.Enumerable
.
• Многие методы класса
Enumerable
требуют передачи делегатов (в частности, Func<>
) в качестве параметров.
• Любой метод, ожидающий параметра типа делегата, может принимать вместо него лямбда-выражение.
• Лямбда-выражения являются всего лишь замаскированными анонимными методами (и значительно улучшают читабельность).
• Анонимные методы представляют собой сокращенные обозначения для размещения экземпляра низкоуровневого делегата и ручного построения целевого метода делегата.
Хотя здесь мы погрузились в детали чуть глубже, чем возможно хотелось, приведенное обсуждение должно было способствовать пониманию того, что фактически делают "за кулисами" дружественные к пользователю операции запросов С#.
LINQ — это набор взаимосвязанных технологий, которые были разработаны для предоставления единого и симметричного стиля взаимодействия с данными несходных форм. Как объяснялось в главе, LINQ может взаимодействовать с любым типом, реализующим интерфейс
IEnumerable
, в том числе с простыми массивами, а также с обобщенными и необобщенными коллекциями данных.
Было показано, что работа с технологиями LINQ обеспечивается несколькими средствами языка С#. Например, учитывая тот факт, что выражения запросов LINQ могут возвращать любое количество результирующих наборов, для представления лежащего в основе типа данных принято использовать ключевое слово
var
. Кроме того, для построения функциональных и компактных запросов LINQ могут применяться лямбда-выражения, синтаксис инициализации объектов и анонимные типы.
Более важно то, что операции запросов LINQ в C# на самом деле являются просто сокращенными обозначениями для обращения к статическим членам типа
System.Linq.Enumerable
. Вы узнали, что большинство членов класса Enumerable
оперируют с типами делегатов Func
и для выполнения запроса могут принимать на входе адреса существующих методов, анонимные методы или лямбда-выражения.
В настоящей главе будут представлены детали обслуживания сборки исполняющей средой, а также отношения между процессами, доменами приложений и контекстами загрузки.
Выражаясь кратко, домены приложений (Application Domain или просто AppDomain) представляют собой логические подразделы внутри заданного процесса, обслуживающего набор связанных сборок .NET Core. Как вы увидите, каждый домен приложения в дальнейшем подразделяется на контекстные границы, которые используются для группирования вместе похожих по смыслу объектов .NET Core. Благодаря понятию контекста исполняющая среда способна обеспечивать надлежащую обработку объектов со специальными требованиями.
Хотя вполне справедливо утверждать, что многие повседневные задачи программирования не предусматривают работу с процессами, доменами приложений или контекстами загрузки напрямую, их понимание важно при взаимодействии с многочисленными API-интерфейсами .NET Core, включая многопоточную и параллельную обработку, а также сериализацию объектов.
Концепция "процесса" существовала в операционных системах Windows задолго до выпуска платформы .NET/.NET Core. Пользуясь простыми терминами, процесс — это выполняющаяся программа. Тем не менее, формально процесс является концепцией уровня операционной системы, которая применяется для описания набора ресурсов (таких как внешние библиотеки кода и главный поток) и необходимых распределений памяти, используемой функционирующим приложением. Для каждого загруженного в память приложения .NET Core операционная система создает отдельный изолированный процесс для применения на протяжении всего времени его существования.
При использовании такого подхода к изоляции приложений в результате получается намного более надежная и устойчивая исполняющая среда, поскольку отказ одного процесса не влияет на работу других процессов. Более того, данные в одном процессе не доступны напрямую другим процессам, если только не применяются специфичные инструменты вроде пространства имен
System.IO.Pipes
или класса MemoryMappedFile
.
Каждый процесс Windows получает уникальный идентификатор процесса (process identifier — PID) и может по мере необходимости независимо загружаться и выгружаться операционной системой (а также программно). Как вам возможно известно, в окне диспетчера задач Windows (открываемом по нажатию комбинации клавиш <Ctrl+Shift+Esc>) имеется вкладка Processes (Процессы), на которой можно просматривать разнообразные статические данные о процессах, функционирующих на машине. На вкладке Details (Подробности) можно видеть назначенный идентификатор PID и имя образа (рис. 14.1).
Каждый процесс Windows содержит начальный "поток", который действует как точка входа для приложения. Особенности построения многопоточных приложений на платформе .NET Core рассматриваются в главе 15; однако для понимания материала настоящей главы необходимо ознакомиться с несколькими рабочими определениями. Поток представляет собой путь выполнения внутри процесса. Выражаясь формально, первый поток, созданный точкой входа процесса, называется главным потоком. В любой программе .NET Core (консольном приложении, Windows-службе, приложении WPF и т.д.) точка входа помечается с помощью метода
Main()
или файла, содержащего операторы верхнего уровня. При обращении к этому коду автоматически создается главный поток.
Процессы, которые содержат единственный главный поток выполнения, по своей сути безопасны в отношении потоков, т.к. в каждый момент времени доступ к данным приложения может получать только один поток. Тем не менее, однопоточный процесс (особенно с графическим пользовательским интерфейсом) часто замедленно реагирует на действия пользователя, когда его единственный поток выполняет сложную операцию (наподобие печати длинного текстового файла, сложных математических вычислений или попытки подключения к удаленному серверу, находящемуся на расстоянии тысяч километров).
Учитывая такой потенциальный недостаток однопоточных приложений, операционные системы, которые поддерживаются .NET Core, и сама платформа .NET Core предоставляют главному потоку возможность порождения дополнительных вторичных потоков (называемых рабочими потоками) с использованием нескольких функций из API-интерфейса Windows, таких как
CreateThread()
. Каждый поток (первичный или вторичный) становится уникальным путем выполнения в процессе и имеет параллельный доступ ко всем совместно используемым элементам данных внутри этого процесса.
Нетрудно догадаться, что разработчики обычно создают дополнительные потоки для улучшения общей степени отзывчивости программы. Многопоточные процессы обеспечивают иллюзию того, что выполнение многочисленных действий происходит более или менее одновременно. Например, приложение может порождать дополнительный рабочий поток для выполнения трудоемкой единицы работы (вроде вывода на печать крупного текстового файла). После запуска вторичного потока главный поток продолжает реагировать на пользовательский ввод, что дает всему процессу возможность достигать более высокой производительности. Однако на самом деле так происходит не всегда: применение слишком большого количества потоков в одном процессе может приводить к ухудшению производительности из-за того, что центральный процессор должен переключаться между активными потоками внутри процесса (а это отнимает время).
На некоторых машинах многопоточность по большей части является иллюзией, обеспечиваемой операционной системой. Машины с единственным (не поддерживающим гиперпотоки) центральным процессором не обладают возможностью обработки множества потоков в одно и то же время. Взамен один центральный процессор выполняет по одному потоку за единицу времени (называемую квантом времени), частично основываясь на приоритете потока. По истечении выделенного кванта времени выполнение существующего потока приостанавливается, позволяя выполнять работу другому потоку. Чтобы поток не "забывал", что происходило до того, как его выполнение было приостановлено, ему предоставляется возможность записывать данные в локальное хранилище потоков (Thread Local Storage — TLS) и выделяется отдельный стек вызовов (рис. 14.2).
Если тема потоков для вас нова, то не стоит беспокоиться о деталях. На данном этапе просто запомните, что любой поток представляет собой уникальный путь выполнения внутри процесса Windows. Каждый процесс имеет главный поток (созданный посредством точки входа исполняемого файла) и может содержать дополнительные потоки, которые создаются программно.
Несмотря на то что с процессами и потоками не связано ничего нового, способ взаимодействия с ними в рамках платформы .NET Core значительно изменился (в лучшую сторону). Чтобы подготовить почву для понимания области построения многопоточных сборок (см. главу 15), давайте начнем с выяснения способов взаимодействия с процессами, используя библиотеки базовых классов .NET Core.
В пространстве имен
System.Diagnostics
определено несколько типов, которые позволяют программно взаимодействовать с процессами и разнообразными типами, связанными с диагностикой, такими как журнал событий системы и счетчики производительности. В текущей главе нас интересуют только типы, связанные с процессами, которые описаны в табл. 14.1.
Класс
System.Diagnostics.Process
позволяет анализировать процессы, выполняющиеся на заданной машине (локальные или удаленные). В классе Process
также определены члены, предназначенные для программного запуска и завершения процессов, просмотра (или модификации) уровня приоритета процесса и получения списка активных потоков и/или загруженных модулей внутри указанного процесса. В табл. 14.2 перечислены некоторые основные свойства класса System.Diagnostics.Process
.
Кроме перечисленных выше свойств в классе
System.Diagnostics.Process
определено несколько полезных методов (табл. 14.3).
Для иллюстрации способа манипулирования объектами
Process
создайте новый проект консольного приложения C# по имени ProcessManipulator
и определите в классе Program
следующий вспомогательный статический метод (не забудьте импортировать в файл кода пространства имен System.Diagnostics
и System.Linq
):
static void ListAllRunningProcesses()
{
// Получить все процессы на локальной машине, упорядоченные по PID.
var runningProcs =
from proc
in Process.GetProcesses(".")
orderby proc.Id
select proc;
// Вывести для каждого процесса идентификатор PID и имя.
foreach(var p in runningProcs)
{
string info = $"-> PID: {p.Id}\tName: {p.ProcessName}";
Console.WriteLine(info);
}
Console.WriteLine("************************************\n");
}
Статический метод
Process.GetProcesses()
возвращает массив объектов Process
, которые представляют выполняющиеся процессы на целевой машине (передаваемая методу строка ".
" обозначает локальный компьютер). После получения массива объектов Process
можно обращаться к любым членам, описанным в табл. 14.2 и 14.3. Здесь просто для каждого процесса выводятся идентификатор PID и имя с упорядочением по PID. Модифицируйте операторы верхнего уровня, как показано ниже:
using System;
using System.Diagnostics;
using System.Linq;
Console.WriteLine("***** Fun with Processes *****\n");
ListAllRunningProcesses();
Console.ReadLine();
Запустив приложение, вы увидите список имен и идентификаторов PID для всех процессов на локальной машине. Ниже показана часть вывода (ваш вывод наверняка будет отличаться):
***** Fun with Processes *****
-> PID: 0 Name: Idle
-> PID: 4 Name: System
-> PID: 104 Name: Secure System
-> PID: 176 Name: Registry
-> PID: 908 Name: svchost
-> PID: 920 Name: smss
-> PID: 1016 Name: csrss
-> PID: 1020 Name: NVDisplay.Container
-> PID: 1104 Name: wininit
-> PID: 1112 Name: csrss
************************************
В дополнение к полному списку всех выполняющихся процессов на заданной машине статический метод
Process.GetProcessById()
позволяет получать одиночный объект Process
по ассоциированному с ним идентификатору PID. В случае запроса несуществующего PID генерируется исключение ArgumentException
. Например, чтобы получить объект Process
, который представляет процесс с PID, равным 30592, можно написать следующий код:
// Если процесс с PID, равным 30592, не существует,
// то сгенерируется исключение во время выполнения.
static void GetSpecificProcess()
{
Process theProc = null;
try
{
theProc = Process.GetProcessById(30592);
}
catch(ArgumentException ex)
{
Console.WriteLine(ex.Message);
}
}
К настоящему моменту вы уже знаете, как получить список всех процессов, а также специфический процесс на машине посредством поиска по PID. Наряду с выяснением идентификаторов PID и имен процессов класс
Process
позволяет просматривать набор текущих потоков и библиотек, применяемых внутри заданного процесса. Давайте посмотрим, как это делается.
Набор потоков представлен в виде строго типизованной коллекции
ProcessThreadCollection
, которая содержит определенное количество отдельных объектов ProcessThread
. В целях иллюстрации добавьте к текущему приложению приведенный далее вспомогательный статический метод:
static void EnumThreadsForPid(int pID)
{
Process theProc = null;
try
{
theProc = Process.GetProcessById(pID);
}
catch(ArgumentException ex)
{
Console.WriteLine(ex.Message);
return;
}
// Вывести статистические сведения по каждому потоку
// в указанном процессе.
Console.WriteLine(
"Here are the threads used by: {0}", theProc.ProcessName);
ProcessThreadCollection theThreads = theProc.Threads;
foreach(ProcessThread pt in theThreads)
{
string info =
$"-> Thread ID: {pt.Id}\tStart Time:
{pt.StartTime.ToShortTimeString()}\tPriority:
{pt.PriorityLevel}";
Console.WriteLine(info);
}
Console.WriteLine("************************************\n");
}
Как видите, свойство
Threads
в типе System.Diagnostics.Process
обеспечивает доступ к классу ProcessThreadCollection
. Здесь для каждого потока внутри указанного клиентом процесса выводится назначенный идентификатор потока, время запуска и уровень приоритета. Обновите операторы верхнего уровня в своей программе, чтобы запрашивать у пользователя идентификатор PID процесса, подлежащего исследованию:
...
// Запросить у пользователя PID и вывести набор активных потоков.
Console.WriteLine("***** Enter PID of process to investigate *****");
Console.Write("PID: ");
string pID = Console.ReadLine();
int theProcID = int.Parse(pID);
EnumThreadsForPid(theProcID);
Console.ReadLine();
После запуска приложения можно вводить PID любого процесса на машине и просматривать имеющиеся внутри него потоки. В следующем выводе показан неполный список потоков, используемых процессом с PID 3804, который (так случилось) обслуживает браузер Edge:
***** Enter PID of process to investigate *****
PID: 3804
Here are the threads used by: msedge
-> Thread ID: 3464 Start Time: 01:20 PM Priority: Normal
-> Thread ID: 19420 Start Time: 01:20 PM Priority: Normal
-> Thread ID: 17780 Start Time: 01:20 PM Priority: Normal
-> Thread ID: 22380 Start Time: 01:20 PM Priority: Normal
-> Thread ID: 27580 Start Time: 01:20 PM Priority: -4
…
************************************
Помимо
Id
, StartTime
и PriorityLevel
тип ProcessThread
содержит дополнительные члены, наиболее интересные из которых перечислены в табл. 14.4.
Прежде чем двигаться дальше, необходимо уяснить, что тип
ProcessThread
не является сущностью, применяемой для создания, приостановки или уничтожения потоков на платформе .NET Core. Тип ProcessThread
скорее представляет собой средство, позволяющее получать диагностическую информацию по активным потокам Windows внутри выполняющегося процесса. Более подробные сведения о том, как создавать многопоточные приложения с использованием пространства имен System.Threading
, приводятся в главе 15.
Теперь давайте посмотрим, как реализовать проход по загруженным модулям, которые размещены внутри конкретного процесса. Когда речь идет о процессах, модуль — это общий термин, применяемый для описания заданной сборки
*.dll
(или самого файла *.ехе
), которая обслуживается специфичным процессом. Когда производится доступ к коллекции ProcessModuleCollection
через свойство Process.Modules
, появляется возможность перечисления всех модулей, размещенных внутри процесса: библиотек на основе .NET Core, СОМ и традиционного языка С. Взгляните на показанный ниже дополнительный вспомогательный метод, который будет перечислять модули в процессе с указанным идентификатором PID:
static void EnumModsForPid(int pID)
{
Process theProc = null;
try
{
theProc = Process.GetProcessById(pID);
}
catch(ArgumentException ex)
{
Console.WriteLine(ex.Message);
return;
}
Console.WriteLine("Here are the loaded modules for: {0}",
theProc.ProcessName);
ProcessModuleCollection theMods = theProc.Modules;
foreach(ProcessModule pm in theMods)
{
string info = $"-> Mod Name: {pm.ModuleName}";
Console.WriteLine(info);
}
Console.WriteLine("************************************\n");
}
Чтобы получить какой-то вывод, давайте просмотрим загружаемые модули для процесса, обслуживающего программу текущего примера (
ProcessManipulator
). Для этого нужно запустить приложение, выяснить идентификатор PID, назначенный ProcessManipulator.exe
(посредством диспетчера задач), и передать значение PID методу EnumModsForPid()
. Вас может удивить, что с простым консольным приложением связан настолько внушительный список библиотек *.dll
(GDI32.dll
, USER32.dll
, ole32.dll
и т.д.). Ниже показан частичный список загруженных модулей (ради краткости отредактированный):
Here are (some of) the loaded modules for: ProcessManipulator
Here are the loaded modules for: ProcessManipulator
-> Mod Name: ProcessManipulator.exe
-> Mod Name: ntdll.dll
-> Mod Name: KERNEL32.DLL
-> Mod Name: KERNELBASE.dll
-> Mod Name: USER32.dll
-> Mod Name: win32u.dll
-> Mod Name: GDI32.dll
-> Mod Name: gdi32full.dll
-> Mod Name: msvcp_win.dll
-> Mod Name: ucrtbase.dll
-> Mod Name: SHELL32.dll
-> Mod Name: ADVAPI32.dll
-> Mod Name: msvcrt.dll
-> Mod Name: sechost.dll
-> Mod Name: RPCRT4.dll
-> Mod Name: IMM32.DLL
-> Mod Name: hostfxr.dll
-> Mod Name: hostpolicy.dll
-> Mod Name: coreclr.dll
-> Mod Name: ole32.dll
-> Mod Name: combase.dll
-> Mod Name: OLEAUT32.dll
-> Mod Name: bcryptPrimitives.dll
-> Mod Name: System.Private.CoreLib.dll
...
************************************
Финальными аспектами класса
System.Diagnostics.Process
, которые мы здесь исследуем, являются методы Start()
и Kill()
. Они позволяют программно запускать и завершать процесс. В качестве примера создадим вспомогательный статический метод StartAndKillProcess()
с приведенным ниже кодом.
На заметку! В зависимости от настроек операционной системы, касающихся безопасности для запуска новых процессов могут требоваться права администратора.
static void StartAndKillProcess()
{
Process proc = null;
// Запустить Edge и перейти на Facebook!
try
{
proc = Process.Start(@"C:\Program Files (x86)\Microsoft\Edge\
Application\msedge.exe",
"www.facebook.com");
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
// Уничтожить процесс по нажатию .
Console.Write("--> Hit enter to kill {0}...",
proc.ProcessName);
Console.ReadLine();
// Уничтожить все процессы msedge.exe.
try
{
foreach (var p in Process.GetProcessesByName("MsEdge"))
{
p.Kill(true);
}
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
}
Статический метод
Process.Start()
имеет несколько перегруженных версий. Как минимум, понадобится указать путь и имя файла запускаемого процесса. В рассматриваемом примере используется версия метода Start()
, которая позволяет задавать любые дополнительные аргументы, подлежащие передаче в точку входа программы, в данном случае веб-страницу для загрузки.
В результате вызова метода
Start()
возвращается ссылка на новый активизированный процесс. Чтобы завершить данный процесс, потребуется просто вызвать метод Kill()
уровня экземпляра. Поскольку Microsoft Edge запускает множество процессов, для их уничтожения организован цикл. Вызовы Start()
и Kill()
помещены внутрь блока try/catch
с целью обработки исключений InvalidOperationException
. Это особенно важно при вызове метода Kill()
, потому что такое исключение генерируется, если процесс был завершен до вызова Kill()
.
На заметку! В .NET Framework (до выхода .NET Core) для запуска процесса методу
Process.Start()
можно было передавать либо полный путь и имя файла процесса, либо его ярлык операционной системы (например, msedge
). С появлением .NET Core и межплатформенной поддержки должны указываться полный путь и имя файла процесса. Файловые ассоциации операционной системы можно задействовать с применением класса ProcessStartInfo
, раскрываемого в последующих двух разделах.
Метод
Process.Start()
позволяет также передавать объект типа System.Diagnostics.ProcessStartInfo
для указания дополнительной информации, касающейся запуска определенного процесса. Ниже приведено частичное определение ProcessStartInfo
(полное определение можно найти в документации):
public sealed class ProcessStartInfo : object
{
public ProcessStartInfo();
public ProcessStartInfo(string fileName);
public ProcessStartInfo(string fileName, string arguments);
public string Arguments { get; set; }
public bool CreateNoWindow { get; set; }
public StringDictionary EnvironmentVariables { get; }
public bool ErrorDialog { get; set; }
public IntPtr ErrorDialogParentHandle { get; set; }
public string FileName { get; set; }
public bool LoadUserProfile { get; set; }
public SecureString Password { get; set; }
public bool RedirectStandardError { get; set; }
public bool RedirectStandardInput { get; set; }
public bool RedirectStandardOutput { get; set; }
public Encoding StandardErrorEncoding { get; set; }
public Encoding StandardOutputEncoding { get; set; }
public bool UseShellExecute { get; set; }
public string Verb { get; set; }
public string[] Verbs { get; }
public ProcessWindowStyle WindowStyle { get; set; }
public string WorkingDirectory { get; set; }
}
Чтобы опробовать настройку запуска процесса, модифицируйте метод
StartAndKillProcess()
для загрузки Microsoft Edge и перехода на сайт www.facebook.com
с применением ассоциации MsEdge
:
static void StartAndKillProcess()
{
Process proc = null;
// Запустить Microsoft Edge и перейти на сайт Facebook
// с развернутым на весь экран окном.
try
{
ProcessStartInfo startInfo = new
ProcessStartInfo("MsEdge", "www.facebook.com");
startInfo.UseShellExecute = true;
proc = Process.Start(startInfo);
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
...
}
В .NET Core свойство
UseShellExecute
по умолчанию имеет значение false
, тогда как в предшествующих версиях .NET его стандартным значением было true
. Именно по этой причине показанная ниже предыдущая версия Process.Start()
больше не работает без использования ProcessStartInfo
и установки свойства UseShellExecute
в true
:
Process.Start("msedge")
Помимо применения ярлыков операционной системы для запуска приложений с классом
ProcessStartInfo
можно также использовать файловые ассоциации. Если в среде Windows щелкнуть правой кнопкой мыши на имени документа Word, то с помощью контекстного меню можно будет отредактировать или распечатать этот документ. Давайте посредством класса ProcessStartInfo
выясним доступные команды и затем применим их для манипулирования процессом. Создайте новый метод со следующим кодом:
static void UseApplicationVerbs()
{
int i = 0;
// Укажите здесь фактический путь и имя документа на своей машине
ProcessStartInfo si =
new ProcessStartInfo(@"..\TestPage.docx");
foreach (var verb in si.Verbs)
{
Console.WriteLine($" {i++}. {verb}");
}
si.WindowStyle = ProcessWindowStyle.Maximized;
si.Verb = "Edit";
si.UseShellExecute = true;
Process.Start(si);
}
Первая часть кода выводит все команды, доступные для документа Word:
***** Fun with Processes *****
0. Edit
1. OnenotePrintto
2. Open
3. OpenAsReadOnly
4. Print
5. Printto
6. ViewProtected
После установки
WindowStyle
в Maximized
(т.е. развернутое на весь экран окно) команда (Verb
)устанавливается в Edit
, что приводит к открытию документа в режиме редактирования. В случае установки команды в Print
документ будет отправлен прямо на принтер.
Теперь, когда вы понимаете роль процессов Windows и знаете способы взаимодействия с ними из кода С#, можно переходить к исследованию концепции доменов приложений .NET.
На заметку! Каталог, в котором выполняется приложение, зависит от того, как вы его запускаете. Если вы применяете команду
dotnet run
, то текущим каталогом будет тот, где располагается файл проекта. Если же вы используете Visual Studio, тогда текущим будет каталог, в котором находится скомпилированная сборка, т.е. .\bin\debug\net5.0
. Вам необходимо должным образом скорректировать путь к документу Word.
На платформах .NET и .NET Core исполняемые файлы не размещаются прямо внутри процесса Windows, как в случае традиционных неуправляемых приложений. Взамен исполняемый файл .NET и .NET Core попадает в отдельный логический раздел внутри процесса, который называется доменом приложения. Такое дополнительное разделение традиционного процесса Windows обеспечивает несколько преимуществ.
• Домены приложений являются ключевым аспектом нейтральной к операционным системам природы платформы .NET Core, поскольку такое логическое разделение абстрагирует отличия в том, как лежащая в основе операционная система представляет загруженный исполняемый файл.
• Домены приложений оказываются гораздо менее затратными в смысле вычислительных ресурсов и памяти по сравнению с полноценными процессами. Таким образом, среда CoreCLR способна загружать и выгружать домены приложений намного быстрее, чем формальный процесс, и может значительно улучшить масштабируемость серверных приложений.
Отдельный домен приложения полностью изолирован от других доменов приложений внутри процесса. Учитывая такой факт, имейте в виду, что приложение, выполняющееся в одном домене приложения, не может получать данные любого рода (глобальные переменные или статические поля) из другого домена приложения, если только не применяется какой-нибудь протокол распределенного программирования.
На заметку! Поддержка доменов приложений в .NET Core изменилась. В среде .NET Core существует в точности один домен приложения. Создавать новые домены приложений больше нельзя, поскольку это требует поддержки со стороны исполняющей среды и в общем случае сопряжено с высокими накладными расходами. Изоляцию сборок в .NET Core обеспечивает класс
ApplicationLoadContext
(рассматриваемый далее в главе).
С выходом версии .NET Core класс
AppDomain
считается почти полностью устаревшим. Хотя большая часть оставшейся поддержки предназначена для упрощения перехода из .NET 4.x в .NET Core, она по-прежнему может приносить пользу, как объясняется в последующих двух разделах.
С помощью статического свойства
AppDomain.CurrentDomain
можно получать доступ к стандартному домену приложения. При наличии такой точки доступа появляется возможность использования методов и свойств AppDomain
для проведения диагностики во время выполнения.
Чтобы научиться взаимодействовать со стандартным доменом приложения, начните с создания нового проекта консольного приложения по имени
DefaultAppDomainApp
. Модифицируйте файл Program.cs
, поместив в него следующий код, который просто выводит подробные сведения о стандартном домене приложения с применением нескольких членов класса AppDomain
:
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
Console.WriteLine("***** Fun with the default AppDomain *****\n");
DisplayDADStats();
Console.ReadLine();
static void DisplayDADStats()
{
// Получить доступ к домену приложения для текущего потока.
AppDomain defaultAD = AppDomain.CurrentDomain;
// Вывести разнообразные статистические данные об этом домене.
Console.WriteLine("Name of this domain: {0}",defaultAD.FriendlyName);
// Дружественное имя этого домена
Console.WriteLine("ID of domain in this process: {0}",defaultAD.Id);
// Идентификатор этого процесса
Console.WriteLine("Is this the default domain?: {0}",
defaultAD.IsDefaultAppDomain());
// Является ли этот домен стандартным
Console.WriteLine("Base directory of this domain: {0}",
defaultAD.BaseDirectory);
// Базовый каталог этого домена
Console.WriteLine("Setup Information for this domain:");
// Информация о настройке этого домена
Console.WriteLine("\tApplication Base: {0}",
defaultAD.SetupInformation.ApplicationBase);
// Базовый каталог приложения
Console.WriteLine("\t Target Framework: {0}",
defaultAD.SetupInformation.TargetFrameworkName);
// Целевая платформа
}
Ниже приведен вывод:
***** Fun with the default AppDomain *****
Name of this domain: DefaultAppDomainApp
ID of domain in this process: 1
Is this the default domain?: True
Base directory of this domain: C:\GitHub\Books\csharp8-wf\Code\Chapter_14\
DefaultAppDomainApp\DefaultAppDomainApp\bin\Debug\net5.0\
Setup Information for this domain:
Application Base: C:\GitHub\Books\csharp8-wf\Code\Chapter_14\
DefaultAppDomainApp\
DefaultAppDomainApp\bin\Debug\net5.0\
Target Framework: .NETCoreApp,Version=v5.0
Обратите внимание, что имя стандартного домена приложения будет идентичным имени содержащегося внутри него исполняемого файла (
DefaultAppDomainApp.exe
в этом примере). Кроме того, значение базового каталога, которое будет использоваться для зондирования обязательных внешних закрытых сборок, отображается на текущее местоположение развернутого исполняемого файла.
С применением метода
GetAssemblies()
уровня экземпляра можно просмотреть все сборки .NET Core, загруженные в указанный домен приложения. Метод возвращает массив объектов типа Assembly
(рассматриваемого в главе 17). Для этого вы должны импортировать пространство имен System.Reflection
в свой файл кода (как делали ранее).
В целях иллюстрации определите в классе Program новый вспомогательный метод по имени
ListAllAssembliesInAppDomain()
. Он будет получать список всех загруженных сборок и выводить для каждой из них дружественное имя и номер версии:
static void ListAllAssembliesInAppDomain()
{
// Получить доступ к домену приложения для текущего потока.
AppDomain defaultAD = AppDomain.CurrentDomain;
// Извлечь все сборки, загруженные в стандартный домен приложения.
Assembly[] loadedAssemblies = defaultAD.GetAssemblies();
Console.WriteLine("***** Here are the assemblies loaded in {0} *****\n",
defaultAD.FriendlyName);
foreach(Assembly a in loadedAssemblies)
{
// Вывести имя и версию
Console.WriteLine($"-> Name,
Version: {a.GetName().Name}:{a.GetName().Version}" );
}
}
Добавив к операторам верхнего уровня вызов метода
ListAllAssembliesInAppDomain()
, вы увидите, что в домене приложения, обслуживающем вашу исполняемую сборку, используются следующие библиотеки .NET Core:
***** Here are the assemblies loaded in DefaultAppDomainApp *****
-> Name, Version: System.Private.CoreLib:5.0.0.0
-> Name, Version: DefaultAppDomainApp:1.0.0.0
-> Name, Version: System.Runtime:5.0.0.0
-> Name, Version: System.Console:5.0.0.0
-> Name, Version: System.Threading:5.0.0.0
-> Name, Version: System.Text.Encoding.Extensions:5.0
Важно понимать, что список загруженных сборок может изменяться в любой момент по мере написания нового кода С#. Например, предположим, что метод
ListAllAssembliesInAppDomain()
модифицирован так, чтобы задействовать запрос LINQ, который упорядочивает загруженные сборки по имени:
using System.Linq;
static void ListAllAssembliesInAppDomain()
{
// Получить доступ к домену приложения для текущего потока.
AppDomain defaultAD = AppDomain.CurrentDomain;
// Извлечь все сборки, загруженные в стандартный домен приложения.
var loadedAssemblies =
defaultAD.GetAssemblies().OrderBy(x=>x.GetName().Name);
Console.WriteLine("***** Here are the assemblies loaded in {0} *****\n",
defaultAD.
FriendlyName);
foreach(Assembly a in loadedAssemblies)
{
// Вывести имя и версию
Console.WriteLine($"-> Name,
Version: {a.GetName().Name}:{a.GetName().Version}" );
}
}
Запустив приложение еще раз, вы заметите, что в память также была загружена сборка
System.Linq.dll
:
** Here are the assemblies loaded in DefaultAppDomainApp **
-> Name, Version: DefaultAppDomainApp:1.0.0.0
-> Name, Version: System.Console:5.0.0.0
-> Name, Version: System.Linq:5.0.0.0
-> Name, Version: System.Private.CoreLib:5.0.0.0
-> Name, Version: System.Runtime:5.0.0.0
-> Name, Version: System.Text.Encoding.Extensions:5.0.0.0
-> Name, Version: System.Threading:5.0.0
Как вам уже известно, домены приложений представляют собой логические разделы, используемые для обслуживания сборок .NET Core. Кроме того, домен приложения может быть дополнительно разделен на многочисленные границы контекстов загрузки. Концептуально контекст загрузки создает область видимости для загрузки, распознавания и потенциально выгрузки набора сборок. По существу контекст загрузки .NET Core наделяет одиночный домен приложения возможностью установить "конкретный дом" для заданного объекта.
На заметку! Хотя понимать процессы и домены приложений довольно-таки важно, в большинстве приложений .NET Core никогда не потребуется работать с контекстами загрузки. Этот обзорный материал был включен в книгу только ради того, чтобы представить более полную картину.
Класс
AssemblyLoadContext
позволяет загружать дополнительные сборки в их собственные контексты. В целях демонстрации создайте новый проект библиотеки классов по имени ClassLibaryl
и добавьте его к текущему решению. С использованием интерфейса командной строки .NET Core CLI выполните показанные ниже команды в каталоге, содержащем текущее решение:
dotnet new classlib -lang c# -n ClassLibrary1 -o .\ClassLibrary1 -f net5.0
dotnet sln .\Chapter14_AllProjects.sln add .\ClassLibrary1
Затем добавьте в
DefaultAppDomainApp
ссылку на проект ClassLibrary1
, выполнив следующую команду CLI:
dotnet add DefaultAppDomainApp reference ClassLibrary1
Если вы работаете в Visual Studio, тогда щелкните правой кнопкой мыши на узле решения в окне Solution Explorer, выберите в контекстном меню пункт Add►New Project (Добавить►Новый проект. В результате создается проект
ClassLibrary1
и добавляется к решению. Далее добавьте ссылку на новый проект, щелкнув правой кнопкой мыши на имени проекта DefaultAppDomainApp
и выбрав в контекстном меню пункт Add►References. Выбрать Projects►Solution (Проекты►Решение), как показано на рис. 14.3.
Добавьте в новую библиотеку классов класс
Car
с таким кодом:
namespace ClassLibrary1
{
public class Car
{
public string PetName { get; set; }
public string Make { get; set; }
public int Speed { get; set; }
}
}
Теперь, имея новую сборку, добавьте необходимые операторы
using
:
using System.IO;
using System.Runtime.Loader;
Метод, добавляемый следующим, требует наличия операторов
using
для пространств имен System.IO
и System.Runtime.Loader
, которые вы уже добавили в Program.cs
. Вот код этого метода:
static void LoadAdditionalAssembliesDifferentContexts()
{
var path =
Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
"ClassLibrary1.dll");
AssemblyLoadContext lc1 =
new AssemblyLoadContext("NewContext1",false);
var cl1 = lc1.LoadFromAssemblyPath(path);
var c1 = cl1.CreateInstance("ClassLibrary1.Car");
AssemblyLoadContext lc2 =
new AssemblyLoadContext("NewContext2",false);
var cl2 = lc2.LoadFromAssemblyPath(path);
var c2 = cl2.CreateInstance("ClassLibrary1.Car");
Console.WriteLine("*** Loading Additional Assemblies in Different Contexts ***");
Console.WriteLine($"Assembly1 Equals(Assembly2) {cl1.Equals(cl2)}");
Console.WriteLine($"Assembly1 == Assembly2 {cl1 == cl2}");
Console.WriteLine($"Class1.Equals(Class2) {c1.Equals(c2)}");
Console.WriteLine($"Class1 == Class2 {c1 == c2}");
}
В первой строке кода с применением статического метода
Path.Combine()
строится каталог для сборки ClassLibrary1
.
На заметку! Вас может интересовать, по какой причине создавалась ссылка на сборку, которая будет загружаться динамически. Это нужно для того, чтобы при компиляции проекта сборка
ClassLibrary1
тоже компилировалась и помещалась в тот же каталог, что и DefaultAppDomainApp
. В данном примере поступать так попросту удобно. Ссылаться на сборку, которая будет загружаться динамически, нет никакой необходимости.
Далее в коде создается объект
AssemblyLoadContext
, имеющий имя NewContext1
(первый параметр конструктора) и не поддерживающий выгрузку (второй параметр), который будет использоваться для загрузки сборки ClassLibrary1
и последующего создания экземпляра класса Car
. Если какие-то фрагменты кода выглядят для вас незнакомыми, то они будут подробно объясняться в главе 19. Процесс повторяется для еще одного объекта AssemblyLoadContext
, после чего сборки и классы сравниваются на предмет эквивалентности. В результате выполнения метода LoadAdditionalAssembliesDifferentContexts()
вы получите следующий вывод:
*** Loading Additional Assemblies in Different Contexts ***
Assembly1 Equals(Assembly2) False
Assembly1 == Assembly2 False
Class1.Equals(Class2) False
Class1 == Class2 False
Вывод демонстрирует, что та же самая сборка была дважды загружена в домен приложения. Как и следовало ожидать, классы тоже отличаются.
Добавьте новый метод, который будет загружать сборку из того же самого объекта
AssemblyLoadContext
:
static void LoadAdditionalAssembliesSameContext()
{
var path =
Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
"ClassLibrary1.dll");
AssemblyLoadContext lc1 =
new AssemblyLoadContext(null,false);
var cl1 = lc1.LoadFromAssemblyPath(path);
var c1 = cl1.CreateInstance("ClassLibrary1.Car");
var cl2 = lc1.LoadFromAssemblyPath(path);
var c2 = cl2.CreateInstance("ClassLibrary1.Car");
Console.WriteLine("*** Loading Additional Assemblies in Same Context ***");
Console.WriteLine($"Assembly1.Equals(Assembly2) {cl1.Equals(cl2)}");
Console.WriteLine($"Assembly1 == Assembly2 {cl1 == cl2}");
Console.WriteLine($"Class1.Equals(Class2) {c1.Equals(c2)}");
Console.WriteLine($"Class1 == Class2 {c1 == c2}");
}
Главное отличие приведенного выше кода в том, что создается только один объект
AssemblyLoadContext
. В таком случае, если сборка ClassLibrary1
загружается дважды, то второй экземпляр сборки является просто указателем на ее первый экземпляр. Выполнение кода дает следующий вывод:
*** Loading Additional Assemblies in Same Context ***
Assembly1.Equals(Assembly2) True
Assembly1 == Assembly2 True
Class1.Equals(Class2) False
Class1 == Class2 False
К настоящему времени вы должны иметь намного лучшее представление о том, как сборка .NET Core обслуживается исполняющей средой. Если изложенный материал показался слишком низкоуровневым, то не переживайте. По большей части .NET Core самостоятельно занимается всеми деталями процессов, доменов приложений и контекстов загрузки. Однако эта информация формирует хороший фундамент для понимания многопоточного программирования на платформе .NET Core.
Задачей главы было исследование особенностей обслуживания приложения .NET Core платформой .NET Core. Как вы видели, давно существующее понятие процесса Windows было внутренне изменено и адаптировано под потребности среды CoreCLR. Одиночный процесс (которым можно программно манипулировать посредством типа
System.Diagnostics.Process
) теперь состоит из домена приложения, которое представляет изолированные и независимые границы внутри процесса.
Домен приложения способен размещать и выполнять любое количество связанных сборок. Кроме того, один домен приложения может содержать любое количество контекстов загрузки для дальнейшей изоляции сборок. Благодаря такому дополнительному уровню изоляции типов среда CoreCLR обеспечивает надлежащую обработку объектов с особыми потребностями во время выполнения.
Вряд ли кому-то понравится работать с приложением, которое притормаживает во время выполнения. Аналогично никто не будет в восторге от того, что запуск какой-то задачи внутри приложения (возможно, по щелчку на элементе в панели инструментов) снижает отзывчивость других частей приложения. До выхода платформы .NET (и .NET Core) построение приложений, способных выполнять сразу несколько задач, обычно требовало написания сложного кода на языке C++, в котором использовались API-интерфейсы многопоточности Windows. К счастью, платформы .NET и .NET Core предлагают ряд способов построения программного обеспечения, которое может совершать нетривиальные операции по уникальным путям выполнения, с намного меньшими сложностями.
Глава начинается с определения общей природы "многопоточного приложения". Затем будет представлено первоначальное пространство имен для многопоточности, поставляемое со времен версии .NET 1.0 и называемое
System.Threading
. Вы ознакомитесь с многочисленными типами (Thread
, ThreadStart
и т.д.), которые позволяют явно создавать дополнительные потоки выполнения и синхронизировать разделяемые ресурсы, обеспечивая совместное использование данных несколькими потоками в неизменчивой манере.
В оставшихся разделах главы будут рассматриваться три более новых технологии, которые разработчики приложений .NET Core могут применять для построения многопоточного программного обеспечения: библиотека параллельных задач (Task Parallel Library — TPL), технология PLINQ (Parallel LINQ — параллельный LINQ) и появившиеся относительно недавно (в версии C# 6) ключевые слова, связанные с асинхронной обработкой (
async
и await
). Вы увидите, что указанные средства помогают значительно упростить процесс создания отзывчивых многопоточных программных приложений.
В главе 14 поток определялся как путь выполнения внутри исполняемого приложения. Хотя многие приложения .NET Core могут успешно и продуктивно работать, будучи однопоточными, первичный поток сборки (создаваемый исполняющей средой при выполнении точки входа приложения) в любое время может порождать вторичные потоки для выполнения дополнительных единиц работы. За счет создания дополнительных потоков можно строить более отзывчивые (но не обязательно быстрее выполняющиеся на одноядерных машинах) приложения.
Пространство имен
System.Threading
появилось в версии .NET 1.0 и предлагает один из подходов к построению многопоточных приложений. Равным типом в этом пространстве имен можно назвать, пожалуй, класс Thread
, поскольку он представляет отдельный поток. Если необходимо программно получить ссылку на поток, который в текущий момент выполняет заданный член, то нужно просто обратиться к статическому свойству Thread.CurrentThread
:
static void ExtractExecutingThread()
{
// Получить поток, который в настоящий момент выполняет данный метод.
Thread currThread = Thread.CurrentThread;
}
Вспомните, что в .NET Core существует только один домен приложения. Хотя создавать дополнительные домены приложений нельзя, домен приложения может иметь многочисленные потоки, выполняющиеся в каждый конкретный момент времени. Чтобы получить ссылку на домен приложения, который обслуживает приложение, понадобится вызвать статический метод
Thread.GetDomain()
:
static void ExtractAppDomainHostingThread()
{
// Получить домен приложения, обслуживающий текущий поток.
AppDomain ad = Thread.GetDomain();
}
Одиночный поток в любой момент также может быть перенесен в контекст выполнения и перемещаться внутри нового контекста выполнения по прихоти среды .NET Core Runtime. Для получения текущего контекста выполнения, в котором выполняется поток, используется статическое свойство
Thread.CurrentThread.ExecutionContext
:
static void ExtractCurrentThreadExecutionContext()
{
// Получить контекст выполнения, в котором работает текущий поток.
ExecutionContext ctx =
Thread.CurrentThread.ExecutionContext;
}
Еще раз: за перемещение потоков в контекст выполнения и из него отвечает среда .NET Core Runtime. Как разработчик приложений .NET Core, вы всегда остаетесь в блаженном неведении относительно того, где завершается каждый конкретный поток. Тем не менее, вы должны быть осведомлены о разнообразных способах получения лежащих в основе примитивов.
Один из многих болезненных аспектов многопоточного программирования связан с ограниченным контролем над тем, как операционная система или исполняющая среда задействует потоки. Например, написав блок кода, который создает новый поток выполнения, нельзя гарантировать, что этот поток запустится немедленно. Взамен такой код только инструктирует операционную систему или исполняющую среду о необходимости как можно более скорого запуска потока (что обычно происходит, когда планировщик потоков добирается до него).
Кроме того, учитывая, что потоки могут перемещаться между границами приложений и контекстов, как требуется исполняющей среде, вы должны представлять, какие аспекты приложения являются изменчивыми в потоках (например, подвергаются многопоточному доступу), а какие операции считаются атомарными (операции, изменчивые в потоках, опасны).
Чтобы проиллюстрировать проблему, давайте предположим, что поток вызывает метод специфичного объекта. Теперь представим, что поток приостановлен планировщиком потока, чтобы позволить другому потоку обратиться к тому же методу того же самого объекта.
Если исходный поток не завершил свою операцию, тогда второй входящий поток может увидеть объект в частично модифицированном состоянии. В таком случае второй поток по существу читает фиктивные данные, что определенно может привести к очень странным (и трудно обнаруживаемым) ошибкам, которые еще труднее воспроизвести и устранить.
С другой стороны, атомарные операции в многопоточной среде всегда безопасны. К сожалению, в библиотеках базовых классов .NET Core есть лишь несколько гарантированно атомарных операций. Даже действие по присваиванию значения переменной-члену не является атомарным! Если только в документации по .NET Core специально не сказано об атомарности операции, то вы обязаны считать ее изменчивой в потоках и предпринимать соответствующие меры предосторожности.
К настоящему моменту должно быть ясно, что многопоточные программы сами по себе довольно изменчивы, т.к. многочисленные потоки могут оперировать разделяемыми ресурсами (более или менее) одновременно. Чтобы защитить ресурсы приложений от возможного повреждения, разработчики приложений .NET Core должны применять потоковые примитивы (такие как блокировки, мониторы, атрибут
[Synchronization]
или поддержка языковых ключевых слов) для управления доступом между выполняющимися потоками.
Несмотря на то что платформа .NET Core не способна полностью скрыть сложности, связанные с построением надежных многопоточных приложений, сам процесс был значительно упрощен. Используя типы из пространства имен
System.Threading
, библиотеку TPL и ключевые слова async
и await
языка С#, можно работать с множеством потоков, прикладывая минимальные усилия.
Прежде чем погрузиться в детали пространства имен
System.Threading
, библиотеки TPL и ключевых слов async
и await
языка С#, мы начнем с выяснения того, каким образом можно применять тип делегата .NET Core для вызова метода в асинхронной манере. Хотя вполне справедливо утверждать, что с выходом версии .NET 4.6 ключевые слова async
и await
предлагают более простую альтернативу асинхронным делегатам, по-прежнему важно знать способы взаимодействия с кодом, использующим этот подход (в производственной среде имеется масса кода, в котором применяются асинхронные делегаты).
В рамках платформ .NET и .NET Core пространство имен
System.Threading
предоставляет типы, которые дают возможность напрямую конструировать многопоточные приложения. В дополнение к типам, позволяющим взаимодействовать с потоком .NET Core Runtime, в System.Threading
определены типы, которые открывают доступ к пулу потоков, обслуживаемому .NET Core Runtime, простому (не связанному с графическим пользовательским интерфейсом) классу Timer
и многочисленным типам, применяемым для синхронизированного доступа к разделяемым ресурсам.
В табл. 15.1 перечислены некоторые важные члены пространства имен
System.Threading
. (За полными сведениями обращайтесь в документацию по .NET Core.)
Класс
Thread
является самым элементарным из всех типов в пространстве имен System.Threading
. Он представляет объектно-ориентированную оболочку вокруг заданного пути выполнения внутри отдельного домена приложения. В этом классе определено несколько методов (статических и уровня экземпляра), которые позволяют создавать новые потоки внутри текущего домена приложения, а также приостанавливать, останавливать и уничтожать указанный поток. Список основных статических членов приведен в табл. 15.2.
Класс
Thread
также поддерживает члены уровня экземпляра, часть которых описана в табл. 15.3.
На заметку! Прекращение работы или приостановка активного потока обычно считается плохой идеей. В таком случае есть шанс (хотя и небольшой), что поток может допустить "утечку" своей рабочей нагрузки.
Вспомните, что точка входа исполняемой сборки (т.е. операторы верхнего уровня или метод
Main()
) запускается в первичном потоке выполнения. Чтобы проиллюстрировать базовое применение типа Thread
, предположим, что имеется новый проект консольного приложения по имени ThreadStats
. Как вам известно, статическое свойство Thread.CurrentThread
извлекает объект Thread
, который представляет поток, выполняющийся в текущий момент. Получив текущий поток, можно вывести разнообразные статистические сведения о нем:
// Не забудьте импортировать пространство имен System.Threading.
using System;
using System.Threading;
Console.WriteLine("***** Primary Thread stats *****\n");
// Получить имя текущего потока.
Thread primaryThread = Thread.CurrentThread;
primaryThread.Name = "ThePrimaryThread";
// Вывести статистические данные о текущем потоке.
Console.WriteLine("ID of current thread: {0}",
primaryThread.ManagedThreadId); // Идентификатор текущего потока
Console.WriteLine("Thread Name: {0}",
primaryThread.Name); // Имя потока
Console.WriteLine("Has thread started?: {0}",
primaryThread.IsAlive); // Запущен ли поток
Console.WriteLine("Priority Level: {0}",
primaryThread.Priority); // Приоритет потока
Console.WriteLine("Thread State: {0}",
primaryThread.ThreadState); // Состояние потока
Console.ReadLine();
Вот как выглядит вывод:
***** Primary Thread stats *****
ID of current thread: 1
Thread Name: ThePrimaryThread
Has thread started?: True
Priority Level: Normal
Thread State: Running
Обратите внимание, что класс
Thread
поддерживает свойство по имени Name
. Если значение Name
не было установлено, тогда будет возвращаться пустая строка. Однако назначение конкретному объекту Thread
дружественного имени может значительно упростить отладку. Во время сеанса отладки в Visual Studio можно открыть окно Threads (Потоки), выбрав пункт меню Debug►Windows►Threads (Отладка► Окна►Потоки). На рис. 15.1 легко заметить, что окно Threads позволяет быстро идентифицировать поток, который нужно диагностировать.
Далее обратите внимание, что в типе
Thread
определено свойство по имени Priority
. По умолчанию все потоки имеют уровень приоритета Normal
. Тем не менее, в любой момент жизненного цикла потока его можно изменить, используя свойство Priority
и связанное с ним перечисление System.Threading.ThreadPriority
:
public enum ThreadPriority
{
Lowest,
BelowNormal,
Normal, // Стандартное значение.
AboveNormal,
Highest
}
В случае присваивания уровню приоритета потока значения, отличающегося от стандартного(
ThreadPriority.Normal
), помните об отсутствии прямого контроля над тем, когда планировщик потоков будет переключать потоки между собой. Уровень приоритета потока предоставляет среде .NET Core Runtime лишь подсказку относительно важности действия потока. Таким образом, поток с уровнем приоритета ThreadPriority.Highest
не обязательно гарантированно получит наивысший приоритет.
Опять-таки, если планировщик потоков занят решением определенной задачи (например, синхронизацией объекта, переключением потоков либо их перемещением), то уровень приоритета, скорее всего, будет соответствующим образом изменен. Однако при прочих равных условиях среда .NET Core Runtime прочитает эти значения и проинструктирует планировщик потоков о том, как лучше выделять кванты времени. Потоки с идентичными уровнями приоритета должны получать одинаковое количество времени на выполнение своей работы.
В большинстве случаев необходимость в прямом изменении уровня приоритета потока возникает редко (если вообще возникает). Теоретически можно так повысить уровень приоритета набора потоков, что в итоге воспрепятствовать выполнению низкоприоритетных потоков с их запрошенными уровнями (поэтому соблюдайте осторожность).
Когда вы хотите программно создать дополнительные потоки для выполнения какой-то единицы работы, то во время применения типов из пространства имен
System.Threading
следуйте представленному ниже предсказуемому процессу.
1. Создать метод, который будет служить точкой входа для нового потока.
2. Создать новый делегат
ParametrizedThreadStart
(или ThreadStart
), передав его конструктору адрес метода, который был определен на шаге 1.
3. Создать объект
Thread
, передав конструктору в качестве аргумента делегат ParametrizedThreadStart/Threadstart
.
4. Установить начальные характеристики потока (имя, приоритет и т.д.).
5. Вызвать метод
Thread.Start()
, что приведет к как можно более скорому запуску потока для метода, на который ссылается делегат, созданный на шаге 2.
Согласно шагу 2 для указания на метод, который будет выполняться во вторичном потоке, можно использовать два разных типа делегата. Делегат
ThreadStart
способен указывать на любой метод, который не принимает аргументов и ничего не возвращает. Такой делегат может быть полезен, когда метод предназначен просто для запуска в фоновом режиме без дальнейшего взаимодействия с ним.
Ограничение
ThreadStart
связано с невозможностью передавать ему параметры для обработки. Тем не менее, тип делегата ParametrizedThreadStart
позволяет передать единственный параметр типа System.Object
. Учитывая, что с помощью System.Object
представляется все, что угодно, посредством специального класса или структуры можно передавать любое количество параметров. Однако имейте в виду, что делегаты ThreadStart
и ParametrizedThreadStart
могут указывать только на методы, возвращающие void
.
Чтобы проиллюстрировать процесс построения многопоточного приложения (а также его полезность), создайте проект консольного приложения по имени
SimpleMultiThreadApp
, которое позволит конечному пользователю выбирать, будет приложение выполнять свою работу в единственном первичном потоке или же разделит рабочую нагрузку с применением двух отдельных потоков выполнения.
После импортирования пространства имен
System.Threading
определите метод для выполнения работы (возможного) вторичного потока. Чтобы сосредоточиться на механизме построения многопоточных программ, этот метод будет просто выводить на консоль последовательность чисел, делая на каждом шаге паузу примерно в 2 секунды. Ниже показано полное определение класса Printer
:
using System;
using System.Threading;
namespace SimpleMultiThreadApp
{
public class Printer
{
public void PrintNumbers()
{
// Вывести информацию о потоке.
Console.WriteLine("-> {0} is executing PrintNumbers()",
Thread.CurrentThread.Name);
// Вывести числа.
Console.Write("Your numbers: ");
for(int i = 0; i < 10; i++)
{
Console.Write("{0}, ", i);
Thread.Sleep(2000);
}
Console.WriteLine();
}
}
}
Добавьте в файл
Program.cs
операторы верхнего уровня, которые предложат пользователю решить, сколько потоков будет использоваться для выполнения работы приложения: один или два. Если пользователь запрашивает один поток, то нужно просто вызвать метод PrintNumbers()
в первичном потоке. Тем не менее, когда пользователь выбирает два потока, понадобится создать делегат ThreadStart
, указывающий на PrintNumbers()
, передать объект делегата конструктору нового объекта Thread
и вызвать метод Start()
для информирования среды .NET Core Runtime о том, что данный поток готов к обработке. Вот полная реализация:
using System;
using System.Threading;
using SimpleMultiThreadApp;
Console.WriteLine("***** The Amazing Thread App *****\n");
Console.Write("Do you want [1] or [2] threads? ");
string threadCount = Console.ReadLine(); // Запрос количества потоков
// Назначить имя текущему потоку.
Thread primaryThread = Thread.CurrentThread;
primaryThread.Name = "Primary";
// Вывести информацию о потоке.
Console.WriteLine("-> {0} is executing Main()",
Thread.CurrentThread.Name);
// Создать рабочий класс.
Printer p = new Printer();
switch(threadCount)
{
case "2":
// Создать поток.
Thread backgroundThread =
new Thread(new ThreadStart(p.PrintNumbers));
backgroundThread.Name = "Secondary";
backgroundThread.Start();
break;
case "1":
p.PrintNumbers();
break;
default:
Console.WriteLine("I don't know what you want...you get 1 thread.");
goto case "1"; // Переход к варианту с одним потоком
}
// Выполнить некоторую дополнительную работу.
Console.WriteLine("This is on the main thread, and we are finished.");
Console.ReadLine();
Если теперь вы запустите программу с одним потоком, то обнаружите, что финальное окно сообщения не будет отображать сообщение, пока вся последовательность чисел не выведется на консоль. Поскольку после вывода каждого числа установлена пауза около 2 секунд, это создаст не особенно приятное впечатление у конечного пользователя. Однако в случае выбора двух потоков окно сообщения отображается немедленно, потому что для вывода чисел на консоль выделен отдельный объект
Thread
.
Вспомните, что делегат
ThreadStart
может указывать только на методы, которые возвращают void
и не принимают аргументов. В некоторых случаях это подходит, но если нужно передать данные методу, выполняющемуся во вторичном потоке, тогда придется использовать тип делегата ParametrizedThreadStart
. В целях иллюстрации создайте новый проект консольного приложения по имени AddWithThreads
и импортируйте пространство имен System.Threading
. С учетом того, что делегат ParametrizedThreadStart
может указывать на любой метод, принимающий параметр типа System.Object
, постройте специальный тип, который содержит числа, подлежащие сложению:
namespace AddWithThreads
{
class AddParams
{
public int a, b;
public AddParams(int numb1, int numb2)
{
a = numb1;
b = numb2;
}
}
}
Далее создайте в классе
Program
статический метод, который принимает параметр AddParams
и выводит на консоль сумму двух чисел:
void Add(object data)
{
if (data is AddParams ap)
{
Console.WriteLine("ID of thread in Add(): {0}",
Thread.CurrentThread.ManagedThreadId);
Console.WriteLine("{0} + {1} is {2}",
ap.a, ap.b, ap.a + ap.b);
}
}
Код в файле
Program.cs
прямолинеен. Вместо типа ThreadStart
просто используется ParametrizedThreadStart
:
using System;
using System.Threading;
using AddWithThreads;
Console.WriteLine("***** Adding with Thread objects *****");
Console.WriteLine("ID of thread in Main(): {0}",
Thread.CurrentThread.ManagedThreadId);
// Создать объект AddParams для передачи вторичному потоку.
AddParams ap = new AddParams(10, 10);
Thread t = new Thread(new ParameterizedThreadStart(Add));
t.Start(ap);
// Подождать, пока другой поток завершится.
Thread.Sleep(5);
Console.ReadLine();
В приведенных выше начальных примерах нет какого-либо надежного способа узнать, когда вторичный поток завершит свою работу. В последнем примере метод
Sleep()
вызывался с произвольным временным периодом, чтобы дать возможность другому потоку завершиться. Простой и безопасный к потокам способ заставить один поток ожидать, пока не завершится другой поток, предусматривает применение класса AutoResetEvent
. В потоке, который должен ожидать, создайте экземпляр AutoResetEvent
и передайте его конструктору значение false
, указав, что уведомления пока не было. Затем в точке, где требуется ожидать, вызовите метод WaitOne()
. Ниже приведен модифицированный класс Program
, который делает все описанное с использованием статической переменной-члена AutoResetEvent
:
AutoResetEvent _waitHandle = new AutoResetEvent(false);
Console.WriteLine("***** Adding with Thread objects *****");
Console.WriteLine("ID of thread in Main(): {0}",
Thread.CurrentThread.ManagedThreadId);
AddParams ap = new AddParams(10, 10);
Thread t = new Thread(new ParameterizedThreadStart(Add));
t.Start(ap);
// Ожидать, пока не поступит уведомление!
_waitHandle.WaitOne();
Console.WriteLine("Other thread is done!");
Console.ReadLine();
...
Когда другой поток завершит свою работу, он вызовет метод
Set()
на том же самом экземпляре типа AutoResetEvent
:
void Add(object data)
{
if (data is AddParams ap)
{
Console.WriteLine("ID of thread in Add(): {0}",
Thread.CurrentThread.ManagedThreadId);
Console.WriteLine("{0} + {1} is {2}",
ap.a, ap.b, ap.a + ap.b);
// Сообщить другому потоку о том, что работа завершена.
_waitHandle.Set();
}
}
Теперь, когда вы знаете, как программно создавать новые потоки выполнения с применением типов из пространства имен
System.Threading
, давайте формализуем разницу между потоками переднего плана и фоновыми потоками.
• Потоки переднего плана имеют возможность предохранять текущее приложение от завершения. Среда .NET Core Runtime не будет прекращать работу приложения (скажем, выгружая обслуживающий домен приложения) до тех пор, пока не будут завершены все потоки переднего плана.
• Фоновые потоки (иногда называемые потоками-демонами) воспринимаются средой .NET Core Runtime как расширяемые пути выполнения, которые в любой момент времени могут быть проигнорированы (даже если они заняты выполнением некоторой части работы). Таким образом, если при выгрузке домена приложения все потоки переднего плана завершены, то все фоновые потоки автоматически уничтожаются.
Важно отметить, что потоки переднего плана и фоновые потоки — не синонимы первичных и рабочих потоков. По умолчанию каждый поток, создаваемый посредством метода
Thread.Start()
, автоматически становится потоком переднего плана. В итоге домен приложения не выгрузится до тех пор, пока все потоки выполнения не завершат свои единицы работы. В большинстве случаев именно такое поведение и требуется.
Ради доказательства сделанных утверждений предположим, что метод
Printer.PrintNumbers()
необходимо вызвать во вторичном потоке, который должен вести себя как фоновый. Это означает, что метод, указываемый типом Thread
(через делегат ThreadStart
или ParametrizedThreadStart
), должен обладать возможностью безопасного останова, как только все потоки переднего плана закончат свою работу. Конфигурирование такого потока сводится просто к установке свойства IsBackground
в true
:
Console.WriteLine("***** Background Threads *****\n");
Printer p = new Printer();
Thread bgroundThread =
new Thread(new ThreadStart(p.PrintNumbers));
// Теперь это фоновый поток.
bgroundThread.IsBackground = true;
bgroundThread.Start();
Обратите внимание, что в приведенном выше коде не делается вызов
Console.ReadLine()
, чтобы заставить окно консоли оставаться видимым, пока не будет нажата клавиша <Enter>. Таким образом, после запуска приложение немедленно прекращается, потому что объект Thread
сконфигурирован как фоновый поток. С учетом того, что точка входа приложения (приведенные здесь операторы верхнего уровня или метод Main()
) инициирует создание первичного потока переднего плана, как только логика в точке входа завершится, домен приложения будет выгружен, прежде чем вторичный поток сможет закончить свою работу.
Однако если закомментировать строку, которая устанавливает свойство
IsBackground
в true
, то обнаружится, что на консоль выводятся все числа, поскольку все потоки переднего плана должны завершить свою работу перед тем, как домен приложения будет выгружен из обслуживающего процесса.
По большей части конфигурировать поток для функционирования в фоновом режиме может быть удобно, когда интересующий рабочий поток выполняет некритичную задачу, потребность в которой исчезает после завершения главной задачи программы. Например, можно было бы построить приложение, которое проверяет почтовый сервер каждые несколько минут на предмет поступления новых сообщений электронной почты, обновляет текущий прогноз погоды или решает какие-то другие некритичные задачи.
При построении многопоточных приложений необходимо гарантировать, что любой фрагмент разделяемых данных защищен от возможности изменения со стороны сразу нескольких потоков. Поскольку все потоки в домене приложения имеют параллельный доступ к разделяемым данным приложения, вообразите, что может произойти, если множество потоков одновременно обратятся к одному и тому же элементу данных. Так как планировщик потоков случайным образом приостанавливает их работу, что если поток
А
будет вытеснен до завершения своей работы? Тогда поток В
прочитает нестабильные данные.
Чтобы проиллюстрировать проблему, связанную с параллелизмом, давайте создадим еще один проект консольного приложения под названием
MultiThreadedPrinting
. В приложении снова будет использоваться построенный ранее класс Printer
, но на этот раз метод PrintNumbers()
приостановит текущий поток на сгенерированный случайным образом период времени.
using System;
using System.Threading;
namespace MultiThreadedPrinting
{
public class Printer
{
public void PrintNumbers()
{
// Отобразить информацию о потоке.
Console.WriteLine("-> {0} is executing PrintNumbers()",
Thread.CurrentThread.Name);
// Вывести числа.
for (int i = 0; i < 10; i++)
{
// Приостановить поток на случайный период времени.
Random r = new Random();
Thread.Sleep(1000 * r.Next(5));
Console.Write("{0}, ", i);
}
Console.WriteLine();
}
}
}
Вызывающий код отвечает за создание массива из десяти (уникально именованных) объектов
Thread
, каждый из которых вызывает метод одного и того же экземпляра класса Printer
:
using System;
using System.Threading;
using MultiThreadedPrinting;
Console.WriteLine("*****Synchronizing Threads *****\n");
Printer p = new Printer();
// Создать 10 потоков, которые указывают на один
.
// и тот же метод того же самого объекта
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++)
{
threads[i] = new Thread(new ThreadStart(p.PrintNumbers))
{
Name = $"Worker thread #{i}"
};
}
// Теперь запустить их все.
foreach (Thread t in threads)
{
t.Start();
}
Console.ReadLine();
Прежде чем взглянуть на тестовые запуски, кратко повторим суть проблемы. Первичный поток внутри этого домена приложения начинает свое существование с порождения десяти вторичных рабочих потоков. Каждому рабочему потоку указывается на необходимость вызова метода
PrintNumbers()
того же самого экземпляра класса Printer
. Поскольку никаких мер для блокировки разделяемых ресурсов данного объекта (консоли) не предпринималось, есть неплохой шанс, что текущий поток будет вытеснен до того, как метод PrintNumbers()
выведет полные результаты. Из-за того, что не известно в точности, когда подобное может произойти (если вообще произойдет), будут получены непредсказуемые результаты. Например, вывод может выглядеть так:
*****Synchronizing Threads *****
-> Worker thread #3 is executing PrintNumbers()
-> Worker thread #0 is executing PrintNumbers()
-> Worker thread #1 is executing PrintNumbers()
-> Worker thread #2 is executing PrintNumbers()
-> Worker thread #4 is executing PrintNumbers()
-> Worker thread #5 is executing PrintNumbers()
-> Worker thread #6 is executing PrintNumbers()
-> Worker thread #7 is executing PrintNumbers()
-> Worker thread #8 is executing PrintNumbers()
-> Worker thread #9 is executing PrintNumbers()
0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 1, 2, 3, 1, 2, 2, 2, 1, 2,
1, 1, 2, 2, 3, 3, 4,
3, 3, 2, 2, 3, 4, 3, 4, 5, 4, 5, 4, 4, 3, 6, 7, 2, 3, 4, 4, 4, 5, 6, 5,
3, 5, 8, 9,
6, 7, 4, 5, 6, 6, 5, 5, 5, 8, 5, 6, 7, 8, 7, 7, 6, 6, 6, 8, 9,
8, 7, 7, 7, 7, 9,
6, 8, 9,
8, 9,
9, 9,
8, 8, 7, 8, 9,
9,
9,
Запустите приложение еще несколько раз. Скорее всего, каждый раз вы будете получать отличающийся вывод.
На заметку! Если получить непредсказуемый вывод не удается, увеличьте количество потоков с 10 до 100 (например) или добавьте в код еще один вызов
Thread.Sleep()
. В конце концов, вы столкнетесь с проблемой параллелизма.
Должно быть совершенно ясно, что здесь присутствуют проблемы. В то время как каждый поток сообщает экземпляру
Printer
о необходимости вывода числовых данных, планировщик потоков благополучно переключает потоки в фоновом режиме. В итоге получается несогласованный вывод. Нужен способ программной реализации синхронизированного доступа к разделяемым ресурсам. Как и можно было предположить, пространство имен System.Threading
предлагает несколько типов, связанных с синхронизацией. В языке C# также предусмотрено ключевое слово для синхронизации разделяемых данных в многопоточных приложениях.
Первый прием, который можно применять для синхронизации доступа к разделяемым ресурсам, предполагает использование ключевого слова
lock
языка С#. Оно позволяет определять блок операторов, которые должны быть синхронизованными между потоками. В результате входящие потоки не могут прерывать текущий поток, мешая ему завершить свою работу. Ключевое слово lock
требует указания маркера (объектной ссылки), который должен быть получен потоком для входа в область действия блокировки. Чтобы попытаться заблокировать закрытый метод уровня экземпляра, необходимо просто передать ссылку на текущий тип:
private void SomePrivateMethod()
{
// Использовать текущий объект как маркер потока.
lock(this)
{
// Весь код внутри этого блока является безопасным к потокам.
}
}
Тем не менее, если блокируется область кода внутри открытого члена, то безопаснее (да и рекомендуется) объявить закрытую переменную-член типа
object
для применения в качестве маркера блокировки:
public class Printer
{
// Маркер блокировки.
private object threadLock = new object();
public void PrintNumbers()
{
// Использовать маркер блокировки.
lock (threadLock)
{
...
}
}
}
В любом случае, если взглянуть на метод
PrintNumbers()
, то можно заметить, что разделяемым ресурсом, за доступ к которому соперничают потоки, является окно консоли. Поместите весь код взаимодействия с типом Console
внутрь области lock
, как показано ниже:
public void PrintNumbers()
{
// Использовать в качестве маркера блокировки закрытый член object.
lock (threadLock)
{
// Вывести информацию о потоке.
Console.WriteLine("-> {0} is executing PrintNumbers()",
Thread.CurrentThread.Name);
// Вывести числа.
Console.Write("Your numbers: ");
for (int i = 0; i < 10; i++)
{
Random r = new Random();
Thread.Sleep(1000 * r.Next(5));
Console.Write("{0}, ", i);
}
Console.WriteLine();
}
}
В итоге вы построили метод, который позволит текущему потоку завершить свою задачу. Как только поток входит в область
lock
, маркер блокировки (в данном случае ссылка на текущий объект) становится недоступным другим потокам до тех пор, пока блокировка не будет освобождена после выхода из области lock
. Таким образом, если поток А
получил маркер блокировки, то другие потоки не смогут войти ни в одну из областей, которые используют тот же самый маркер, до тех пор, пока поток А
не освободит его.
На заметку! Если необходимо блокировать код в статическом методе, тогда следует просто объявить закрытую статическую переменную-член типа
object
, которая и будет служить маркером блокировки.
Запустив приложение, вы заметите, что каждый поток получил возможность выполнить свою работу до конца:
*****Synchronizing Threads *****
-> Worker thread #0 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #1 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #3 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #2 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #4 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #5 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #7 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #6 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #8 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #9 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
Оператор
lock
языка C# на самом деле представляет собой сокращение для работы с классом System.Threading.Monitor
. При обработке компилятором C# область lock
преобразуется в следующую конструкцию (в чем легко убедиться с помощью утилиты ldasm.exe
):
public void PrintNumbers()
{
Monitor.Enter(threadLock);
try
{
// Вывести информацию о потоке.
Console.WriteLine("-> {0} is executing PrintNumbers()",
Thread.CurrentThread.Name);
// Вывести числа.
Console.Write("Your numbers: ");
for (int i = 0; i < 10; i++)
{
Random r = new Random();
Thread.Sleep(1000 * r.Next(5));
Console.Write("{0}, ", i);
}
Console.WriteLine();
}
finally
{
Monitor.Exit(threadLock);
}
}
Первым делом обратите внимание, что конечным получателем маркера потока, который указывается как аргумент ключевого слова
lock
, является метод Monitor. Enter()
. Весь код внутри области lock
помещен внутрь блока try
. Соответствующий блок finally
гарантирует освобождение маркера блокировки (посредством метода Monitor.Exit()
), даже если возникнут любые исключения времени выполнения. Модифицировав программу MultiThreadShareData
с целью прямого применения типа Monitor
(как только что было показано), вы обнаружите, что вывод идентичен.
С учетом того, что ключевое слово
lock
требует написания меньшего объема кода, чем при явной работе с типом System.Threading.Monitor
, может возникнуть вопрос о преимуществах использования этого типа напрямую. Выражаясь кратко, тип Monitor
обеспечивает большую степень контроля. Применяя тип Monitor
, можно заставить активный поток ожидать в течение некоторого периода времени (с помощью статического метода Monitor.Wait()
), информировать ожидающие потоки о том, что текущий поток завершен (через статические методы Monitor.Pulse()
и Monitor.PulseAll()
), и т.д.
Как и можно было ожидать, в значительном числе случаев ключевого слова
lock
будет достаточно. Если вас интересуют дополнительные члены класса Monitor
, тогда обращайтесь в документацию по .NET Core.
Не заглядывая в код CIL, обычно нелегко поверить в то, что присваивание и простые арифметические операции не являются атомарными. По указанной причине в пространстве имен
System.Threading
предоставляется тип, который позволяет атомарно оперировать одиночным элементом данных с меньшими накладными расходами, чем тип Monitor
. В классе Interlocked
определены статические члены, часть которых описана в табл. 15.4.
Несмотря на то что это не сразу видно, процесс атомарного изменения одиночного значения довольно часто применяется в многопоточной среде. Пусть имеется код, который инкрементирует целочисленную переменную-член по имени
intVal
. Вместо написания кода синхронизации вроде показанного ниже:
int intVal = 5;
object myLockToken = new();
lock(myLockToken)
{
intVal++;
}
код можно упростить, используя статический метод
Interlocked.Increment()
.
Методу потребуется передать инкрементируемую переменную по ссылке. Обратите внимание, что метод
Increment()
не только изменяет значение входного параметра, но также возвращает полученное новое значение:
intVal = Interlocked.Increment(ref intVal);
В дополнение к методам
Increment()
и Decrement()
тип Interlocked
позволяет атомарно присваивать числовые и объектные данные. Например, чтобы присвоить переменной-члену значение 83
, можно обойтись без явного оператора lock
(или явной логики Monitor
) и применить метод Interlock.Exchange()
:
Interlocked.Exchange(ref myInt, 83);
Наконец, если необходимо проверить два значения на предмет равенства и изменить элемент сравнения в безопасной к потокам манере, тогда допускается использовать метод
Interlocked.CompareExchange()
:
public void CompareAndExchange()
{
// Если значение i равно 83, то изменить его на 99.
Interlocked.CompareExchange(ref i, 99, 83);
}
Многие приложения нуждаются в вызове специфического метода через регулярные интервалы времени. Например, в приложении может существовать необходимость в отображении текущего времени внутри панели состояния с помощью определенной вспомогательной функции. Или, скажем, нужно, чтобы приложение эпизодически вызывало вспомогательную функцию, выполняющую некритичные фоновые задачи, такие как проверка поступления новых сообщений электронной почты. В ситуациях подобного рода можно применять тип
System.Threading.Timer
в сочетании со связанным делегатом по имени TimerCallback
.
В целях иллюстрации предположим, что у вас есть проект консольного приложения (
TimerApp
), которое будет выводить текущее время каждую секунду до тех пор, пока пользователь не нажмет клавишу <Enter> для прекращения работы приложения. Первый очевидный шаг — написание метода, который будет вызываться типом Timer
(не забудьте импортировать в свой файл кода пространство имен System.Threading
):
using System;
using System.Threading;
Console.WriteLine("***** Working with Timer type *****\n");
Console.ReadLine();
static void PrintTime(object state)
{
Console.WriteLine("Time is: {0}",
DateTime.Now.ToLongTimeString());
}
Обратите внимание, что метод
PrintTime()
принимает единственный параметр типа System.Object
и возвращает void
. Это обязательно, потому что делегат TimerCallback
может вызывать только методы, которые соответствуют такой сигнатуре. Значение, передаваемое целевому методу делегата TimerCallback
, может быть объектом любого типа (в случае примера с электронной почтой параметр может представлять имя сервера Microsoft Exchange Server для взаимодействия в течение процесса). Также обратите внимание, что поскольку параметр на самом деле является экземпляром типа System.Object
, в нем можно передавать несколько аргументов, используя System.Array
или специальный класс либо структуру.
Следующий шаг связан с конфигурированием экземпляра делегата
TimerCallback
и передачей его объекту Timer
. В дополнение к настройке делегата TimerCallback
конструктор Timer
позволяет указывать необязательный информационный параметр для передачи целевому методу делегата (определенный как System.Object
), интервал вызова метода и период ожидания (в миллисекундах), который должен истечь перед первым вызовом. Вот пример:
Console.WriteLine("***** Working with Timer type *****\n");
// Создать делегат для типа Timer.
TimerCallback timeCB = new TimerCallback(PrintTime);
// Установить параметры таймера.
Timer t = new Timer(
timeCB, // Объект делегата TimerCallback.
null, // Информация для передачи в вызванный метод.
// (null, если информация отсутствует).
0, // Период ожидания перед запуском (в миллисекундах).
1000); // Интервал между вызовами (в миллисекундах).
Console.WriteLine("Hit Enter key to terminate...");
Console.ReadLine();
В этом случае метод
PrintTime()
вызывается приблизительно каждую секунду и не получает никакой дополнительной информации. Ниже показан вывод примера:
***** Working with Timer type *****
Hit key to terminate...
Time is: 6:51:48 PM
Time is: 6:51:49 PM
Time is: 6:51:50 PM
Time is: 6:51:51 PM
Time is: 6:51:52 PM
Press any key to continue ...
Чтобы передать целевому методу делегата какую-то информацию, необходимо просто заменить значение
null
во втором параметре конструктора подходящей информацией, например:
// Установить параметры таймера.
Timer t = new Timer(timeCB, "Hello From C# 9.0", 0, 1000);
You can then obtain the incoming data as follows:static void PrintTime(object state)
{
Console.WriteLine("Time is: {0}, Param is: {1}",
DateTime.Now.ToLongTimeString(), state.ToString());
}
В предыдущем примере переменная
Timer
не применяется в каком-либо пути выполнения и потому может быть заменена отбрасыванием:
var _ = new Timer(
timeCB, // Объект делегата TimerCallback.
null, // Информация для передачи в вызванный метод
// (null, если информация отсутствует).
0, // Период ожидания перед запуском
// (в миллисекундах).
1000); // Интервал между вызовами
// (в миллисекундах).
Следующей темой о потоках, которую мы рассмотрим в настоящей главе, будет роль пула потоков. Запуск нового потока связан с затратами, поэтому в целях повышения эффективности пул потоков удерживает созданные (но неактивные) потоки до тех пор, пока они не понадобятся. Для взаимодействия с этим пулом ожидающих потоков в пространстве имен
System.Threading
предлагается класс ThreadPool
.
Чтобы запросить поток из пула для обработки вызова метода, можно использовать метод
ThreadPool.QueueUserWorkItem()
. Он имеет перегруженную версию, которая позволяет в дополнение к экземпляру делегата WaitCallback
указывать необязательный параметр System.Object
для передачи специальных данных состояния:
public static class ThreadPool
{
...
public static bool QueueUserWorkItem(WaitCallback callBack);
public static bool QueueUserWorkItem(WaitCallback callBack,
object state);
}
Делегат
WaitCallback
может указывать на любой метод, который принимает в качестве единственного параметра экземпляр System.Object
(представляющий необязательные данные состояния) и ничего не возвращает. Обратите внимание, что если при вызове QueueUserWorkItem()
не задается экземпляр System.Object
, то среда .NET Core Runtime автоматически передает значение null
. Чтобы продемонстрировать работу методов очередей, работающих с пулом потоков .NET Core Runtime, рассмотрим еще раз программу (в проекте консольного приложения по имени ThreadPoolApp
), в которой применяется тип Printer
. На этот раз массив объектов Thread
не создается вручную, а метод PrintNumbers()
будет назначаться членам пула потоков:
using System;
using System.Threading;
using ThreadPoolApp;
Console.WriteLine("***** Fun with the .NET Core Runtime Thread Pool *****\n");
Console.WriteLine("Main thread started. ThreadID = {0}",
Thread.CurrentThread.ManagedThreadId);
Printer p = new Printer();
WaitCallback workItem = new WaitCallback(PrintTheNumbers);
// Поставить в очередь метод десять раз.
for (int i = 0; i < 10; i++)
{
ThreadPool.QueueUserWorkItem(workItem, p);
}
Console.WriteLine("All tasks queued");
Console.ReadLine();
static void PrintTheNumbers(object state)
{
Printer task = (Printer)state;
task.PrintNumbers();
}
У вас может возникнуть вопрос: почему взаимодействовать с пулом потоков, поддерживаемым средой .NET Core Runtime, выгоднее по сравнению с явным созданием объектов
Thread
? Использование пула потоков обеспечивает следующие преимущества.
• Пул потоков эффективно управляет потоками, сводя к минимуму количество потоков, которые должны создаваться, запускаться и останавливаться.
• За счет применения пула потоков можно сосредоточиться на решении задачи, а не на потоковой инфраструктуре приложения.
Тем не менее, в некоторых случаях ручное управление потоками оказывается более предпочтительным. Ниже приведены примеры.
• Когда требуются потоки переднего плана или должен устанавливаться приоритет потока. Потоки из пула всегда являются фоновыми и обладают стандартным приоритетом (
ThreadPriority.Normal
).
• Когда требуется поток с фиксированной идентичностью, чтобы его можно было прерывать, приостанавливать или находить по имени.
На этом исследование пространства имен
System.Threading
завершено. Несомненно, понимание вопросов, рассмотренных в настоящей главе до сих пор (особенно в разделе, посвященном проблемам параллелизма), будет чрезвычайно ценным при создании многопоточного приложения. А теперь, опираясь на имеющийся фундамент, мы переключим внимание на несколько новых аспектов, связанных с потоками, которые появились в .NET 4.0 и остались в .NET Core. Для начала мы обратимся к альтернативной потоковой модели под названием TPL.
Вы уже ознакомились с объектами из пространства имен
System.Threading
, которые позволяют строить многопоточное программное обеспечение. Начиная с версии .NET 4.0, в Microsoft ввели новый подход к разработке многопоточных приложений, предусматривающий применение библиотеки параллельного программирования, которая называется TPL. С помощью типов из System.Threading.Tasks
можно строить мелкомодульный масштабируемый параллельный код без необходимости напрямую иметь дело с потоками или пулом потоков.
Однако речь не идет о том, что вы не будете использовать типы из пространства имен
System.Threading
во время применения TPL. Оба инструментальных набора для создания многопоточных приложений могут вполне естественно работать вместе. Сказанное особенно верно в связи с тем, что пространство имен System.Threading
по-прежнему предоставляет большинство примитивов синхронизации, которые рассматривались ранее (Monitor
, Interlocked
и т.д.). В итоге вы на самом деле обнаружите, что иметь дело с библиотекой TPL предпочтительнее, чем с первоначальным пространством имен System.Threading
, т.к. те же самые задачи могут решаться гораздо проще.
Все вместе типы из пространства
System.Threading.Tasks
называются библиотекой параллельных задач (Task Parallel Library — TPL). Библиотека TPL будет автоматически распределять нагрузку приложения между доступными процессорами в динамическом режиме с применением пула потоков исполняющей среды. Библиотека TPL поддерживает разбиение работы на части, планирование потоков, управление состоянием и другие низкоуровневые детали. В конечном итоге появляется возможность максимизировать производительность приложений .NET Core, не сталкиваясь со сложностями прямой работы с потоками.
Основным классом в TPL является
System.Threading.Tasks.Parallel
. Он содержит методы, которые позволяют осуществлять итерацию по коллекции данных (точнее по объекту, реализующему интерфейс IEnumerable
) в параллельной манере. Это делается главным образом посредством двух статических методов Parallel.For()
и Parallel.ForEach()
, каждый из которых имеет множество перегруженных версий.
Упомянутые методы позволяют создавать тело из операторов кода, которое будет выполняться в параллельном режиме. Концептуально такие операторы представляют логику того же рода, которая была бы написана в нормальной циклической конструкции (с использованием ключевых слов
for
и foreach
языка С#). Преимущество заключается в том, что класс Parallel
будет самостоятельно извлекать потоки из пула потоков (и управлять параллелизмом).
Оба метода требуют передачи совместимого с
IEnumerable
или IEnumerable
контейнера, который хранит данные, подлежащие обработке в параллельном режиме. Контейнер может быть простым массивом, необобщенной коллекцией (вроде ArrayList
), обобщенной коллекцией (наподобие List
) или результатами запроса LINQ.
Вдобавок понадобится применять делегаты
System.Func
и System.Action
для указания целевого метода, который будет вызываться при обработке данных. Делегат Func
уже встречался в главе 13 во время исследования технологии LINQ to Objects. Вспомните, что Func
представляет метод, который возвращает значение и принимает различное количество аргументов. Делегат Action
похож на Func
в том, что позволяет задавать метод, принимающий несколько параметров, но данный метод должен возвращать void
.
Хотя можно было бы вызывать методы
Parallel.For()
и Parallel.ForEach()
и передавать им строго типизированный объект делегата Func
или Action
, задача программирования упрощается за счет использования подходящих анонимных методов или лямбда-выражений С#.
Первое применение библиотеки TPL связано с обеспечением параллелизма данных. Таким термином обозначается задача прохода по массиву или коллекции в параллельной манере с помощью метода
Parallel.For()
или Parallel.ForEach()
. Предположим, что необходимо выполнить некоторые трудоемкие операции файлового ввода-вывода. В частности, требуется загрузить в память большое число файлов *.jpg
, повернуть содержащиеся в них изображения и сохранить модифицированные данные изображений в новом месте.
Задача будет решаться с использованием графического пользовательского интерфейса, так что вы увидите, как применять "анонимные делегаты", позволяющие вторичным потокам обновлять первичный поток пользовательского интерфейса.
На заметку! При построении многопоточного приложения с графическим пользовательским интерфейсом вторичные потоки никогда не смогут напрямую обращаться к элементам управления пользовательского интерфейса. Причина в том, что элементы управления (кнопки, текстовые поля, метки, индикаторы хода работ и т.п.) привязаны к потоку, в котором они создавались. В следующем примере иллюстрируется один из способов обеспечения для вторичных потоков возможности получать доступ к элементам пользовательского интерфейса в безопасной к потокам манере. Во время рассмотрения ключевых слов
async
и await
языка C# будет предложен более простой подход.
В целях иллюстрации создайте приложение Windows Presentation Foundation (WPF) по имени
DataParallelismWithForEach
, выбрав шаблон WPF Арр (.NET Core). Чтобы создать проект и добавить его к решению с помощью командной строки, используйте следующие команды:
dotnet new wpf -lang c# -n DataParallelismWithForEach
-o .\DataParallelismWithForEach -f
net5.0
dotnet sln .\Chapter15_AllProjects.sln add .\DataParallelismWithForEach
На заметку! Инфраструктура Windows Presentation Foundation (WPF) в текущей версии .NET Core предназначена только для Windows и будет подробно рассматриваться в главах 24-28. Если вы еще не работали с WPF, то здесь описано все, что необходимо для данного примера. Разработка приложений WPF ведется в среде Visual Studio Code, хотя никаких визуальных конструкторов там не предусмотрено. Чтобы получить больший опыт разработки приложений WPF, рекомендуется использовать Visual Studio 2019.
Дважды щелкните на имени файла
MainWindow.xaml
в окне Solution Explorer и поместите в него показанное далее содержимое XAML:
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:DataParallelismWithForEach"
mc:Ignorable="d"
Title="Fun with TPL" Height="400" Width="800">
Feel free to type here while the images are processed...
Margin="10,10,0,10"
Click="cmdCancel_Click">
Cancel
Margin="0,10,10,10"
Click="cmdProcess_Click">
Click to Flip Your Images!
И снова пока не следует задаваться вопросом о том, что означает приведенная разметка или как она работает; вскоре вам придется посвятить немало времени на исследование WPF. Графический пользовательский интерфейс приложения состоит из многострочной текстовой области
TextBox
и одной кнопки Button
(по имени cmdProcess
). Текстовая область предназначена для ввода данных во время выполнения работы в фоновом режиме, иллюстрируя тем самым неблокирующую природу параллельной задачи.
В этом примере требуется дополнительный пакет NuGet (
System.Drawing.Common
). Чтобы добавить его в проект, введите следующую команду (целиком в одной строке) в окне командной строки (в каталоге, где находится файл решения) или в консоли диспетчера пакетов в Visual Studio:
dotnet add DataParallelismWithForEach package System.Drawing.Common
Дважды щелкнув на имени файла
MainWindow.xaml.cs
(может потребоваться развернуть узел MainWindow.xaml
), добавьте в его начало представленные ниже операторы using
:
// Обеспечить доступ к перечисленным ниже пространствам имен!
// (System.Threading.Tasks уже должно присутствовать благодаря
// выбранному шаблону.)
using System;
using System.Drawing;
using System.Threading.Tasks;
using System.Threading;
using System.Windows;
using System.IO;
На заметку! Вы должны обновить строку, передаваемую методу
Directory.GetFiles()
, чтобы в ней был указан конкретный путь к каталогу на вашей машине, который содержит файлы изображений. Для вашего удобства в каталог TestPictures
включено несколько примеров изображений (поставляемых в составе операционной системы Windows).
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void cmdCancel_Click(object sender, EventArgs e)
{
// Код метода будет вскоре обновлен.
}
private void cmdProcess_Click(object sender, EventArgs e)
{
ProcessFiles();
this.Title = "Processing Complete";
}
private void ProcessFiles()
{
// Загрузить все файлы *.jpg и создать новый каталог
// для модифицированных данных.
// Получить путь к каталогу с исполняемым файлом.
// В режиме отладки VS 2019 текущим каталогом будет
// <каталог npoeктa>\bin\debug\net5.0 - windows.
// В случае VS Code или команды dotnet run текущим
// каталогом будет <каталог проекта>.
var basePath = Directory.GetCurrentDirectory();
var pictureDirectory =
Path.Combine(basePath, "TestPictures");
var outputDirectory =
Path.Combine(basePath, "ModifiedPictures");
// Удались любые существующие файлы.
if (Directory.Exists(outputDirectory))
{
Directory.Delete(outputDirectory, true);
}
Directory.CreateDirectory(outputDirectory);
string[] files = Directory.GetFiles(pictureDirectory,
"*.jpg", SearchOption.AllDirectories);
// Обработать данные изображений в блокирующей манере.
foreach (string currentFile in files)
{
string filename =
System.IO.Path.GetFileName(currentFile);
// Вывести идентификатор потока, обрабатывающего текущее изображение.
this.Title = $"Processing {filename}
on thread {Thread.CurrentThread.
ManagedThreadId}";
using (Bitmap bitmap = new Bitmap(currentFile))
{
bitmap.RotateFlip(RotateFlipType.Rotate180FlipNone);
bitmap.Save(System.IO.Path.Combine(
outputDirectory, filename));
}
}
}
}
На заметку! В случае получения сообщения об ошибке, связанной с неоднозначностью имени
Path
между System.IO.Path
и System.Windows.Shapes.Path
, либо удалите оператор using
для System.Windows.Shapes
, либо добавьте System.IO
к Path
: System.IO.Path.Combine(...).
Обратите внимание, что метод
ProcessFiles()
выполнит поворот изображения в каждом файле *.jpg
из указанного каталога. В настоящее время вся работа происходит в первичном потоке исполняемой программы. Следовательно, после щелчка на кнопке Click to Flip Your Images! (Щелкните для поворота ваших изображений) программа выглядит зависшей. Вдобавок заголовок окна также сообщит о том, что файл обрабатывается тем же самым первичным потоком, т.к. в наличии есть только один поток выполнения.
Чтобы обрабатывать файлы на как можно большем количестве процессоров, текущий цикл
foreach
можно заменить вызовом метода Parallel.ForEach()
. Вспомните, что этот метод имеет множество перегруженных версий. Простейшая форма метода принимает совместимый с IEnumerable
объект, который содержит элементы, подлежащие обработке (например, строковый массив files
), и делегат Action
, указывающий на метод, который будет выполнять необходимую работу.
Ниже приведен модифицированный код, где вместо литерального объекта делегата
Action
применяется лямбда-операция С#. Как видите, в коде закомментированы строки, которые отображают идентификатор потока, обрабатывающего текущий файл изображения. Причина объясняется в следующем разделе.
// Обработать данные изображений в параллельном режиме!
Parallel.ForEach(files, currentFile =>
{
string filename = Path.GetFileName(currentFile);
// Этот оператор теперь приводит к проблеме! См. следующий раздел.
// this.Title = $" Processing {filename} on thread
// {Thread.CurrentThread.ManagedThreadld}"
// Thread.CurrentThread.ManagedThreadld);
using (Bitmap bitmap = new Bitmap( currentFile))
using (Bitmap bitmap = new Bitmap(currentFile))
{
bitmap.RotateFlip(RotateFlipType.Rotate180FlipNone);
bitmap.Save(Path.Combine(outputDirectory, filename));
}
}
);
Вы наверняка заметили, что в показанном выше коде закомментированы строки, которые обновляют заголовок главного окна значением идентификатора текущего выполняющегося потока. Как упоминалось ранее, элементы управления графического пользовательского интерфейса привязаны к потоку, где они были созданы. Если вторичные потоки пытаются получить доступ к элементу управления, который они напрямую не создавали, то при отладке программного обеспечения возникают ошибки времени выполнения. С другой стороны, если запустить приложение (нажатием <Ctrl+F5>), тогда первоначальный код может и не вызвать каких-либо проблем.
На заметку! Не лишним будет повторить: при отладке многопоточного приложения вы иногда будете получать ошибки, когда вторичный поток обращается к элементу управления, созданному в первичном потоке. Однако часто после запуска приложение может выглядеть функционирующим корректно (или же довольно скоро может возникнуть ошибка). Если не предпринять меры предосторожности (описанные далее), то приложение в подобных обстоятельствах может потенциально сгенерировать ошибку во время выполнения.
Один из подходов, который можно использовать для предоставления вторичным потокам доступа к элементам управления в безопасной к потокам манере, предусматривает применение другого приема — анонимного делегата. Родительский класс
Control
в WPF определяет объект Dispatcher
, который управляет рабочими элементами для потока. Указанный объект имеет метод по имени Invoke()
, принимающий на входе System.Delegate
. Этот метод можно вызывать внутри кода, выполняющегося во вторичных потоках, чтобы обеспечить возможность безопасного в отношении потоков обновления пользовательского интерфейса для заданного элемента управления. В то время как весь требуемый код делегата можно было бы написать напрямую, большинство разработчиков используют в качестве простой альтернативы синтаксис выражений. Вот как выглядит модифицированный код:
// Этот код больше не работает!
// this.Title = $"Processing {filename} on thread {Thread.
// CurrentThread.ManagedThreadld}";
// Вызвать Invoke() на объекте Dispatcher, чтобы позволить вторичным потокам
// получать доступ к элементам управления в безопасной к потокам манере.
Dispatcher?.Invoke(() =>
{
this.Title = $"Processing {filename}";
});
using (Bitmap bitmap = new Bitmap(currentFile))
{
bitmap.RotateFlip(RotateFlipType.Rotate180FlipNone);
bitmap.Save(Path.Combine(outputDirectory, filename));
}
Теперь после запуска программы библиотека TPL распределит рабочую нагрузку по множеству потоков из пула, используя столько процессоров, сколько возможно. Тем не менее, поскольку заголовок
Title
всегда обновляется из главного потока, код обновления Title
больше не отображает текущий поток, и при вводе в текстовой области вы ничего не увидите до тех пор, пока не обработаются все файлы изображений! Причина в том, что первичный поток пользовательского интерфейса по-прежнему блокируется, ожидая завершения работы всех остальных потоков.
Класс
Task
позволяет легко вызывать метод во вторичном потоке и может применяться как простая альтернатива асинхронным делегатам. Измените обработчик события Click
элемента управления Button
следующим образом:
private void cmdProcess_Click(object sender, EventArgs e)
{
// Запустить новую "задачу" для обработки файлов.
Task.Factory.StartNew(() => ProcessFiles());
// Можно записать и так:
// Task.Factory.StartNew(ProcessFiles);
}
Свойство
Factory
класса Task
возвращает объект TaskFactory
. Методу StartNew()
при вызове передается делегат Action
(что здесь скрыто с помощью подходящего лямбда-выражения), указывающий на метод, который подлежит вызову в асинхронной манере. После такой небольшой модификации вы обнаружите, что заголовок окна отображает информацию о потоке из пула, обрабатывающем конкретный файл, а текстовое поле может принимать ввод, поскольку пользовательский интерфейс больше не блокируется.
В текущий пример можно внести еще одно улучшение — предоставить пользователю способ для останова обработки данных изображений путем щелчка на второй кнопке Cancel (Отмена). К счастью, методы
Parallel.For()
и Parallel.ForEach()
поддерживают отмену за счет использования маркеров отмены. При вызове методов на объекте Parallel
им можно передавать объект ParallelOptions
, который в свою очередь содержит объект CancellationTokenSource
.
Первым делом определите в производном от Window классе закрытую переменную-член
_cancelToken
типа CancellationTokenSource
:
public partial class MainWindow :Window
{
// Новая переменная уровня Window.
private CancellationTokenSource _cancelToken =
new CancellationTokenSource();
...
}
Обновите обработчик события
Click
:
private void cmdCancel_
Click(object sender, EventArgs e)
{
// Используется для сообщения всем рабочим потокам о необходимости останова!
_cancelToken.Cancel();
}
Теперь можно заняться необходимыми модификациями метода
ProcessFiles()
. Вот его финальная реализация:
private void ProcessFiles()
{
// Использовать экземпляр ParallelOptions для хранения CancellationToken.
ParallelOptions parOpts = new ParallelOptions();
parOpts.CancellationToken = _cancelToken.Token;
parOpts.MaxDegreeOfParallelism = System.Environment.ProcessorCount;
// Загрузить все файлы *.jpg и создать новый каталог
// для модифицированных данных.
string[] files = Directory.GetFiles(@".\TestPictures", "*.jpg",
SearchOption.
AllDirectories);
string outputDirectory = @".\ModifiedPictures";
Directory.CreateDirectory(outputDirectory);
try
{
// Обработать данные изображения в параллельном режиме!
Parallel.ForEach(files, parOpts, currentFile =>
{
parOpts
.CancellationToken.ThrowIfCancellationRequested();
string filename = Path.GetFileName(currentFile);
Dispatcher?.Invoke(() =>
{
this.Title =
$"Processing {filename}
on thread {Thread.CurrentThread.ManagedThreadId}";
});
using (Bitmap bitmap = new Bitmap(currentFile))
{
bitmap.RotateFlip(RotateFlipType.Rotate180FlipNone);
bitmap.Save(Path.Combine(outputDirectory, filename));
}
});
Dispatcher?.Invoke(()=>this.Title = "Done!");
}
catch (OperationCanceledException ex)
{
Dispatcher?.Invoke(()=>this.Title = ex.Message);
}
}
Обратите внимание, что в начале метода конфигурируется объект
ParallelOptions
с установкой его свойства CancellationToken
для применения маркера CancellationTokenSource
. Кроме того, этот объект ParallelOptions
передается во втором параметре методу Parallel.ForEach()
.
Внутри логики цикла осуществляется вызов
ThrowIfCancellationRequested()
на маркере отмены, гарантируя тем самым, что если пользователь щелкнет на кнопке Cancel, то все потоки будут остановлены ив качестве уведомления сгенерируется исключение времени выполнения. Перехватив исключение OperationCanceledException
, можно добавить в текст главного окна сообщение об ошибке.
В дополнение к обеспечению параллелизма данных библиотека TPL также может использоваться для запуска любого количества асинхронных задач с помощью метода
Parallel.Invoke()
. Такой подход немного проще, чем применение делегатов или типов из пространства имен System.Threading
, но если нужна более высокая степень контроля над выполняемыми задачами, тогда следует отказаться от использования Parallel.Invoke()
и напрямую работать с классом Task
, как делалось в предыдущем примере.
Чтобы взглянуть на параллелизм задач в действии, создайте новый проект консольного приложения по имени
MyEBookReader
и импортируйте в начале файла Program.cs
пространства имен System.Threading
, System.Text
, System.Threading.Tasks
, System.Linq
и System.Net
(пример является модификацией полезного примера из документации по .NET Core). Здесь мы будем извлекать публично доступную электронную книгу из сайта проекта Гутенберга (www.gutenberg.org
) и затем параллельно выполнять набор длительных задач. Книга загружается в методе GetBook()
, показанном ниже:
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Net;
using System.Text;
string _theEBook = "";
GetBook();
Console.WriteLine("Downloading book...");
Console.ReadLine();
void GetBook()
{
WebClient wc = new WebClient();
wc.DownloadStringCompleted += (s, eArgs) =>
{
_theEBook = eArgs.Result;
Console.WriteLine("Download complete.");
GetStats();
};
// Загрузить электронную книгу Чарльза Диккенса "A Tale of Two Cities".
// Может понадобиться двукратное выполнение этого кода, если ранее вы
// не посещали данный сайт, поскольку при первом его посещении появляется
// окно с сообщением, предотвращающее нормальное выполнение кода.
wc.DownloadStringAsync(new Uri("http://www.gutenberg.org/
files/98/98-8.txt"));
}
Класс
WebClient
определен в пространстве имен System.Net
. Он предоставляет несколько методов для отправки и получения данных от ресурса, идентифицируемого посредством URL. В свою очередь многие из них имеют асинхронные версии, такие как метод DownloadStringAsync()
, который автоматически порождает новый поток из пула потоков .NET Core Runtime. Когда объект WebClient
завершает получение данных, он инициирует событие DownloadStringCompleted
, которое обрабатывается с применением лямбда-выражения С#. Если вызвать синхронную версию этого метода (DownloadString()
), то сообщение Downloading book...
не появится до тех пор, пока загрузка не завершится.
Далее реализуйте метод
GetStats()
для извлечения индивидуальных слов, содержащихся в переменной theEBook
, и передачи строкового массива на обработку нескольким вспомогательным методам:
void GetStats()
{
// Получить слова из электронной книги.
string[] words = _theEBook.Split(new char[]
{ ' ', '\u000A', ',', '.', ';', ':', '-', '?', '/' },
StringSplitOptions.RemoveEmptyEntries);
// Найти 10 наиболее часто встречающихся слов.
string[] tenMostCommon = FindTenMostCommon(words);
// Получить самое длинное слово.
string longestWord = FindLongestWord(words);
// Когда все задачи завершены, построить строку, показывающую
// все статистические данные в окне сообщений.
StringBuilder bookStats =
new StringBuilder("Ten Most Common Words are:\n");
foreach (string s in tenMostCommon)
{
bookStats.AppendLine(s);
}
bookStats.AppendFormat("Longest word is: {0}", longestWord);
// Самое длинное слово
bookStats.AppendLine();
Console.WriteLine(bookStats.ToString(), "Book info");
// Информация о книге
}
Метод
FindTenMostCommon()
использует запрос LINQ для получения списка объектов string
, которые наиболее часто встречаются в массиве string
, а метод FindLongestWord()
находит самое длинное слово:
string[] FindTenMostCommon(string[] words)
{
var frequencyOrder = from word in words
where word.Length > 6
group word by word into g
orderby g.Count() descending
select g.Key;
string[] commonWords = (frequencyOrder.Take(10)).ToArray();
return commonWords;
}
string FindLongestWord(string[] words)
{
return (from w in words orderby w.Length descending select w)
.FirstOrDefault();
}
После запуска проекта выполнение всех задач может занять внушительный промежуток времени, что зависит от количества процессоров в машине и их тактовой частоты. В конце концов, должен появиться следующий вывод:
Downloading book...
Download complete.
Ten Most Common Words are:
Defarge
himself
Manette
through
nothing
business
another
looking
prisoner
Cruncher
Longest word is: undistinguishable
Помочь удостовериться в том, что приложение задействует все доступные процессоры машины, может параллельный вызов методов
FindTenMostCommon()
и FindLongestWord()
. Для этого модифицируйте метод GetStats():
void GetStats()
{
// Получить слова из электронной книги.
string[] words = _theEBook.Split(
new char[] { ' ', '\u000A', ',', '.', ';', ':', '-', '?', '/' },
StringSplitOptions.RemoveEmptyEntries);
string[] tenMostCommon = null;
string longestWord = string.Empty;
Parallel.Invoke(
() =>
{
// Найти 10 наиболее часто встречающихся слов.
tenMostCommon = FindTenMostCommon(words);
},
() =>
{
// Найти самое длинное слово.
longestWord = FindLongestWord(words);
});
// Когда все задачи завершены, построить строку,
// показывающую все статистические данные.
...
}
Метод
Parallel.Invoke()
ожидает передачи в качестве параметра массива делегатов Action<>
, который предоставляется косвенно с применением лямбда-выражения. В то время как вывод идентичен, преимущество заключается в том, что библиотека TPL теперь будет использовать все доступные процессоры машины для вызова каждого метода параллельно, если подобное возможно.
В завершение знакомства с библиотекой TPL следует отметить, что существует еще один способ встраивания параллельных задач в приложения .NET Core. При желании можно применять набор расширяющих методов, которые позволяют конструировать запрос LINQ, распределяющий свою рабочую нагрузку по параллельным потокам (когда это возможно). Соответственно запросы LINQ, которые спроектированы для параллельного выполнения, называются запросами Parallel LINQ (PLINQ).
Подобно параллельному коду, написанному с использованием класса
Parallel
, в PLINQ имеется опция игнорирования запроса на обработку коллекции параллельным образом, если понадобится. Инфраструктура PLINQ оптимизирована во многих отношениях, включая определение того, не будет ли запрос на самом деле более эффективно выполняться в синхронной манере.
Во время выполнения PLINQ анализирует общую структуру запроса, и если есть вероятность, что запрос выиграет от распараллеливания, то он будет выполняться параллельно. Однако если распараллеливание запроса ухудшит производительность, то PLINQ просто запустит запрос последовательно. Когда возникает выбор между потенциально затратным (в плане ресурсов) параллельным алгоритмом и экономным последовательным, предпочтение по умолчанию отдается последовательному алгоритму.
Необходимые расширяющие методы находятся в классе
ParallelEnumerable
из пространства имен System.Linq
. В табл. 15.5 описаны некоторые полезные расширяющие методы PLINQ.
Чтобы взглянуть на PLINQ в действии, создайте проект консольного приложения по имени
PLINQDataProcessingWithCancellation
и импортируйте в него пространства имен System.Linq
, System.Threading
и System.Threading.Tasks
(если это еще не сделано). После начала обработки запускается новая задача, выполняющая запрос LINQ, который просматривает крупный массив целых чисел в поиске элементов, удовлетворяющих условию, что остаток от их деления на 3 дает 0. Вот непараллельная версия такого запроса:
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
Console.WriteLine("Start any key to start processing");
// Нажмите любую клавишу, чтобы начать обработку
Console.ReadKey();
Console.WriteLine("Processing");
Task.Factory.StartNew(ProcessIntData);
Console.ReadLine();
void ProcessIntData()
{
// Получить очень большой массив целых чисел.
int[] source = Enumerable.Range(1, 10_000_000).ToArray();
// Найти числа, для которых истинно условие num % 3 == О,
// и возвратить их в убывающем порядке.
int[] modThreeIsZero = (
from num in source
where num % 3 == 0
orderby num descending
select num).ToArray();
// Вывести количество найденных чисел
Console.WriteLine($"Found {modThreeIsZero.Count()} numbers
that match query!");
}
Чтобы проинформировать библиотеку TPL о выполнении запроса в параллельном режиме (если такое возможно), необходимо использовать расширяющий метод
AsParallel()
:
int[] modThreeIsZero = (
from num in source.AsParallel()
where num % 3 == 0
orderby num descending select num).ToArray();
Обратите внимание, что общий формат запроса LINQ идентичен тому, что вы видели в предыдущих главах. Тем не менее, за счет включения вызова
AsParallel()
библиотека TPL попытается распределить рабочую нагрузку по доступным процессорам.
С помощью объекта
CancellationTokenSource
запрос PLINQ можно также информировать о прекращении обработки при определенных условиях (обычно из-за вмешательства пользователя). Объявите на уровне класса Program
объект CancellationTokenSource
по имени _cancelToken
и модифицируйте операторы верхнего уровня для принятия ввода от пользователя. Ниже показаны соответствующие изменения в коде:
CancellationTokenSource _cancelToken =
new CancellationTokenSource();
do
{
Console.WriteLine("Start any key to start processing");
// Нажмите любую клавишу, чтобы начать обработку
Console.ReadKey();
Console.WriteLine("Processing");
Task.Factory.StartNew(ProcessIntData);
Console.Write("Enter Q to quit: ");
// Введите Q для выхода:
string answer = Console.ReadLine();
// Желает ли пользователь выйти?
if (answer.Equals("Q",
StringComparison.OrdinalIgnoreCase))
{
_cancelToken.Cancel();
break;
}
}
while (true);
Console.ReadLine();
Теперь запрос PLINQ необходимо информировать о том, что он должен ожидать входящего запроса на отмену выполнения, добавив в цепочку вызов расширяющего метода
WithCancellation()
с передачей ему маркера отмены. Кроме того, этот запрос PLINQ понадобится поместить в подходящий блок try/catch
и обработать возможные исключения. Финальная версия метода ProcessInData()
выглядит следующим образом:
void ProcessIntData()
{
// Получить очень большой массив целых чисел.
int[] source = Enumerable.Range(1, 10_000_000).ToArray();
// Найти числа, для которых истинно условие num % 3 == 0,
// и возвратить их в убывающем порядке.
int[] modThreeIsZero = null;
try
{
modThreeIsZero =
(from num in source.AsParallel().WithCancellation(_cancelToken.Token)
where num % 3 == 0
orderby num descending
select num).ToArray();
Console.WriteLine();
// Вывести количество найденных чисел.
Console.WriteLine($"Found {modThreeIsZero.Count()} numbers
that match query!");
}
catch (OperationCanceledException ex)
{
Console.WriteLine(ex.Message);
}
}
Во время выполнения метода
ProcessIntData()
понадобится нажать <Q> и быстро произвести ввод, чтобы увидеть сообщение от маркера отмены.
В этой довольно длинной главе было представлено много материала в сжатом виде. Конечно, построение, отладка и понимание сложных многопоточных приложений требует прикладывания усилий в любой инфраструктуре. Хотя TPL, PLINQ и тип делегата могут до некоторой степени упростить решение (особенно по сравнению с другими платформами и языками), разработчики по-прежнему должны хорошо знать детали разнообразных расширенных приемов.
С выходом версии .NET 4.5 в языке программирования C# появились два новых ключевых слова, которые дополнительно упрощают процесс написания асинхронного кода. По контрасту со всеми примерами, показанными ранее в главе, когда применяются ключевые слова
async
и await
, компилятор будет самостоятельно генерировать большой объем кода, связанного с потоками, с использованием многочисленных членов из пространств имен System.Threading
и System.Threading.Tasks
.
Ключевое слово
async
языка C# применяется для указания на то, что метод, лямбда-выражение или анонимный метод должен вызываться в асинхронной манере автоматически. Да, это правда. Благодаря простой пометке метода модификатором async
среда .NET Core Runtime будет создавать новый поток выполнения для обработки текущей задачи. Более того, при вызове метода async
ключевое слово await
будет автоматически приостанавливать текущий поток до тех пор, пока задача не завершится, давая возможность вызывающему потоку продолжить свою работу.
В целях иллюстрации создайте новый проект консольного приложения по имени
FunWithCSharpAsync
и импортируйте в файл Program.cs
пространства имен System.Threading
, System.Threading.Task
и System.Collections.Generic
. Добавьте метод DoWork()
, который заставляет вызывающий поток ожидать пять секунд. Ниже показан код:
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
Console.WriteLine(" Fun With Async ===>");
Console.WriteLine(DoWork());
Console.WriteLine("Completed");
Console.ReadLine();
static string DoWork()
{
Thread.Sleep(5_000);
return "Done with work!";
}
Вам известно, что после запуска программы придется ожидать пять секунд, прежде чем сможет произойти что-то еще. В случае графического приложения весь пользовательский интерфейс был бы заблокирован до тех пор, пока работа не завершится.
Если бы мы решили прибегнуть к одному из описанных ранее приемов, чтобы сделать приложение более отзывчивым, тогда пришлось бы немало потрудиться. Тем не менее, начиная с версии .NET 4.5, можно написать следующий код С#:
...
string message = await DoWorkAsync();
Console.WriteLine(message);
...
static string DoWork()
{
Thread.Sleep(5_000);
return "Done with work!";
}
static async Task DoWorkAsync()
{
return await Task.Run(() =>
{
Thread.Sleep(5_000);
return "Done with work!";
});
}
Если вы используете в качестве точки входа метод
Main()
(вместо операторов верхнего уровня), тогда должны пометить метод с помощью ключевого слова async
, появившегося в версии C# 7.1:
static async Task Main(string[] args)
{
...
string message = await DoWorkAsync();
Conole.WriteLine(message);
...
}
На заметку! Возможность декорирования метода
Main()
посредством async
— нововведение, появившееся в версии C# 7.1. Операторы верхнего уровня в версии C# 9.0 являются неявно асинхронными.
Обратите внимание на ключевое слово
await
перед именем метода, который будет вызван в неблокирующей манере. Это важно: если метод декорируется ключевым словом async
, но не имеет хотя бы одного внутреннего вызова метода с использованием await
, то получится синхронный вызов (на самом деле компилятор выдаст соответствующее предупреждение).
Кроме того, вы должны применять класс
Task
из пространства имен System.Threading.Tasks
для переделки методов Main()
(если вы используете Main()
) и DoWork()
(последний добавляется как DoWorkAsync()
). По существу вместо возвращения просто специфического значения (объекта string
в текущем примере) возвращается объект Task
, где обобщенный параметр типа Т
представляет собой действительное возвращаемое значение.
Реализация метода
DoWorkAsync()
теперь напрямую возвращает объект Task
, который является возвращаемым значением Task.Run()
. Метод Run()
принимает делегат Func<>
или Action<>
и, как вам уже известно, для простоты здесь можно использовать лямбда-выражение. В целом новая версия DoWorkAsync()
может быть описана следующим образом.
При вызове запускается новая задача, которая заставляет вызывающий поток уснуть на пять секунд. После завершения вызывающий поток предоставляет строковое возвращаемое значение. Эта строка помещается в новый объект
и возвращается вызывающему коду.Task
Благодаря новой реализации метода
DoWorkAsync()
мы можем получить некоторое представление о подлинной роли ключевого слова await
. Оно всегда будет модифицировать метод, который возвращает объект Task
. Когда поток выполнения достигает await
, вызывающий поток приостанавливается до тех пор, пока вызов не будет завершен. Запустив эту версию приложения, вы обнаружите, что сообщение Completed
отображается перед сообщением Done with work!
В случае графического приложения можно было бы продолжать работу с пользовательским интерфейсом одновременно с выполнением метода DoWorkAsync()
.
Тип
SynchronizationContext
формально определен как базовый класс, который предоставляет свободный от потоков контекст баз синхронизации. Хотя такое первоначальное определение не особо информативно, в официальной документации указаны следующие сведения.
Цель модели синхронизации, реализуемой классом
, заключается в том, чтобы позволить внутренним асинхронным/синхронным операциям общеязыковой исполняющей среды вести себя надлежащим образом с различными моделями синхронизации.SynchronizationContext
Наряду с тем, что вам уже известно о многопоточности, такое заявление проливает свет на этот вопрос. Вспомните, что приложения с графическим пользовательским интерфейсом (Windows Forms, WPF) не разрешают прямой доступ к элементам управления из вторичных потоков, а требуют делегирования доступа. Вы уже видели объект
Dispatcher
в примере приложения WPF. В консольных приложениях, которые не используют WPF, это ограничение отсутствует. Речь идет о разных моделях синхронизации. С учетом всего сказанного давайте рассмотрим класс SynchronizationContext
.
Класс
SynchonizationContext
является типом, предоставляющим виртуальный метод отправки, который принимает делегат, предназначенный для выполнения асинхронным образом. В результате инфраструктуры получают шаблон для надлежащей обработки асинхронных запросов (диспетчеризация для приложений WPF/Windows Forms, прямое выполнение для приложений без графического пользовательского интерфейса и т.д.). Он предлагает способ постановки в очередь единицы работы в контексте и подсчета асинхронных операций, ожидающих выполнения.
Как обсуждалось ранее, когда делегат помещается в очередь для асинхронного выполнения, он планируется к запуску в отдельном потоке, что обрабатывается средой .NET Core Runtime. Задача обычно решается с помощью управляемого пула потоков .NET Core Runtime, но может быть построена и специальная реализация.
Хотя такими связующими действиями можно управлять вручную в коде, шаблон
async/await
делает большую часть трудной работы. В случае применения await
к асинхронному методу задействуются реализации SynchronizationContext
и TaskScheduler
целевой инфраструктуры. Например, если вы используете async/await
в приложении WPF, то инфраструктура WPF обеспечит диспетчеризацию делегата и обратный вызов в конечном автомате при завершении ожидающей задачи, чтобы безопасным образом обновить элементы управления.
Теперь, когда вы лучше понимаете роль класса
SynchronizationContext
, пришло время раскрыть роль метода ConfigureAwait()
. По умолчанию применение await
к объекту Task
приводит к использованию контекста синхронизации. При разработке приложений с графическим пользовательским интерфейсом (Windows Forms, WPF) именно такое поведение является желательным. Однако в случае написания кода приложения без графического пользовательского интерфейса накладные расходы, связанные с постановкой в очередь исходного контекста, когда в этом нет нужды, потенциально могут вызвать проблемы с производительностью приложения.
Чтобы увидеть все в действии, модифицируйте операторы верхнего уровня, как показано ниже:
Console.WriteLine(" Fun With Async ===>");
// Console.WriteLine(DoWork());
string message = await DoWorkAsync();
Console.WriteLine(message);
string message1 = await DoWorkAsync().ConfigureAwait(false);
Console.WriteLine(message1);
В исходном блоке кода применяется класс
SynchronizationContext
, поставляемый инфраструктурой (в данном случае средой .NET Core Runtime), что эквивалентно вызову ConfigureAwait(true)
. Во втором примере текущий контекст и планировщик игнорируются.
Согласно рекомендациям команды создателей .NET Core при разработке прикладного кода (Windows Forms, WPF и т.д.) следует полагаться на стандартное поведение, а в случае написания неприкладного кода (скажем, библиотеки) использовать вызов
ConfigureAwait(false)
. Одним исключением является инфраструктура ASP.NET Core (рассматриваемая в части IX), где специальная реализация SynchronizationContext
не создается; таким образом, вызов ConfigureAwait(false)
не дает преимущества при работе с другими инфраструктурами.
Конечно же, вы заметили, что мы изменили имя метода с
DoWork()
на DoWorkAsync()
, но по какой причине? Давайте предположим, что новая версия метода по-прежнему называется DoWork()
, но вызывающий код реализован так:
// Отсутствует ключевое слово await!
string message = DoWork();
Обратите внимание, что мы действительно пометили метод ключевым словом
async
, но не указали ключевое слово await при вызове DoWork()
. Здесь мы получим ошибки на этапе компиляции, потому что возвращаемым значением DoWork()
является объект Task
, который мы пытаемся напрямую присвоить переменной типа string
. Вспомните, что ключевое слово await
отвечает за извлечение внутреннего возвращаемого значения, которое содержится в объекте Task
. Поскольку await
отсутствует, возникает несоответствие типов.
На заметку! Метод, поддерживающий
await
— это просто метод, который возвращает Task
или Task
.
С учетом того, что методы, которые возвращают объекты
Task
, теперь могут вызываться в неблокирующей манере посредством конструкций async
и await
, в Microsoft рекомендуют (в качестве установившейся практики) снабжать имя любого метода, возвращающего Task
, суффиксом Async
. В таком случае разработчики, которым известно данное соглашение об именовании, получают визуальное напоминание о том, что ключевое слово await
является обязательным, если они намерены вызывать метод внутри асинхронного контекста.
На заметку! Обработчики событий для элементов управления графического пользовательского интерфейса (вроде обработчика события
Click
кнопки), а также методы действий внутри приложений в стиле MVC, к которым применяются ключевые слова async
и await
, не следуют указанному соглашению об именовании.
В настоящий момент наш метод
DoWorkAsync()
возвращает объект Task
, содержащий "реальные данные" для вызывающего кода, которые будут получены прозрачным образом через ключевое слово await
. Однако что если требуется построить асинхронный метод, возвращающий void
? Реализация зависит о того, нуждается метод в применении await
или нет (как в сценариях "запустил и забыл").
Если асинхронный метод должен поддерживать
await
, тогда используйте необобщенный класс Task
и опустите любые операторы return
, например:
static async Task MethodReturningTaskOfVoidAsync()
{
await Task.Run(() => { /* Выполнить какую-то работу... */
Thread.Sleep(4_000);
});
Console.WriteLine("Void method completed");
// Метод завершен
}
Затем в коде, вызывающем этот метод, примените ключевое слово
await
:
MethodReturningVoidAsync();
Console.WriteLine("Void method complete");
Если метод должен быть асинхронным, но не обязан поддерживать
await
и применяться в сценариях "запустил и забыл", тогда добавьте ключевое слово async
и сделайте возвращаемым типом void
, а не Task
. Методы такого рода обычно используются для задач вроде ведения журнала, когда нежелательно, чтобы запись в журнал приводила к задержке выполнения остального кода.
static async void MethodReturningVoidAsync()
{
await Task.Run(() => { /* Выполнить какую-то работу... */
Thread.Sleep(4_000);
});
Console.WriteLine("Fire and forget void method completed");
// Метод завершен
}
Затем в коде, вызывающем этот метод, ключевое слово
await
не используется:
MethodReturningVoidAsync();
Console.WriteLine("Void method complete");
Внутри реализации асинхронного метода разрешено иметь множество контекстов
await
. Следующий код является вполне допустимым:
static async Task MultipleAwaits()
{
await Task.Run(() => { Thread.Sleep(2_000); });
Console.WriteLine("Done with first task!");
// Первая задача завершена!
await Task.Run(() => { Thread.Sleep(2_000); });
Console.WriteLine("Done with second task!");
// Вторая задача завершена!
await Task.Run(() => { Thread.Sleep(2_000); });
Console.WriteLine("Done with third task!");
// Третья задача завершена!
}
Здесь каждая задача всего лишь приостанавливает текущий поток на некоторый период времени; тем не менее, посредством таких задач может быть представлена любая единица работы (обращение к веб-службе, чтение базы данных или что-нибудь еще). Еще один вариант предусматривает ожидание не каждой отдельной задачи, а всех их вместе. Это более вероятный сценарий, когда имеются три работы (скажем, проверка поступления сообщений электронной почты, обновление сервера, загрузка файлов), которые должны делаться в пакете, но могут выполняться параллельно. Ниже приведен модифицированный код, в котором используется метод
Task.WhenAll()
:
static async Task MultipleAwaits()
{
var task1 = Task.Run(() =>
{
Thread.Sleep(2_000);
Console.WriteLine("Done with first task!");
});
var task2=Task.Run(() =>
{
Thread.Sleep(1_000);
Console.WriteLine("Done with second task!");
});
var task3 = Task.Run(() =>
{
Thread.Sleep(1_000);
Console.WriteLine("Done with third task!");
});
await Task.WhenAll(task1, task2, task3);
}
Запустив программу, вы увидите, что три задачи запускаются в порядке от наименьшего значения, указанного при вызове метода
Sleep()
:
Fun With Async ===>
Done with work!
Void method completed
Done with second task!
Done with third task!
Done with first task!
Completed
Существует также метод
WhenAnу()
, возвращающий задачу, которая завершилась. Для демонстрации работы WhenAny()
измените последнюю строку метода MultipleAwaits()
следующим образом:
await Task.WhenAny(task1, task2, task3);
В результате вывод становится таким:
Fun With Async ===>
Done with work!
Void method completed
Done with second task!
Completed
Done with third task!
Done with first task!
В каждом из предшествующих примеров ключевое слово
async
использовалось для возвращения в поток вызывающего кода, пока выполняется асинхронный метод. В целом ключевое слово await
может применяться только в методе, помеченном как async
. А что если вы не можете (или не хотите) помечать метод с помощью async
?
К счастью, существуют другие способы вызова асинхронных методов. Если вы просто не используете ключевое слово
await
, тогда код продолжает работу после асинхронного метода, не возвращая управление вызывающему коду. Если вам необходимо ожидать завершения асинхронного метода (что происходит, когда применяется ключевое слово await
), то существуют два подхода.
Первый подход предусматривает просто использование свойства
Result
с методами, возвращающими Task
, или метода Wait()
с методами, возвращающими Task/Task
. (Вспомните, что метод, который возвращает значение, обязан возвращать Task
, будучи асинхронным, а метод, не имеющий возвращаемого значения, возвращает Task
, когда является асинхронным.) Если метод терпит неудачу, то возвращается AggregateException
.
Можете также добавить вызов
GetAwaiter().GetResult()
, который обеспечивает такой же эффект, как ключевое слово await
в асинхронном методе, и распространяет исключения в той же манере, что и async/await
. Тем не менее, указанные методы помечены в документации как "не предназначенные для внешнего использования", а это значит, что они могут измениться либо вовсе исчезнуть в какой-то момент в будущем. Вызов GetAwaiter().GetResult()
работает как с методами, возвращающими значение, так и с методами без возвращаемого значения.
На заметку! Решение использовать свойство
Result
или вызов GetAwaiter().GetResult()
с Task
возлагается полностью на вас, и большинство разработчиков принимают решение, основываясь на обработке исключений. Если ваш метод возвращает Task
, тогда вы должны применять вызов GetAwaiter().GetResult()
или Wait()
.
Например, вот как вы могли бы вызывать метод
DoWorkAsync()
:
Console.WriteLine(DoWorkAsync().Result);
Console.WriteLine(DoWorkAsync().GetAwaiter().GetResult());
Чтобы остановить выполнение до тех пор, пока не произойдет возврат из метода с возвращаемым типом
void
, просто вызовите метод Wait()
на объекте Task
:
MethodReturningVoidAsync().Wait();
В версии C# 6 появилась возможность помещения вызовов await в блоки
catch
и finally
. Для этого сам метод обязан быть async
. Указанная возможность демонстрируется в следующем примере кода:
static async Task MethodWithTryCatch()
{
try
{
//Do some work
return "Hello";
}
catch (Exception ex)
{
await LogTheErrors();
throw;
}
finally
{
await DoMagicCleanUp();
}
}
До выхода версии C# 7 возвращаемыми типами методов
async
были только Task
, Task
и void
. В версии C# 7 доступны дополнительные возвращаемые типы при условии, что они следуют шаблону с ключевым словом async
. В качестве конкретного примера можно назвать тип ValueTask
. Введите код, подобный показанному ниже:
static async ValueTask ReturnAnInt()
{
await Task.Delay(1_000);
return 5;
}
К типу
ValueTask
применимы все те же самые правила, что и к типу Task
, поскольку ValueTask
— это просто объект Task
для типов значений, заменяющий собой принудительное размещение объекта в куче.
Локальные функции были представлены в главе 4 и использовались в главе 8 с итераторами. Они также могут оказаться полезными для асинхронных методов. Чтобы продемонстрировать преимущество, сначала нужно взглянуть на проблему. Добавьте новый метод по имени
MethodWithProblems()
со следующим кодом:
static async Task MethodWithProblems(int firstParam, int secondParam)
{
Console.WriteLine("Enter");
await Task.Run(() =>
{
// Вызвать длительно выполняющийся метод
Thread.Sleep(4_000);
Console.WriteLine("First Complete");
// Вызвать еще один длительно выполняющийся метод, который терпит
// неудачу из-за того, что значение второго параметра выходит
// за пределы допустимого диапазона.
Console.WriteLine("Something bad happened");
});
}
Сценарий заключается в том, что вторая длительно выполняющаяся задача терпит неудачу из-за недопустимых входных данных. Вы можете (и должны) добавить в начало метода проверки, но поскольку весь метод является асинхронным, нет никаких гарантий, что такие проверки выполнятся. Было бы лучше, чтобы проверки происходили непосредственно перед выполнением вызываемого кода. В приведенном далее обновленном коде проверки делаются в синхронной манере, после чего закрытая функция выполняется асинхронным образом.
static async Task MethodWithProblemsFixed(int firstParam, int secondParam)
{
Console.WriteLine("Enter");
if (secondParam < 0)
{
Console.WriteLine("Bad data");
return;
}
await actualImplementation();
async Task actualImplementation()
{
await Task.Run(() =>
{
// Вызвать длительно выполняющийся метод
Thread.Sleep(4_000);
Console.WriteLine("First Complete");
// Вызвать еще один длительно выполняющийся метод, который терпит
// неудачу из-за того, что значение второго параметра выходит
// за пределы допустимого диапазона.
Console.WriteLine("Something bad happened");
});
}
}
Шаблон
async/await
также допускает отмену, которая реализуется намного проще, чем с методом Parallel.ForEach()
. Для демонстрации будет применяться тот же самый проект приложения WPF, рассмотренный ранее в главе. Вы можете либо повторно использовать этот проект, либо создать в решении новый проект приложения WPF (.NET Core) и добавить к нему пакет System.Drawing.Common
с помощью следующих команд CLI:
dotnet new wpf -lang c# -n PictureHandlerWithAsyncAwait
-o .\PictureHandlerWithAsyncAwait -f net5.0
dotnet sln .\Chapter15_AllProjects.sln add .\PictureHandlerWithAsyncAwait
dotnet add PictureHandlerWithAsyncAwait package System.Drawing.Common
Если вы работаете в Visual Studio, тогда щелкните правой кнопкой мыши на имени решения в окне Solution Explorer, выберите в контекстном меню пункт Add►Project (Добавить►Проект) и назначьте ему имя
PictureHandlerWithAsyncAwait
. Сделайте новый проект стартовым, щелкнув правой кнопкой мыши на его имени и выбрав в контекстном меню пункт Set as Startup Project (Установить как стартовый проект). Добавьте NuGet-пакет System.Drawing.Common
:
dotnet add PictureHandlerWithAsyncAwait package System.Drawing.Common
Приведите разметку XAML в соответствие с предыдущим проектом приложения WPF, но с заголовком
Picture Handler with Async/Await
.
Удостоверьтесь, что в файле
MainWindow.xaml.cs
присутствуют показанные ниже операторы using
:
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Drawing;
Затем добавьте переменную уровня класса для объекта
CancellationToken
и обработчик событий для кнопки Cancel:
private CancellationTokenSource _cancelToken = null;
private void cmdCancel_Click(object sender, EventArgs e)
{
_cancelToken.Cancel();
}
Процесс здесь такой же, как в предыдущем примере: получение каталога с файлами изображений, создание выходного каталога, получение файлов, поворот изображений в файлах и сохранение их в выходном каталоге. В новой версии для выполнения работы будут применяться асинхронные методы, а не
Parallel.ForEach()
, и сигнатуры методов принимают в качестве параметра объект CancellationToken
. Введите следующий код:
private async void cmdProcess_Click(object sender, EventArgs e)
{
_cancelToken = new CancellationTokenSource();
var basePath = Directory.GetCurrentDirectory();
var pictureDirectory =
Path.Combine(basePath, "TestPictures");
var outputDirectory =
Path.Combine(basePath, "ModifiedPictures");
// Удалить любые существующие файлы
if (Directory.Exists(outputDirectory))
{
Directory.Delete(outputDirectory, true);
}
Directory.CreateDirectory(outputDirectory);
string[] files = Directory.GetFiles(
pictureDirectory, "*.jpg", SearchOption.AllDirectories);
try
{
foreach(string file in files)
{
try
{
await ProcessFile(
file, outputDirectory,_cancelToken.Token);
}
catch (OperationCanceledException ex)
{
Console.WriteLine(ex);
throw;
}
}
}
catch (OperationCanceledException ex)
{
Console.WriteLine(ex);
throw;
}
catch (Exception ex)
{
Console.WriteLine(ex);
throw;
}
_cancelToken = null;
this.Title = "Processing complete";
}
После начальных настроек в коде организуется цикл по файлам с асинхронным вызовом метода
ProcessFile()
для каждого файла. Вызов метода ProcessFile()
помещен внутрь блока try/catch
и ему передается объект CancellationToken
. Если вызов Cancel()
выполняется на CancellationTokenSource
(т.е. когда пользователь щелкает на кнопке Cancel), тогда генерируется исключение OperationCanceledException
.
На заметку! Код
try/catch
может находиться где угодно в цепочке вызовов (как вскоре вы увидите). Размещать его при первом вызове или внутри самого асинхронного метода — вопрос личных предпочтений и нужд приложения.
Наконец, добавьте финальный метод
ProcessFile()
:
private async Task ProcessFile(string currentFile,
string outputDirectory, CancellationToken token)
{
string filename = Path.GetFileName(currentFile);
using (Bitmap bitmap = new Bitmap(currentFile))
{
try
{
await Task.Run(() =>
{
Dispatcher?.Invoke(() =>
{
this.Title = $"Processing {filename}";
});
bitmap.RotateFlip(RotateFlipType.Rotate180FlipNone);
bitmap.Save(Path.Combine(outputDirectory, filename));
}
,token);
}
catch (OperationCanceledException ex)
{
Console.WriteLine(ex);
throw;
}
}
}
Метод
ProcessFile()
использует еще одну перегруженную версию Task.Run()
, которая принимает в качестве параметра объект CancellationToken
. Вызов Task.Run()
помещен внутрь блока try/catch
(как и вызывающий код) на случай щелчка пользователем на кнопке Cancel.
В версии C# 8.0 появилась возможность создания и потребления потоков данных (раскрываются в главе 20) асинхронным образом. Метод, который возвращает асинхронный поток данных:
• объявляется с модификатором
async
;
• возвращает реализацию
IAsyncEnumerable
;
• содержит операторы
yield return
(рассматривались в главе 8) для возвращения последовательных элементов в асинхронном потоке данных.
Взгляните на приведенный далее пример:
public static async IAsyncEnumerable GenerateSequence()
{
for (int i = 0; i < 20; i++)
{
await Task.Delay(100);
yield return i;
}
}
Метод
GenerateSequence()
объявлен как async
, возвращает реализацию IAsyncEnumerable
и применяет yield return
для возвращения целых чисел из последовательности. Чтобы вызывать этот метод, добавьте следующий код:
await foreach (var number in GenerateSequence())
{
Console.WriteLine(number);
}
Настоящий раздел содержал много примеров; ниже перечислены ключевые моменты, которые в нем рассматривались.
• Методы (а также лямбда-выражения или анонимные методы) могут быть помечены ключевым словом
async
, что позволяет им работать в неблокирующей манере.
• Методы (а также лямбда-выражения или анонимные методы), помеченные ключевым словом
async
, будут выполняться синхронно до тех пор, пока не встретится ключевое слово await
.
• Один метод
async
может иметь множество контекстов await
.
• Когда встречается выражение
await
, вызывающий поток приостанавливается до тех пор, пока ожидаемая задача не завершится. Тем временем управление возвращается коду, вызвавшему метод.
• Ключевое слово
await
будет скрывать с глаз возвращаемый объект Task
, что выглядит как прямой возврат лежащего в основе возвращаемого значения. Методы, не имеющие возвращаемого значения, просто возвращают void
.
• Проверка параметров и другая обработка ошибок должна делаться в главной части метода с переносом фактической порции
async
в закрытую функцию.
• Для переменных, находящихся в стеке, объект
ValueTask
более эффективен, чем объект Task
, который может стать причиной упаковки и распаковки.
• По соглашению об именовании методы, которые могут вызываться асинхронно, должны быть помечены с помощью суффикса
Async
.
Глава начиналась с исследования роли пространства имен
System.Threading
. Как было показано, когда приложение создает дополнительные потоки выполнения, в результате появляется возможность выполнять множество задач (по виду) одновременно. Также было продемонстрировано несколько способов защиты чувствительных к потокам блоков кода, чтобы предотвратить повреждение разделяемых ресурсов.
Затем в главе исследовались новые модели для разработки многопоточных приложений, введенные в .NET 4.0, в частности Task Parallel Library и PLINQ. В завершение главы была раскрыта роль ключевых слов
async
и await
. Вы видели, что эти ключевые слова используются многими типами в библиотеке TPL; однако большинство работ по созданию сложного кода для многопоточной обработки и синхронизации компилятор выполняет самостоятельно.