Всем привет, эта статья создана для ребят, которые знакомы с делегатами, но хотели бы разобраться, что это поподробнее.
Что же такое делегаты?
Делегаты - это указатели на методы
Такое понятие нам дает практически каждый сайт, на который мы перейдем по запросу "Делегаты C#".
Также, как мы уже знаем - делегаты это ссылочный тип. Но давайте подумаем, где они хранятся, как передаются и просто работают?
Сами методы хранятся в метаданных класса/структуры. "ссылки на методы" для делегатов хранятся в куче. Но только лишь ссылка на метод храниться в куче у делегата или что - то еще? Давайте проверим
Создадим простой класс счетчика
class CounterClass
{
public int Num = 0;
public void Iteration()
{
Num++;
Console.WriteLine(Num + " Из Iteration");
}
}
Здесь у нас при вызове функции переменная Num увеличивается на единичку и потом значение выводится на экран
Теперь возьмем делегат и назначим его к нашему методу Iteration. Вызовем метод Invoke у делегата и выведем на экран значение Num у экземпляра нашего класса
public static void Main()
{
CounterClass cnt = new CounterClass();
Action act = cnt.Iteration;
act();
Console.WriteLine(cnt.Num + " Из Main");
}
На экране увидим:
1 Из Iteration
1 Из Main
Результат логичный, но давайте всё же разберём, почему именно так.
Для начала нужно - где хранятся сами методы структур и классов? Они хранятся в метаданных. Получается, делегат будет выделять место в куче, где будет просто ссылка на указанный метод?
Почти Верно!
Но тогда как делегат узнаёт значение Num нашего класса? Всё просто, под капотом в делегате еще и будет храниться ссылка на наш класс => на все его переменные. Именно поэтому наш делегат будет знать значение, он использует не просто функцию, определенную в метаданных, но и еще ссылку на экземпляр класса этой фукнции. А наши экземпляры располагаются в куче. Данные к ним у нас будут, поэтому все хорошо.
Как это выглядит в Low-level c#? У нас также есть тут ссылка
public static void Main()
{
CounterClass cnt = new CounterClass();
new Action((object) cnt, __methodptr(Iteration))();
Console.WriteLine(string.Concat(cnt.Num.ToString(), " Из Main"));
}
Но что произойдет, если наш класс счетчика станет структурой?
Вывод будет:
1 Из Iteration
0 Из Main
Под капотом, мы будем боксить нашу структуру, копировать ее значение. Структура, которая фактически принимается делегатом и структура cnt - разные обьекты.
Поэтому, если мы сделаем следующее:
public static void Main()
{
CounterStruct cnt = new CounterStruct();
Action act = cnt.Iteration;
act();
Action act2 = act;
act2();
Console.WriteLine(cnt.Num + " Из Main");
}
Вывод будет:
1 Из Iteration
2 Из Iteration
0 Из Main
Структура уже будет в куче, новый делегат будет указывать уже на существующую структуру в куче, а не просто копировать ее. Но эти действия никак не влияют на нашу переменную cnt.
Теперь давайте поговорим про замыкания
public static void Main()
{
CounterStruct cnt = new CounterStruct();
Action act =()=>
{
cnt.Iteration();
};
cnt.Iteration();
act();
Console.WriteLine(cnt.Num + " Из Main");
}
Вот пример кода, как мы видим, у нас есть СТРУКТУРА Counter, все также, но мы используем лямбда функцию и уже внутри функции вызываем у переменной cnt метод.
Вывод:
1 Из Iteration
2 Из Iteration
2 Из Main
Low-level c# код:
using System;
using System.Runtime.CompilerServices;
internal class Programm
{
public static void Main()
{
Programm.<>c__DisplayClass0_0 cDisplayClass00 = new Programm.<>c__DisplayClass0_0();
cDisplayClass00.cnt = new CounterStruct();
Action act = new Action((object) cDisplayClass00, __methodptr(<Main>b__0));
cDisplayClass00.cnt.Iteration();
act();
Console.WriteLine(string.Concat(cDisplayClass00.cnt.Num.ToString(), " Из Main"));
}
public Programm()
{
base..ctor();
}
[CompilerGenerated]
private sealed class <>c__DisplayClass0_0
{
public CounterStruct cnt;
public <>c__DisplayClass0_0()
{
base..ctor();
}
internal void <Main>b__0()
{
this.cnt.Iteration();
}
}
}
У нас наша лямбда-функция преобразуется в полноценный класс, в котором и создается наша переменная cnt, пусть это и структура, но она часть класса => хранится в хипе, лямбда-функция преобразуется в отдельный метод для созданного компилятором класса. Все действия которые мы совершаем вручную с cnt на самом деле происходят с полем созданного компилятором класса. Именно поэтому у нас происходит работа с одной и той же структурой CounterStruct, передаётся не поле, а весь класс делегату, боксинга нет.
Интересный пример, где эти знания пригодятся - довольно популярный "квиз":
static void Main(string[] args)
{
Action act = null;
for (int i = 0; i < 10; i++)
{
act += () => { Console.WriteLine(i); };
}
act();
}
На первый взгляд - просится ответ 0, 1, 2, 3 ...
Но давайте применим уже имеющиеся знания - у нас есть делегат Action, к нему каждый раз "прибавляется" одна и та же функция - вывод i, наша лямбда-функция должна преобразоваться в класс, в котором будет поле i (именно её мы и используем в цикле), а также в классе будет присутствовать функция, выводящая на экран значение i. Посмотрим на вывод:
10
10
10
10
10
10
10
10
10
10
namespace ConsoleApp1
{
internal class Program
{
[NullableContext(1)]
private static void Main(string[] args)
{
Action act = (Action) null;
Program.<>c__DisplayClass0_0 cDisplayClass00 = new Program.<>c__DisplayClass0_0();
for (cDisplayClass00.i = 0; cDisplayClass00.i < 10; cDisplayClass00.i++)
act = (Action) Delegate.Combine((Delegate) act, (Delegate) new Action((object) cDisplayClass00, __methodptr(<Main>b__0)));
act();
}
public Program()
{
base..ctor();
}
[CompilerGenerated]
private sealed class <>c__DisplayClass0_0
{
public int i; // эту переменную дергаем в цикле и лямбда-функции
public <>c__DisplayClass0_0()
{
base..ctor();
}
internal void <Main>b__0()
{
Console.WriteLine(this.i); // тут наша фукнция с выводом
}
}
}
}
Будут везде десятки, так как наша переменная i — часть класса => находится в куче, общая для всех делегатов, а исполнение наших делегатов начинается после цикла for. Получается, когда делегат начинает своё выполнение он работает с переменной i, которая после «прокрутки» цикла for — принимает значение 10.
Но здесь важно уловить 1 мысль и запомнить её - какую именно переменную захватит класс, сгенерированный компилятором. Например, в примере выше - все логично, у нас 1 раз создётся i, и мы просто переписываем её значение. Поэтому компилятор легко может "засунуть" её в свой класс. Но что насчёт следующих примеров?
static void Main(string[] args)
{
Action action = null;
for (int i = 0; i < 10; i++)
{
var copy = i;
action += ()=> {Console.WriteLine(copy);};
}
action();
}
static void Main(string[] args)
{
Action[] actions = new Action[3];
List<int> numbers = new List<int> { 1, 2, 3 };
int index = 0;
foreach (var i in numbers)
{
actions[index++] = () => Console.WriteLine(i);
}
foreach (var action in actions)
{
action();
}
}
В первом примере бросается сразу переменная copy, ведь именно её я хочу выводить.
Но давайте подумаем, значит теперь компилятор сгенерирует класс с полем для copy? Если так, то это поле будет при каждом прокруте цикла - новой переменной, как это будет реализовано в Low-level C#?
internal class Program
{
[NullableContext(1)]
private static void Main(string[] args)
{
Action action = (Action) null;
for (int i = 0; i < 10; ++i)
{
Program.<>c__DisplayClass0_0 cDisplayClass00 = new Program.<>c__DisplayClass0_0();
cDisplayClass00.copy = i;
action = (Action) Delegate.Combine((Delegate) action, (Delegate) new Action((object) cDisplayClass00, __methodptr(<Main>b__0)));
}
action();
}
public Program()
{
base..ctor();
}
[CompilerGenerated]
private sealed class <>c__DisplayClass0_0
{
public int copy;
public <>c__DisplayClass0_0()
{
base..ctor();
}
internal void <Main>b__0()
{
Console.WriteLine(this.copy);
}
}
}
На самом деле, ничего глобально не меняется, единственное - теперь мы создаём экземпляр класса (который лямбда) не в самом начале в Main, а в цикле, чтобы улавливать каждую переменную copy. И в делегате будут храниться РАЗНЫЕ экземпляры классов, каждый - со своим значением copy.
С вторым примером - ещё проще, различие foreach и for - в for мы переменную перезаписываем, а в foreach - каждый раз отдаём новую. Поэтому для 2 примера Low-level C#:
private static void Main(string[] args)
{
Action[] actions = new Action[3];
List<int> intList = new List<int>();
intList.Add(1);
intList.Add(2);
intList.Add(3);
List<int> numbers = intList;
int index1 = 0;
List<int>.Enumerator enumerator = numbers.GetEnumerator();
try
{
while (enumerator.MoveNext())
{
Program.<>c__DisplayClass0_0 cDisplayClass00 = new Program.<>c__DisplayClass0_0();
cDisplayClass00.i = enumerator.Current;
actions[index1++] = new Action((object) cDisplayClass00, __methodptr(<Main>b__0));
}
}
finally
{
enumerator.Dispose();
}
Action[] actionArray = actions;
for (int index2 = 0; index2 < actionArray.Length; ++index2)
actionArray[index2]();
}
То есть в цикле у нас i каждый раз - новая переменная, а наша лямбда создаёт новый экземпляр класса со своим i при каждой итерации цикла.
Просто запомните - класс, генерируемый компилятором всеми силами будет стремиться уловить все переменные, которые вы используете в лямбде, поэтому тут он создаётся в теле цикла.
Что насчет операций "вычитания", "сложения" делегатов? Фактически мы уже увидели выше, что происходит - мы создаем НОВЫЙ делегат, который хранит ссылки на обьекты и методы всех функций, которые мы ему скормили.
Например:
class Program
{
static void Main(string[] args)
{
var cnter = new CounterClass();
Action act = cnter.Iteration;
act += cnter.Iteration;
act();
act += Console.WriteLine;
}
}
На Low-level c#:
internal class Program
{
[NullableContext(1)]
private static void Main(string[] args)
{
CounterClass cnter = new CounterClass();
Action act1 = (Action) Delegate.Combine((Delegate) new Action((object) cnter, __methodptr(Iteration)), (Delegate) new Action((object) cnter, __methodptr(Iteration)));
act1();
Action act2 = (Action) Delegate.Combine((Delegate) act1, (Delegate) (Program.<>O.<0>__WriteLine ?? (Program.<>O.<0>__WriteLine = new Action((object) null, __methodptr(WriteLine)))));
}
public Program()
{
base..ctor();
}
[CompilerGenerated]
private static class <>O
{
public static Action <0>__WriteLine;
}
}
Мы видим - передаем ссылку на объект + ссылка на метод объекта. Но что будет, если счетчик будет структурой?:
class Program
{
static void Main(string[] args)
{
var cnter = new CounterStruct();
Action act = cnter.Iteration;
act += cnter.Iteration;
act += act;
act();
Console.WriteLine(cnter.Num);
}
}
Low-level c# код:
internal class Program
{
[NullableContext(1)]
private static void Main(string[] args)
{
CounterStruct cnter = new CounterStruct();
Action act = (Action) Delegate.Combine((Delegate) new Action((object) cnter, __methodptr(Iteration)), (Delegate) new Action((object) cnter, __methodptr(Iteration)));
((Action) Delegate.Combine((Delegate) act, (Delegate) act))();
Console.WriteLine(cnter.Num);
}
public Program()
{
base..ctor();
}
}
Вывод будет :1 Из Iteration
1 Из Iteration
2 Из Iteration
2 Из Iteration
0
Почему же так?
Сперва Action act = cnter.Iteration; создается копия структуры
Далее создается ЕЩЕ одна копия структуры, новый делегат хранит ссылки на 2 разных экземпляра структуры.
А после - мы под капотом храним ссылки на уже готовый делегат. То есть мы снова не создаем объекты. Мы делаем новый делегат (ссылочный объект) через 2 ссылки на готовый делегат (а каждая функция + ссылка на объект этой функции уже является делегатом). Поэтому мы будем работать только с 2 экземплярами нашей структуры. В общем - не путаем функции, на которые мы только ссылаем делегаты (соответсвенно создаём новый делегат) с уже существующим делегатом (когда просто делаем ссылку на существующий делегат).
На этом всё, главное — не забывать о существовании структур и как они передаются!!!