Как их использовала Microsoft

Методы расширения - достаточно удобный механизм, который упрощает понимание кода в разы. Приведу в пример Microsoft, которая использовала данный механизм для создания LINQ.

Допустим, есть коллекция из строк. У нас стоит задача отобрать из них те, которые начинаются на латинскую букву "A". Напишем код, который решает данную задачу

var array = new[]
{
  "Array", "Adapter", "Class", "Interface"  
}; // Объявляем массив со строками

// Используем статичный метод Where в статичном классе Enumerable
var startsWithA = Enumerable.Where(array, s => s.StartsWith('A'));

// Выводим все результат
foreach (var s in startsWithA)
{
    Console.WriteLine(s);
}

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

var array = new[]
{
  "Array", "Adapter", "Class", "Interface"  
}; // Объявляем массив со строками

// Используем статичный метод расширения Where
var startsWithA = array.Where(s => s.StartsWith('A'));

// Выводим результат
foreach (var s in startsWithA)
{
    Console.WriteLine(s);
}

Строчка с отбором элементов коллекции стала опрятней. Давайте посмотрим на определение метода Where

public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> source,
                                                  Func<TSource, bool> predicate)

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

Создаем метод расширения для выведения членов массива в консоль

Теперь мы можем тоже использовать этот механизм для увеличения читаемости нашего кода

Задача: создать инструмент для вывода всех элементов массива в консоль

Создадим статичный класс CollectionWriter, в котором будет определен метод WriteToConsole, который как раз и выводит всю коллекцию

using System.Collections;

public static class CollectionWriter
{
    public static void WriteToConsole(IEnumerable collection)
    {
        foreach (var member in collection)
        {
            Console.WriteLine(member);
        }
    }
}

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

var array = new[] { "Member1", "Member2", "Member3" };

CollectionWriter.WriteToConsole(array);

Поставим ключевое слово this перед первым аргументом, создав тем самым для компилятора пометку, что данный метод является "расширяющим" (позже посмотрим какую именно метку ставит после нас компилятор для увеличения производительности). Объявление метода теперь имеет следующий вид

public static void WriteToConsole(this IEnumerable collection)

Теперь мы можем вызывать метод уже следующим образом

var array = new[] { "Member1", "Member2", "Member3" };

array.WriteToConsole();

По сути мы можем дословно перевести строчку 3 на человеческий язык.

array.WriteToConsole() = массив.ВывестиВКонсоль()

Нужно быть аккуратным в использовании этого механизма. Если в следующем обновлении библиотек Microsoft добавит, например, в класс List<T> наш метод WriteToConsole, то компилятор будет ставить в приоритет экземплярный метод, и программа будет вести себя как-нибудь по-другому.

Как это работает "под капотом"

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

Когда компилятор встречает array.WriteToConsole(), он в первую очередь начинает проверять на соответствие методы, которые находятся в текущем классе и его родителях. Если он не нашел подходящий, начинается уже поиск методов расширения по всех статических классах. Как только он нашел подходящий метод с пометкой this у первого аргумента, он генерирует IL код для него, помечая его и класс, в котором он определен, атрибутом ExtensionAttribute. Для нашего класса IL код выглядит так:

.class public abstract sealed auto ansi beforefieldinit
  CollectionWriter
    extends [System.Runtime]System.Object
{
  .custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor()
    = (01 00 00 00 )

  .method public hidebysig static void
    WriteToConsole(
      class [System.Runtime]System.Collections.IEnumerable collection
    ) cil managed
  {
    .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor([in] unsigned int8)
      = (01 00 01 00 00 ) // .....
      // unsigned int8(1) // 0x01
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor()
      = (01 00 00 00 )
    .maxstack 1
    .locals init (
      [0] class [System.Runtime]System.Collections.IEnumerator V_0,
      [1] object member,
      [2] class [System.Runtime]System.IDisposable V_2
    )

    // [10 5 - 10 6]
    IL_0000: nop

    // [11 9 - 11 16]
    IL_0001: nop

    // [11 32 - 11 42]
    IL_0002: ldarg.0      // collection
    IL_0003: callvirt     instance class [System.Runtime]System.Collections.IEnumerator [System.Runtime]System.Collections.IEnumerable::GetEnumerator()
    IL_0008: stloc.0      // V_0
    .try
    {

      IL_0009: br.s         IL_001b
      // start of loop, entry point: IL_001b

        // [11 18 - 11 28]
        IL_000b: ldloc.0      // V_0
        IL_000c: callvirt     instance object [System.Runtime]System.Collections.IEnumerator::get_Current()
        IL_0011: stloc.1      // member

        // [12 9 - 12 10]
        IL_0012: nop

        // [13 13 - 13 39]
        IL_0013: ldloc.1      // member
        IL_0014: call         void [System.Console]System.Console::WriteLine(object)
        IL_0019: nop

        // [14 9 - 14 10]
        IL_001a: nop

        // [11 29 - 11 31]
        IL_001b: ldloc.0      // V_0
        IL_001c: callvirt     instance bool [System.Runtime]System.Collections.IEnumerator::MoveNext()
        IL_0021: brtrue.s     IL_000b
      // end of loop
      IL_0023: leave.s      IL_0037
    } // end of .try
    finally
    {

      IL_0025: ldloc.0      // V_0
      IL_0026: isinst       [System.Runtime]System.IDisposable
      IL_002b: stloc.2      // V_2
      IL_002c: ldloc.2      // V_2
      IL_002d: brfalse.s    IL_0036
      IL_002f: ldloc.2      // V_2
      IL_0030: callvirt     instance void [System.Runtime]System.IDisposable::Dispose()
      IL_0035: nop

      IL_0036: endfinally
    } // end of finally

    // [15 5 - 15 6]
    IL_0037: ret

  } // end of method CollectionWriter::WriteToConsole
} // end of class CollectionWriter

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

Пометка класса

.class public abstract sealed auto ansi beforefieldinit
  CollectionWriter
    extends [System.Runtime]System.Object
{
  .custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor()
    = (01 00 00 00 ) // АТРИБУТ

Пометка метода

.method public hidebysig static void
    WriteToConsole(
      class [System.Runtime]System.Collections.IEnumerable collection
    ) cil managed
  {
    .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor([in] unsigned int8)
      = (01 00 01 00 00 ) // .....
      // unsigned int8(1) // 0x01
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor()
      = (01 00 00 00 ) // АТРИБУТ

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

Перейдем теперь к строчке вызова метода. Он используется как обычный метод статичного класса, который запрашивает в аргументе ссылку на объект IEnumerable.

// [5 1 - 5 24]
IL_001f: ldloc.0      // 'array'
IL_0020: call         void CollectionWriter::WriteToConsole(class [System.Runtime]System.Collections.IEnumerable)
IL_0025: nop
IL_0026: ret

Из этого можно сделать вывод: если мы используем объект со значением null, то метод расширения выполнится без вызова исключения NullReferenceException (в отличие от экземлярного метода).

Заключение

Методы расширения - удобный механизм, который поможет увеличить читабельность Вашего кода. Они крайне полезны, когда нужно добавить метод к уже реализованному классу или интерфейсу (как, например, делала Microsoft в LINQ или мы с классом IEnumerable).

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


  1. Naf2000
    26.12.2022 07:47
    +4

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

    1. Можно применить шаблон Fluent interface, так как внешне методы неотличимы.

    2. Для значений null у this-аргумента (первого) метод спокойно вызовется без исключений, т.к. природа его статична. Поэтому скорее всего потребуется проверка этого значения на null.


    1. DenVot Автор
      26.12.2022 12:46

      Спасибо. Действительно, важные следствия. Чуть позже дополню статью)


  1. a-tk
    26.12.2022 08:17
    +10

    Эта статья должна иметь метку не .net 6, а .net fx 3.5. И выйти на 15 лет раньше.

    Единственное существенное изменение, которое произошло за эти 15 лет - это то, что утиный GetEnumerator теперь можно иметь методом-расширением, но и это произошло не в .net6/C#10, а в C#9/.net5


  1. vconst
    26.12.2022 09:30
    +1

    Можно ли придумать еще более отвратительно-вырвиглазную кдпв?


    1. DenVot Автор
      26.12.2022 12:51

      Действительно, с картинкой я перемудрил из-за недостатка опыта в ремесле написания статей. Обязательно учту это в следующий раз


      1. vconst
        26.12.2022 14:22
        +1

        А можно ее прям щяс поменять?


        1. DenVot Автор
          26.12.2022 17:59

          В принципе да


        1. DenVot Автор
          26.12.2022 23:24

          Поменял. Надеюсь, угодил)


  1. avmartynov
    26.12.2022 09:52
    -2

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

    Для обеспечения этого поведения, обычно (на самом деле всегда) this-аргумент
    проверяют на null и в случае необходимости генерят ArgumentNullException.

    ИМХО:
    Конечно, есть любители, пишущие методы расширения, допускающие вызов для нуловой ссылки. Код с использованием таких методов выглядит ужасно (напрягает,
    противоречит привычкам, обманывает).


    1. Karnah
      26.12.2022 10:03
      +3

      обычно (на самом деле всегда)

      Немного позанудствую, но вот исключения из правил:
      - Валидация. Например, Fluent Assertions с конструкцией вида x.Should().BeNull/NotBeNull().
      - Методы, которые явно подчеркивают, что принимают null: string.IsNullOrEmpty(s) -> s.IsNullOrEmpty().

      Также с появлением nullable reference, можно в методе-расширении явно указать, ожидается ли там null или нет. Это очень удобно


      1. avmartynov
        26.12.2022 10:13

        Когда я вижу, что для переменной, которая может содержать null, безусловно вызывается какой-нибудь метод, в моей голове включается сирена. Приходится разбираться, что это за метод (настоящий или расширение), лезть в код метода расширения, чтобы убедиться, что всё будет в порядке. Только тогда сирена выключается...


      1. avmartynov
        26.12.2022 10:35

        IsNullOrEmpty(this string.. - постоянно вижу во многих проектах такой самописный метод расширения.

        А вы не задумывались, почему разработчики Miсrosoft за столько лет не написали такой метод расширения - IsNullOrEmpty? или подобный... "Ума им не хватило" что ли?

        Нет. Не написали, потому что это было бы неправильно. И вы не пишите.


        1. Karnah
          26.12.2022 11:05
          +3

          Во-первых, в VS точно можно было отдельным цветом выделять методы-расширения. Если у Вас часто встречается такая проблема - это Вам может помочь.

          Во-вторых, также всё-таки советую начать подключать nullable reference (если это возможно в проекте, конечно), чтобы процесс nullable/не-nullable был более контролируемым и очевидным.

          Ну и в-третьих, последнее сообщение - откровенная грубость. При чём с аргументацией в стиле "не думай, что ты умнее разработчиков из Microsoft". Это прямо фу-фу-фу, не надо так.


        1. Tangeman
          26.12.2022 20:01
          +2

          В чём принципиальное отличие метода расширения IsNullOrEmpty(this string ...) от написанного самим Microsoft статического метода String.IsNullOrEmpty(string ...)?

          Будет это вызвано как s.IsNullOrEmpty() или string.IsNullOrEmpty(s) совершенно непринципиально и работает одинаково (причём так и было задумано), тем более в данном конкректном случае, благодаря правильно выбранному имени, очевидно что именно произойдёт.

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

          Мне лично как раз больше нравится с точки зрения восприятия именно s.IsNullOrEmpty(), не говоря уже о том что это ощутимо короче и приятней на вид чем String.IsNullOrEmpty(s), а компилятору вообще всё равно.

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


          1. a-tk
            26.12.2022 22:06

            И до unified function call остаётся один шаг.


    1. Evengard
      26.12.2022 10:10

      Честно говоря, в моём коде есть метод расширения (для linq-а) который специально устроен так, что принимает null. Называется он NeverNull и конвертирует null в пустой массив - для единообразия linq-обработки.

      Это особенно полезно например когда у тебя есть какие-то navigational параметры (EF Core), но ты заранее не знаешь подтянули они что-то или там так null и остался, да ещё если и по цепочке, можно просто их опрашивать как param1?.param2?.param3?.NeverNull(). Чем городить кучу проверок на null, просто один удобный метод расширения.


      1. avmartynov
        26.12.2022 10:46
        +1

        Для конвертации нула во что либо хорошо подходит оператор ?? (операция дефолтного значения). Очень ясно и стандартно выражает мысль: "если переменная случится нуловая, надо заменить её значение на вот такое значение". Это стандартное средство языка, любой программист поймёт такой код однозначно и без проблем.


        1. Evengard
          26.12.2022 11:04
          +1

          ?? хорош, кто ж спорит, но в данном конкретном кейсе он слишком тяжеловесен. Писать каждый раз ?? Array.Empty<тип>() куда многословней чем .NeverNull(), который за тебя ещё и тип выведет как надо.


          1. avmartynov
            26.12.2022 11:31

            Да. Многословнее. А главное придётся явно указать тип. В Вашем варианте этого удаётся избежать.

            Надеюсь, что когда-нибудь нам позволят писать просто new[0], без указания типа (как это уже сейчас можно делать в некоторых других случаях).


  1. ksbes
    26.12.2022 11:35
    +4

    Меня, конечно попинают, но выскажусь.

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

    Т.е. я реально работал с одним человеком, которому чем-то не угодил LINQ и он написал в проекте свои методы расширения Where, Count и т.д. к Enumerable. И сколько я словил неприятных часов только из-за того что линтер не тот using подставлял!
    И в будете правы, когда скажите, что не надо писть свой линкью (и тем более использовать его параллельно с оргинальным!). Но это уже крайний случай, который подчёркивает общую тенденцию — расширения создают неочевидные зависимости между проектами, модулями (файлами). Раздувают и запутывают раздел импорта, т.к. вместо импорта одного класса, приходится импортировать класс и все его расширения — почти во всех файлах проекта! И это я ещё про рефлексию не говорю (и упомянутые выше методы над null)

    Так что без особой необходимости лучше их не использовать. В подавляющем большинстве случаев, что я видел, расширения надо было заменять либо partial классами, либо обычными статическими методами класса, как микрософтовсий IsNullOrEmpty

    А необходимость в расширениях реально есть вразве что в фреймворках со сложной структурой, когда возникает ситуация, что вот в этом проекте/классе сложный и запутанный функционал А нужен, а также запутанный и сложный функционал Б — не нужен и будет только мешаться. Ну примерно как тот же LINQ — его не редко и не нужно подключать, но если нужен — легко подключается. И расширения тут позволяют избежать лишних уровней абстракций классов.
    Но писать расширения просто чтобы строки парсить — это, я считаю, методологически некорректно.


    1. avmartynov
      26.12.2022 13:57

      https://cdnpdf.com/embed/8017-infrastruktura-programmnyh-proektov-soglasheniya

      5.6 Методы расширения (стр. 176)

      Много интересного на эту тему.