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

Что же такое делегаты?

Делегаты - это указатели на методы

Такое понятие нам дает практически каждый сайт, на который мы перейдем по запросу "Делегаты 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.

Что насчет операций "вычитания", "сложения" делегатов? Фактически мы уже увидели выше, что происходит - мы создаем НОВЫЙ делегат, который хранит ссылки на обьекты и методы всех функций, которые мы ему скормили.

Например:

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 экземплярами нашей структуры. В общем - не путаем функции, на которые мы только ссылаем делегаты (соответсвенно создаём новый делегат) с уже существующим делегатом (когда просто делаем ссылку на существующий делегат).

На этом всё, главное — не забывать о существовании структур и как они передаются!!!

Комментарии (7)


  1. Malstream
    14.02.2025 14:28

    IL код:

    Это не IL-код.


    1. VsevKokhan Автор
      14.02.2025 14:28

      low-level c# *

      случайно написал про IL)


  1. freeExec
    14.02.2025 14:28

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

    И лучше определится с heap, хип, куча и писать везде одинаково.


  1. Fitbie
    14.02.2025 14:28

    Хорошая статья чтобы освежить память, но с неймингом и правда беда. Правда в последней части лично я бы выразился не "копия структуры не создаётся", а структура боксится 2 раза в 2 разных ссылочных объекта. И я бы добавил довольно очевидные, но полезные для подрастающих разработчиков вещи, что low-level c# это пережёванный компилятором код, поэтому лапша

    Action act = (Action) Delegate.Combine((Delegate) new Action((object) cnter, __methodptr(Iteration)), (Delegate) new Action((object) cnter, __methodptr(Iteration)));

    На деле оптимизация компилятора над Actionact = cnter.Iteration; + заворачиванием правой части выражения act += cnter.Iteration в Action-делегат и итоговой комбинацией двух получившихся делегатов. Здесь же можно добавить, что любая операция, происходящая между делегатом и методом под капотом всегда приводит к оборачиванию метода экземпляром делегата, включая подписку на event, если только в событии не переопределены аксессоры add/remove.
    Ну и просто любопытные вещи в духе myDelegate() полностью эквивалентен myDelegate.Invoke(), с той лишь разницей что во второй вариант можно вставить Null-conditional myDelegate?.Invoke(), про очень полезный паттерн применения IDisposable и комбинации(подписки) делегатов и то, почему он защищает от утечек памяти (статья очень хорошо акцентирует внимание на захвате ссылки на экземпляр объекта), хотя про события я бы в целом добавил, материала не так много, при этом с моего опыта именно через инкапсулирующие event`ы разработчики чаще всего работают с делегатами на практике, ну и всякая вариантность generic-делегатов, хотя это уже довольно общая информация.
    В любом случае автору плюсик.


    1. VsevKokhan Автор
      14.02.2025 14:28

      спасибо за комментарии, что решил нужным - учёл. А так как раз собирался комплексом поместить работу с ивентами в отдельную статью, чтобы лучше видеть разницу, что и как где работает\используется


  1. a-tk
    14.02.2025 14:28

    Автору статьи загадка

    Func<int> x = () => 1;
    x += () => 2;
    Console.WriteLine(x());

    Какой будет результат, не запуская код?

    Без этого статья будет не полной.


    1. VsevKokhan Автор
      14.02.2025 14:28

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