>
>
>
Generic Math: суперфича C#, доступная в…

Гость
Статей: 23

Generic Math: суперфича C#, доступная в .NET 6 Preview 7

10 августа 2021 года Microsoft в блоге опубликовала информацию о свежевыпущенном .NET 6 Preview 7.

Мы опубликовали и перевели эту статью с разрешения правообладателя. Автор статьи – DistortNeo. Оригинал опубликован на сайте Habr.

[Ссылка на статью Microsoft Announcing .NET 6 Preview 7.]

Помимо добавления очередной порции синтаксического сахара, расширения функционала библиотек, улучшения поддержки UTF-8 и т. д., в данное обновление была включена демонстрация суперфичи — абстрактные статические методы интерфейсов и реализованная на её основе возможность использования арифметических операторов в дженериках:

T Add<T>(T lhs, T rhs)
    where T : INumber<T>
{
    return lhs + rhs;
}

Введение

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

Например, в LINQ to objects функции .Max, .Sum, .Average и т.д. реализованы отдельно для каждого из простых типов, а для пользовательских типов предлагается передавать делегат. Это и неудобно, и неэффективно: при многократном дублировании кода есть возможность ошибиться, а вызов делегата не даётся бесплатно (впрочем, уже идут обсуждения о реализации zero-cost делегатов в JIT-компиляторе).

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

// Interface specifies static properties and operators
interface IAddable<T> where T : IAddable<T>
{
    static abstract T Zero { get; }
    static abstract T operator +(T t1, T t2);
}
// Classes and structs (including built-ins) can implement interface
struct Int32 : ..., IAddable<Int32>
{
    static Int32 I.operator +(Int32 x, Int32 y) => x + y; // Explicit
    public static int Zero => 0;                          // Implicit
}
// Generic algorithms can use static members on T
public static T AddAll<T>(T[] ts) where T : IAddable<T>
{
    T result = T.Zero;                   // Call static operator
    foreach (T t in ts) { result += t; } // Use `+`
    return result;
}
// Generic method can be applied to built-in and user-defined types
int sixtyThree = AddAll(new [] { 1, 2, 4, 8, 16, 32 });

Реализация

Синтаксис

Статические члены, которые являются частью контракта интерфейса, объявляются с использованием ключевых слов static и abstract.

Хотя слово static было бы идеально для описания подобных методов, в одном из недавних обновлений была добавлена возможность объявлять вспомогательные статические методы в интерфейсах. Поэтому, чтобы отличать вспомогательные методы от статических членов контракта, было решено использовать модификатор abstract.

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

Вызвать статические методы интерфейса можно только через обобщённый тип и только если на тип наложено соответствующее ограничение:

public static T AddAll<T>(T[] ts) where T : IAddable<T>
{
    T result = T.Zero;            // Correct
    T result2 = IAddable<T>.Zero; // Incorrect
}

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

interface IStatic
{
    static abstract int StaticValue { get; }
    int Value { get; }
}
class Impl1 : IStatic
{
    public static int StaticValue => 1;
    public int Value => 1;
}
class Impl2 : Impl1, IStatic
{
    public static int StaticValue => 2;
    public int Value => 2;
}
static void Print<T>(T obj)
    where T : IStatic
{
    Console.WriteLine("{0}, {1}", T.StaticValue, obj.Value);
}
static void Test()
{
    Impl1 obj1 = new Impl1();
    Impl2 obj2 = new Impl2();
    Impl1 obj3 = obj2;
    Print(obj1);    // 1, 1
    Print(obj2);    // 2, 2
    Print(obj3);    // 1, 2
}

Вызов статического метода интерфейса определяется на этапе компиляции (на самом деле, JIT-компиляции, а не сборки C# кода). Таким образом, можно утверждать: ура, в C# завезли статический полиморфизм!

Под капотом

Посмотрим на сгенерированный IL-код для простейшей функции, суммирующей два числа:

.method private hidebysig static !!0/*T*/
  Sum<(class [System.Runtime]System.INumber`1<!!0/*T*/>) T>(
    !!0/*T*/ lhs,
    !!0/*T*/ rhs
  ) cil managed
{
  .maxstack 8
  // [4903 17 - 4903 34]
  IL_0000: ldarg.0      // lhs
  IL_0001: ldarg.1      // rhs
  IL_0002: constrained. !!0/*T*/
  IL_0008: call !2/*T*/ class ....::op_Addition(!0/*T*/, !1/*T*/)
  IL_000d: ret
} // end of method GenericMathTest::Sum

Ничего примечательного: просто невиртуальный вызов статического метода интерфейса для типа T (для виртуальных вызовов используется callvirt). Оно и понятно: как можно сделать виртуальный вызов без объекта?

Поначалу у меня была мысль, что это сахар, сделанный через какие-нибудь магические объекты, создаваемые в единственном экземпляре для каждой пары тип-интерфейс, но нет: это честная реализация новой фичи на уровне JIT-компилятора: для простых типов компилятор генерирует инструкцию соответствующей операции, для остальных типов — вызывает соответствующий метод. Из этого можно сделать вывод, что код, использующий новые возможности, не сможет работать на более старых рантаймах.

Также стоить ожидать, что JIT-компилятор будет компилировать метод для каждой комбинации обобщённых типов, для которых вызываются статические методы интерфейсов. То есть производительность обобщённых методов, вызывающих статические методы интерфейсов, не должна отличаться от производительности частных реализаций.

Статус

Несмотря на то, что есть возможность пощупать эту возможность уже сейчас, она запланирована к релизу только в .NET 7, а после релиза .NET 6 останется в состоянии preview. Сейчас эта фича находится в состоянии активной разработки, детали её реализации могут измениться, поэтому просто брать и использовать её пока нельзя.

Попробовать на практике

Чтобы поиграться с новой возможностью, нужно добавить свойство EnablePreviewFeatures=true в файл проекта и подключить NuGet пакет System.Runtime.Experimental:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <EnablePreviewFeatures>true</EnablePreviewFeatures>
    <LangVersion>preview</LangVersion>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="System.Runtime.Experimental"
       Version="6.0.0-preview.7.21377.19" />
  </ItemGroup>
</Project>

Само собой, должен быть установлен .NET 6 Preview 7 SDK и в качестве целевой платформы указано net6.0.

Мои впечатления

Попробовал — очень понравилось. То, что я давно ждал, потому что раньше проблему приходилось решать через костыли, например, так:

interface IOperationProvider<T>
{
    T Sum(T lhs, T rhs)
}
void SomeProcessing<T, TOperation>(...)
    where TOperation : struct, IOperationProvider<T>
{
    T var1 = ...;
    T var2 = ...;
    T sum = default(TOperation).Sum(var1, var2);  // This is zero cost!
}

Альтернатива такому костылю: реализация типом T интерфейса IOperation и вызов var1.Sum(var2). Но в данном случае теряется производительность из-за виртуальных вызовов, да и банально не во все классы можно залезть и добавить интерфейс.

Ещё один положительный момент — производительность. Я немного позапускал бенчмарки: скорость работы обычного кода и кода с generic арифметикой оказалась одинаковой. То есть мои ранее описанные предположения относительно JIT-компиляции кода оказались верны.

А что вот немного расстроило, так это то, что с типами-перечислениями эта фича не работает. Сравнивать их придётся по-прежнему через EqualityComparer<T>.Default.Equals.

Также не понравилось, что приходится использовать слово-костыль abstract. Похоже, сложность C# достигла уже того уровня, что добавление новых фишек без ущерба для старого функционала становится затруднительным, и фактически приходим к тому, что сейчас происходит с C++.