Как стать автором
Обновить
3182.64
RUVDS.com
VDS/VPS-хостинг. Скидка 15% по коду HABR15

Всё про Generic Math в C#

Уровень сложностиСложный
Время на прочтение21 мин
Количество просмотров4.9K


С момента своего релиза в C# 11 и .NET 7 Обобщённая Математика так и осталась тёмной лошадкой в глазах программистов. Разработчики не понимают и не используют эту фичу, статья же ответит на все вопросы и разложит всё по полочкам.

Рассмотрим с нуля концепцию Generic Math. Как она выглядит в C# и других языках программирования, почему вообще появилась. Также зароемся в «кишки» System.Numerics и узнаем, как применить в продакшне кровавого ынтэрпрайза.

Что случилось?


Вернёмся ненадолго в прошлое.

8 ноября 2022 года релизится .NET 7, привнося одно из самых важных и эпохальных обновлений языка программирования C#.

Спустя время, я проводил опрос в своём Telegram-канале, который показал, лично для меня, достаточно печальные результаты.



Оказалось, что большинство разработчиков не использует Обобщённую Математику и не понимает, зачем нужен данный инструмент.

Соответственно, цель моей статьи — рассказать вам максимально подробно всю информацию об этой фиче, которую мне удалось собрать с открытых ресурсов Microsoft.

Дальше будет много интересного!

Давняя боль .NET разработчиков


Ещё Кириллом Мауриным было сказано в 2020 году, насколько печально программирование на C#, поскольку нельзя взять и написать простейший обобщённый сумматор. Ведь, если мы попытаемся запустить подобный код:

public static T Sum<T>(this T[] array, T initial)
{
    var result = initial;
    for (var i = 0; i < array.Length; i++)
        result += array[i];
    return result;
}

То неизбежно свалимся с ошибкой компиляции CS0019 «Operator '+=' cannot be applied to operands of type 'T' and 'T'». Почему, понятно: тип T — максимально загадочная штука для компилятора, а перегрузка оператора — и вовсе статический член типа.

Интуиция подсказывает, что нужно подсунуть некоторый constraint для обогащения информации о формальном типовом параметре:

public static T Sum<T>(this T[] array, T initial)
    where T : +
{
    var result = initial;
    for (var i = 0; i < array.Length; i++)
        result += array[i];
    return result;
}

Теперь у нас ошибка компиляции CS1031 «Type expected», потому что в ограничениях надо указывать тип, а не случайные символы. И получается, что инструмента указания наличия оператора тоже нет.

Обобщённая математика


Что мы получили? Коротко — расширение языка и стандартной библиотеки.

Первый рабочий черновик представили команде Microsoft на Language Design Meeting 29 июня 2020 года. Тогда инженеры Мигель Де Иказа и Аарон Буковер презентовали результат годовой работы. Ознакомиться с протоколами можно по ссылке.

Чуть позже это переросло в полноценный Proposal, который обсуждали шарписты. В целом восприятие было позитивным. Также давали интересные возражения, боялись усложнения по типу C++ и не понимали применения в продакшне.

Вся дискуссия велась в issue по ссылке, рекомендую ознакомиться.

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

interface IAddable<T> where T : IAddable<T>
{
    static abstract T Zero { get; }
    static abstract T operator +(T t1, T t2);
}

struct Int32 : IAddable<Int32>
{
    static int operator +(int x, int y) => x + y;
    public static int Zero => 0;
}

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

System.Numerics:

public static T Sum<T>(this T[] array) where T :
    IAdditiveIdentity<T, T>,
    IAdditionOperators<T, T, T>
{
    var result = T.AdditiveIdentity;
    for (var i = 0; i < array.Length; i++)
        result += array[i];
    return result;
}

Здесь стоит обратить внимание на две вещи.

Во-первых, вместо одного контракта в ограничении дженерика мы указываем целых два.
О причинах подобного поговорим позже.

Во-вторых, и для реализации функционала, и для объявления типов активно используется паттерн CRTP.

▍ .NET Numerics


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

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

Это правда безобразно большая картинка для самостоятельного локального изучения:



Так вот, если мы заглянем внутрь какого-нибудь стандартного типа данных, например, int он же Int32, и покликаем наверх, то увидим, что появился некий INumberBase. Этот интерфейс описывает абстрактное число, то есть, объект над которым можно производить абстрактные числовые операции. На удивление эти операции вынесены в отдельные интерфейсы, основные показал под спойлером ниже.
Основные операции
  • IAdditionOperators.cs — сложение
    public interface IAdditionOperators<TSelf, TOther, TResult>
        where TSelf : IAdditionOperators<TSelf, TOther, TResult>?
    {
      static abstract TResult operator +(TSelf left, TOther right);
      static virtual TResult operator checked +(TSelf left, TOther right) => left + right;
    }
    

  • IAdditiveIdentity.cs — 0 по сложению
    public interface IAdditiveIdentity<TSelf, TResult>
        where TSelf : IAdditiveIdentity<TSelf, TResult>?
    {
      static abstract TResult AdditiveIdentity { get; }
    }
    

  • IUnaryNegationOperators.cs — унарный минус
    public interface IUnaryNegationOperators<TSelf, TResult>
        where TSelf : IUnaryNegationOperators<TSelf, TResult>?
    {
      static abstract TResult operator -(TSelf value);
      static virtual TResult operator checked -(TSelf value) => -value;
    }
    

  • ISubstractionOperators.cs — вычитание
    public interface ISubtractionOperators<TSelf, TOther, TResult>
        where TSelf : ISubtractionOperators<TSelf, TOther, TResult>?
    {
      static abstract TResult operator -(TSelf left, TOther right);
      static virtual TResult operator checked -(TSelf left, TOther right) => left - right;
    }
    

  • IMultiplyOperators.cs — умножение
    public interface IMultiplyOperators<TSelf, TOther, TResult>
        where TSelf : IMultiplyOperators<TSelf, TOther, TResult>?
    {
      static abstract TResult operator *(TSelf left, TOther right);
      static virtual TResult operator checked *(TSelf left, TOther right) => left * right;
    }
    

  • IMultiplicativeIdentity.cs — 1 по умножению
    public interface IMultiplicativeIdentity<TSelf, TResult>
        where TSelf : IMultiplicativeIdentity<TSelf, TResult>?
    {
        static abstract TResult MultiplicativeIdentity { get; }
    }
    



.NET Numerics появился в рамках расширения BCL через серию пулл-реквестов в рантайм дотнета: ссылка.



Один из самых важных и первых PR'ов доступен по указанной ссылке. Там был выполнен ряд задач:

  • Создание «численных» контрактов. Благодаря созданным интерфейсам появились абстракции операций, как было показано выше.
  • Внедрение контрактов через реализацию созданных интерфейсов стандартными типами данных. Если какой-то тип можно было складывать через "+", например DateTime, то теперь он реализует нужный интерфейс.
  • Поддержка доработки в рантайме (System.Runtime.cs)

Обратите внимание, как мало в pull request'е удалённых строк — всего 27.



▍ Как всё это работает?


Каких-либо существенных доработок в back-end компилятора не производилось.

В Intermediate Language убрали запрет на совместное использование ключевых слов static abstract. Сама платформа была готова к внедрению фичи, как минимум со времён C# 8, поскольку тогда появились реализации по умолчанию в интерфейсах.

При этом, во время генерации IL-кода при обращении к абстрактному статическому члену генерируется constrained. call последовательность.

public static T Sum<T>(this T[] array) where T :
    IAdditiveIdentity<T, T>,
    IAdditionOperators<T, T, T>
{
    var result = T.AdditiveIdentity;
    for (var i = 0; i < array.Length; i++)
        result += array[i];
    return result;
}



Где применять, кроме математики


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

▍ IParsable


public interface IParsable<TSelf>
    where TSelf : IParsable<TSelf>?
{
  static abstract TSelf Parse(string s, IFormatProvider? provider);

  static abstract bool TryParse(
    [NotNullWhen(true)] string? s,
    IFormatProvider? provider,
    [MaybeNullWhen(false)] out TSelf result);
}

По своей сути это узаконенный Value Object на уровне API — наибольшую пользу интерфейс показывает в контексте ASP NET Core. Например, у нас есть некий объект диапазона дат, который мы хотим получать в параметре методов из строки переданной в параметре запроса.

public class DateRange
{
    public DateOnly? From { get; init; }
    public DateOnly? To { get; init; }
}

Раньше даже из официальной документации MSDN не совсем было понятно, каков рекомендованный способ преобразования строки в объект: то ли TypeConverter, то ли IModelBinder, то ли что-то ещё. Тем не менее, любое решение выглядело достаточно громоздко, а где-то инвазивно. Например, под спойлером вы увидите мою реализацию задачи через IModelBinder.
ASP.NET Core old model binding
Создаём реализацию привязчика модели:

internal class DateRangeModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        ArgumentNullException.ThrowIfNull(bindingContext);
        var fieldName = bindingContext.FieldName;
        var valueProviderResult = bindingContext.ValueProvider.GetValue(fieldName);
        if (valueProviderResult == ValueProviderResult.None) return Task.CompletedTask;
        bindingContext.ModelState.SetModelValue(fieldName, valueProviderResult);
        var value = valueProviderResult.FirstValue;
        if (string.IsNullOrEmpty(value))
        {
            bindingContext.Result = ModelBindingResult.Failed();
            return Task.CompletedTask;
        }
        var segments = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        var dtfi = new DateTimeFormatInfo
        {
            DateSeparator = "/"
        };
        if (segments.Length == 2 && DateOnly.TryParse(segments[0], dtfi, out
                var fromDate) && DateOnly.TryParse(segments[1], dtfi, out
                var toDate))
        {
            
            var dateRange = new DateRange
            {
                From = fromDate, To = toDate
            };
            bindingContext.Result = ModelBindingResult.Success(dateRange);
            return Task.CompletedTask;
        }
        bindingContext.Result = ModelBindingResult.Failed();
        return Task.CompletedTask;
    }
}

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

// GET /WeatherForecast/ByRange?range=7/24/2022,07/26/2022
public IActionResult ByRange(
    [ModelBinder<DateRangeModelBinder>] DateRange range)
{
    // ...
}


С приходом обобщённой математики ситуация изменилась — теперь нам достаточно один раз реализовать интерфейс IParsable и наслаждаться беззаботной жизнью смузихлёба маковода.

public class DateRange : IParsable<DateRange>
{
    public DateOnly? From { get; init; }
    public DateOnly? To { get; init; }

    public static DateRange Parse(string value, IFormatProvider? provider)
    {
        if (!TryParse(value, provider, out var result))
        {
            throw new ArgumentException("Could not parse supplied value.", nameof(value));
        }

        return result;
    }

    public static bool TryParse(string? value,
        IFormatProvider? provider, out DateRange dateRange)
    {
        var segments = value?.Split(
            ',',
            StringSplitOptions.RemoveEmptyEntries |
            StringSplitOptions.TrimEntries);

        if (segments?.Length == 2
            && DateOnly.TryParse(segments[0], provider, out var fromDate)
            && DateOnly.TryParse(segments[1], provider, out var toDate))
        {
            dateRange = new DateRange { From = fromDate, To = toDate };
            return true;
        }

        dateRange = new DateRange { From = default, To = default };
        return false;
    }
}

Интеграция в API-методы выглядит следующим образом:

// GET /WeatherForecast/ByRange?range=7/24/2022,07/26/2022
public IActionResult ByRange([FromQuery] DateRange range)
{
    // ...
}

▍ Проектируем cache-like хранилище


Допустим, что перед нами стоит задача спроектировать контракт cache-like хранилище, где ключ зависит от типа хранимого элемента. Давайте рассмотрим имеющиеся варианты решения до .NET 7 и их недостатки.

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

    interface IResource
    {
        string CacheKey { get; }
    }
    
  • Можно разработать специальный атрибут, которым будут размечаться типы объектов, подлежащих помещению в хранилище. Однако в таком случае мы теряем в производительности при использовании рефлексии для получения метаданных.

    [CacheKey(nameof(MyResource) + "suffix")]
    record MyResource;
    
    class CacheKeyAttribute(string value) : Attribute
    {
        public string Value { get; } = value;
    }
    
  • Можно попробовать вынести логику в некоторый сервис, но тогда не ясно, как её распространять и предоставлять.

    abstract class KeyProvider
    {
        public abstract string Key { get; }
    }
    
    abstract class Resource<TKeyProvider>
        where TKeyProvider : KeyProvider, new()
    {
        public string CacheKey =>
            new TKeyProvider().Key;
    }
    
  • Можно явно отобразить типы на ключи, но тогда нужно регулярно поддерживать конфиг в соответствии с кодовой базой, и теряется какая-либо типобезопасность.

    class Cache(IReadOnlyDictionary<Type, string> resourceTypeToKeyMap);
    

С наличием же Generic Math всё решается достаточно просто — заводится контракт и расширяется информацией, которая доступна для всех экземпляров на уровне типа.

interface IResource
{
    //...
    static abstract string CacheKey { get; }
}

class Cache
{
    public TResource Get<TResource>() where TResource : IResource
    {
        string cacheKey = TResource.CacheKey;
        // ...
        return //...;
    }
    
    public void Set<TResource>(TResource resource) where TResource : IResource
    {
        string cacheKey = TResource.CacheKey;
        // ...
    }
}

▍ Новый класс паттернов


Теперь возможно придумать новый подход к реализации паттернов для ситуаций, когда для написания алгоритма требуется информация на уровне типа, но нет доступа к экземпляру объекта. Это позволит типобезопасно писать более обобщающий код.

public interface IAsyncFactory<T>
{
    static abstract Task<T> CreateAsync();
}

public interface IExampleStrategy
{
    static abstract bool IsEnabled(string foo);
    void DoStuff(string foo);
}

▍ Интеграция в обобщённые атрибуты


Как было показано мной в статье про unit-тесты, возможность прописывать статические члены в абстрактных контрактах позволяет расширять поведение атрибутов, вызывая логику в статическом контексте конструктора.

public interface IFixtureCustomizer
{
    static abstract void Customize(IFixture fixture);
}

public class AutoDataAttribute<TFixtureCustomizer>() :
    AutoDataAttribute(
        fixtureFactory: () =>
        {
            var fixture = new Fixture();
            TFixtureCustomizer.Customize(fixture);
            return fixture;
        }) where TFixtureCustomizer : IFixtureCustomizer;

▍ Новый DDD приём


Можно переосмыслить подход, который мы применяем к проектированию сущностей (entities) в кодовой базе C# приложений, использующих Domain Driven Design. Мне часто доводилось сталкиваться с практикой, когда любое действие над сущностью провоцирует выброс некоторого доменного события (domain event), которое можно обработать.

interface IDomainEvent;

interface IEntityCreated<T> : IDomainEvent
{
    T Entity { get; }
}

record CustomerCreated(Customer Entity) : IEntityCreated<Customer>;

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

record CustomerCreateDto(string Name, DateTimeOffset DateOfBirth);

class Customer(Guid id, string name, DateTimeOffset? dateOfBirth)
{
    public static IEntityCreated<Customer> Create(CustomerCreateDto createDto) =>
        new CustomerCreated(
            new Customer(
                id: Guid.NewGuid(),
                createDto.Name,
                createDto.DateOfBirth));
}

Теперь можно обобщить процесс создания Customer, и вообще любой другой сущности, говоря на уровне типизации, какие сценарии создания объекта содержит домен. Тот самый единый язык!

interface IFactory<TEntity, TEntityCreateDto>
    where TEntity : class, IFactory<TEntity, TEntityCreateDto>
    where TEntityCreateDto : class
{
    static abstract IEntityCreated<TEntity> Create(
        TEntityCreateDto createDto);
}

Например, мы видим, что дата рождения у Customer необязательная, и поэтому можем указать два пользовательских сценария создания — с датой и без.

record CustomerCreateDto(string Name, DateTimeOffset DateOfBirth);

record CustomerCreateWithoutDateOfBirthDto(string Name);

class Customer(Guid id, string name, DateTimeOffset? dateOfBirth) :
    IFactory<Customer, CustomerCreateDto>,
    IFactory<Customer, CustomerCreateWithoutDateOfBirthDto>
{
    public static IEntityCreated<Customer> Create(
        CustomerCreateDto createDto) =>
        new CustomerCreated(
            new Customer(
                id: Guid.NewGuid(),
                createDto.Name,
                createDto.DateOfBirth));

    public static IEntityCreated<Customer> Create(
        CustomerCreateWithoutDateOfBirthDto createDto) =>
        new CustomerCreated(
            new Customer(
                id: Guid.NewGuid(),
                createDto.Name,
                dateOfBirth: null));
}

▍ Выделение контрактов сгенерированных моделей таблиц БД


Допустим, на вашем проекте используется Database-First подход. Тогда по имеющейся структуре некоторым образом генерируются файлы C# классов. Предположим, существует некоторая сущность «документ», которую представляет таблица documents и соответствующий сгенерированный класс Document.

partial class Document
{
    public required string Name { get; set; }

    // ...

    public static int NameLengthConstraint { get; }
}

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

interface IDocument
{
    // ...
    public string Name { get; set; }
}

partial class Document : IDocument;

Однако, здесь кроется подвох. Помимо столбцов генерируются статические поля с информацией об ограничениях, которые в интерфейс не добавишь. Например, максимальная длина строки. И благодаря static abstract есть решение!

interface IDocumentConstraints<TDocument>
    where TDocument : IDocumentConstraints<TDocument>
{
    static abstract int NameLengthConstraint { get; }
}

partial class Document :
    IDocument,
    IDocumentConstraints<Document>;

▍ Архитектурная граница слоёв


Имеется проект, построенный по принципам DDD и Чистой Архитектуры, где поддомены делятся по проектам.



Есть доменная сущность, которая располагается в проекте HydraScript.Domain.FrontEnd. Её реализация нуждается в регулярном выражении, которое генерируется Source Generator'ом в проекте HydraScript.Infrastructure.LexeRegexGenerator. Каким образом создать реализацию? Благодаря обобщённой математике можно использовать те же тактические паттерны классического DDD, но в новой канве. Для начала создаём контейнер с регулярным выражением, затем через static abstract подсовываем его в реализацию доменной сущности, регистрируя всё в DI.

public interface IStructure : IEnumerable<TokenType>
{
    public Regex Regex { get; }

    public TokenType FindByTag(string tag);
}

public interface IGeneratedRegexContainer
{
    public static abstract Regex Regex { get; }
}

public class Structure<TContainer>(ITokenTypesProvider provider) : IStructure
    where TContainer : IGeneratedRegexContainer
{
    // ...
    public Regex Regex { get; } = TContainer.Regex;
}

// ...

services.AddSingleton<IStructure, Structure<GeneratedRegexContainer>>();

Немного про ML.NET


ML.NET – это инструмент, который привносит машинное обучение в .NET приложения как в online, так и offline сценариях. Центральным понятием для ML.NET является модель машинного обучения. Такой black box, который после обучения умеет давать «выход» на определённый «вход».

Вместе с ML.NET можно обучать собственную модели или импортировать предобученные модели в ONNX формате. Это такой общепринятый JSON в мире нейросетей. Однако, реальность такова, что в большинстве случаев на дотнете не обучают никаких моделей. Это связано с бедностью инструментария и недостатком реализаций алгоритмов.

Выступление Дмитрия Сошникова, где раскрываются проблемы ML.NET


После прихода Generic Math был проведён масштабный рефакторинг исходного кода фреймворка, который приоткрыл для .NET дверь в мир Deep Learning (ссылка).

Спаны, тензоры и SIMD


Вопрос AI решается также с другой стороны через развитие NuGet пакета System.Numerics.Tensors, который предоставляет инструментарий для тензорных вычислений. Однако это стало ясно не сразу.

В .NET 9 появился тип данных Tensor для работы с тензорами, и был расширен класс TensorPrimitives, содержащий (теперь обобщённые) операции над векторными типами данных в обёртке Span'ов.

Эти методы разработаны так, чтобы использовать SIMD ускорения CPU в зависимости от предоставляемых возможностей (AVX, SSE и так далее).

public class ManhattanDistance<T> : IDistanceCalculator<T>
  where T : unmanaged, INumberBase<T>
{
  public double ComputeDistance(T[] attributesOne, T[] attributesTwo)
  {
    Span<T> diff = stackalloc T[Math.Min(attributesOne.Length, attributesTwo.Length)];
    TensorPrimitives.Subtract(attributesOne, attributesTwo, diff);
    var l1Norm = TensorPrimitives.SumOfMagnitudes<T>(diff);
    return double.CreateTruncating(l1Norm);
  }
}

Получается некоторая долголетняя многоходовочка из Generic Math, Span, stackalloc и многого другого, которая убивает несколько зайцев сразу:

  • Адепты ООП получают новый мощный архитектурный инструмент, решающий проблемы костылей из синглотонов и жалких попыток симуляции ad-hoc полиморфизма.
  • Microsoft приближается ещё на один шаг к обретению конкурентоспособности на рынке ML-разработки, добиваясь возможности разработки глубоких моделей, чтобы нейросети тренировали не на Python, а на C#.
  • Любителям Производительности закрывают вопрос ускорения вычислений на Span, который не оптимизируется JIT'ом так, как массив или список.

Плюсы


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

Во-вторых, появился мощный инструмент для продвинутого проектирования, который возможно даже обогнал своё время.

В-третьих, как мы увидим дальше в разговоре про альтернативы, нет никакой просадки в перфомансе от использования static abstract членов в C# коде.

Минусы


Во-первых, использовать Generic Math можно только в рамках типа «полного цикла» — свой контракт, своя реализация. Натянуть обобщение на внешний библиотечный код не получится. Например, я захотел описать моноид, объединив два интерфейса в один, и сделал, например, реализацию для строки.

interface IAdditive<TAdditive> :
    IAdditiveIdentity<TAdditive, TAdditive>,
    IAdditionOperators<TAdditive, TAdditive, TAdditive>
    where TAdditive : IAdditive<TAdditive>;

class AdditiveString(string s) : IAdditive<AdditiveString>
{
    private readonly string _string = s;

    public static AdditiveString AdditiveIdentity => new(string.Empty);

    public static AdditiveString operator +(AdditiveString left, AdditiveString right) =>
        new(left._string + right._string);
}

Кажется, что всё хорошо, даже пользовательский тип данных сделали. Но теперь мы можем забыть про встроенные типы данных, хотя казалось, что они удовлетворяют предъявляемым требованиям — наличие + и 0.



Временных решений всего два — писать обёртки и большие constraint'ы на дженерики. Решение, которое могло бы быть, но не случилось — это implicit interface. Путём добавления ключевого слова implicit в объявление интерфейса мы бы заставили код на картинке компилироваться. Придумали в 2015 году, но в 2020-м обсуждение окончательно заглохло и обрело статус Likely Never — github.com/dotnet/csharplang/issues/110

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

abstract class AdditiveBase<TAdditive> : IAdditive<TAdditive>
    where TAdditive : IAdditive<TAdditive>
{
    public abstract static TAdditive AdditiveIdentity { get; }
    public abstract static TAdditive operator +(TAdditive left, TAdditive right);
}

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

Альтернативы


Мы узнали, что такое Generic Math, сценарии применения для промышленного кода и не только, плюсы и минусы фичи. Теперь рассмотрим, каковы альтернативы как с точки зрения других языков программирования, так и с точки зрения других подходов, реализуемых в самом C#. Для начала в рамках небольшого лирического отступления поговорим про…

▍ Три вида полиморфизма


Полиморфизм в программировании имеет не только объектно-ориентированный смысл. На самом деле, видов полиморфизма очень много, и даже больше трёх, однако нам понадобятся только три.

Допустим, перед нами стоит задача реализовать принтер, распечатывающий несколько видов объектов. Её можно решить по-разному, выбирая тот или иной вид полиморфизма.

  1. Полиморфизм подтипов — это то, к чему мы привыкли в смысле ООП. Создаём базовые абстракции, реализация в наследниках.

    interface IPrintable
    {
        string Content { get; }
    }
    
    interface IPrinter
    {
        void Print(IPrintable printable);
    }
    
  2. Параметрический полиморфизм в C# реализован дженериками. Называется так, потому что реализация параметризуется чем-то, в данном случае — типом.

    interface IPrinter<in T>
    {
        void Print(T item);
    }
    
  3. Ad-hoc полиморфизм представлен в C# перегрузкой методов. Нужная реализация выбирается на этапе компиляции.

    interface IPrinter
    {
        void Print(int i);
        
        void Print(string s);
        
        void Print(bool b);
    }
    

Как вы могли заметить, ad-hoc полиморфизм наиболее близок к нашему запросу в реализации обобщённой абстрактной статики, но появляется другая проблема. Как заставить компилятор и создать один универсальный контракт вместо множества перегрузок? Ответ на этот вопрос даёт паттерн…

▍ Классы типов


Он решает проблему за счёт отделения операции от данных. Подход из функционального программирования. И в некоторых ФП языках паттерн является их частью, как, например, в Haskell.

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

class Printer
{
    public void Print<T, TPrintable>(T item)
        where TPrintable : struct, IPrintable<T>
    {
        var content = default(TPrintable).GetContent(item);
        Console.WriteLine(content);
    }
}

interface IPrintable<T>
{
    string GetContent(T item);
}

struct PrintableInt : IPrintable<int>
{
    public string GetContent(int item) =>
        item.ToString();
}

struct PrintableBool : IPrintable<bool>
{
    public string GetContent(bool item) =>
        item ? "true" : "false";
}

var printer = new Printer();
printer.Print<int, PrintableInt>(13);
printer.Print<bool, PrintableBool>(false);

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

interface IAdder<T>
{
    T Zero { get; }

    T Plus(T left, T right);
}

public static T SumTypeClass<T, TAdder>(
    this IReadOnlyList<T> array,
    TAdder adder = default)
    where TAdder : struct, IAdder<T>
{
    var result = adder.Zero;
    var count = array.Count;
    for (var i = 0; i < count; i++)
        result = adder.Plus(result, array[i]);
    return result;
}

int[] array = [1, 2, 3];
var sum = array.SumTypeClass<int, IntAdder>();

struct IntAdder : IAdder<int>
{
    public int Zero => 0;

    public int Plus(int left, int right) =>
        left + right;
}

Вроде выглядит неплохо и эффективно с точки зрения C#, однако мы получили новую проблему — необходимость явного указания формальных типовых параметров при вызове обобщённого метода SumTypeClass. Она возникла, потому что вывод типов C# работает справа налево, из source в destination. Решить можно написанием обёрток расширений с указанием явных типов.

Могли ли быть классы типов в C# когда-то? Безусловно, в 2017-м появился proposal под названием Type Classes For The Masses (ссылка).

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

concept был бы аналогом контракта, который реализовывался бы instance'ом, а проблему указания типовых параметров решили бы вычислением типа в контексте с помощью ключевого слова implicit.

concept Num<A>
{
    A operator +(A a, A b);
    A operator *(A a, A b);
    A operator -(A a, A b);
    implicit operator A(int i);
}

instance NumInt
{
    int operator +(int a, int b) => a + b;
    int operator *(int a, int b) => a * b;
    int operator -(int a, int b) => a – b;
    implicit operator int(int i) => i;
}

public static A F<A, implicit NumA>(A x)
    where NumA : Num<A> =>
    x * x + x + 666;

Однако по разным причинам proposal в статусе Likely Never. Здесь можно только гадать почему. Кто-то убеждён что это исключительно политическое решение, идти ООПшным путём до конца. Кто-то считает, что всё дело в неудачной интерпретации неудачных implicit'ов Scala — вроде бы .NET должен исправлять ошибки JVM, а не повторять их.

trait Comparator[A]:
def compare(x: A, y: A): Int

object Comparator:
given Comparator[Int] with
def compare(x: Int, y: Int): Int = Integer.compare(x, y)

given Comparator[String] with
def compare(x: String, y: String): Int = x.compareTo(y)
end Comparator

def max[A](x: A, y: A)(using comparator: Comparator[A]): A =
  if comparator.compare(x, y) >= 0 then x
  else y

println(max(10, 6))             // 10
println(max("hello", "world"))  // world

Больше про ad-hoc полиморфизм и классы типов можно узнать в следующих источниках:


▍ Альтернатива от Swift


Погрузимся немного в основы iOS разработки, чтобы узнать о другой вариации обобщённой математики за рамками C#. В языке программирования Swift есть свой аналог интерфейсов — протоколы.

protocol Animal {
    var maxBabiesCount: Int { get }
    func getSound() -> String
}

class Cat : Animal {
    let maxBabiesCount: Int = 5
    func getSound() -> String {
        "Meow"
    }
}

Протоколы могут быть параметризированы неким ассоциированным типом с помощью объявления этого типа в теле протокола. Считай аналог обобщённых интерфейсов.

protocol Animal {
    associatedtype BabyType
    var babies: [BabyType] { get set }
}

class Cat : Animal {
    var babies = [Kitten]()
}

class Kitten { }

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

protocol Animal {
    func mate(with: Self)
}

class Cat : Animal {
    func mate(with: Cat) {
        print("mating with another cat")
    }
}

Так вот на уровне языка Swift долгое время был готов к появлению некоторой библиотеки для обобщения арифметики, поскольку там есть Self и static'и в protocol'ах. Так появился proposal Swift Numerics, созданный тем самым Крисом Латтнером, автором LLVM.

public protocol AdditiveArithmetic : Equatable {
     static var zero: Self { get }
    static func + (lhs: Self, rhs: Self) -> Self
}



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

Производительность


А так как, у нас есть с чем сравнивать Generic Math в C#, то напишем бенчмарк, который сопоставит скорость сумматоров и LINQ вызова.



Конфигурация бенчмарка:

  • BenchmarkDotNet v0.13.12
  • macOS Monterey 12.3
  • Apple M1 Pro, 1 CPU, 10 logical and 10 physical cores
  • .NET SDK 8.0.100

Код бенчмарка
struct IntAdder : IAdder<int>
{
    public int Zero => 0;

    public int Plus(int left, int right) =>
        left + right;
}

interface IAdder<T>
{
    T Zero { get; }

    T Plus(T left, T right);
}

static class ArrayExtensions
{
    public static T SumTypeClass<T, TAdder>(
        this IReadOnlyList<T> array,
        TAdder adder = default)
        where TAdder : struct, IAdder<T>
    {
        var result = adder.Zero;
        var count = array.Count;
        for (var i = 0; i < count; i++)
            result = adder.Plus(result, array[i]);
        return result;
    }

    public static T SumGenericMath<T>(
        this IReadOnlyList<T> array) where T :
        IAdditiveIdentity<T, T>,
        IAdditionOperators<T, T, T>
    {
        var result = T.AdditiveIdentity;
        var count = array.Count;
        for (var i = 0; i < count; i++)
            result += array[i];
        return result;
    }
}

[SimpleJob]
[SuppressMessage("ReSharper", "UnassignedField.Global")]
#pragma warning disable CA1050
public class SumBenchmarks
#pragma warning restore CA1050
{
    private IReadOnlyList<int> _dataSet = [];
    private readonly Consumer _consumer = new();

    [Params(100_000)] public int CollectionCount;

    [Params(CollectionType.Array, CollectionType.ImmutableArray)]
    public CollectionType CollectionType;

    [GlobalSetup]
    public void GlobalSetup()
    {
        var enumerable = Enumerable.Range(1, CollectionCount)
            .Select(_ => Random.Shared.Next(1, 10001));
        _dataSet = CollectionType switch
        {
            CollectionType.Array => enumerable.ToArray(),
            CollectionType.ImmutableArray => enumerable.ToImmutableArray(),
            _ => throw new ArgumentOutOfRangeException(nameof(CollectionType))
        };
    }

    [Benchmark(Baseline = true)]
    public void SumLinq()
    {
        var sum = _dataSet.Sum();
        _consumer.Consume(sum);
    }

    [Benchmark]
    public void SumClassicFor()
    {
        var sum = 0;
        for (var i = 0; i < CollectionCount; i++)
            sum += _dataSet[i];
        _consumer.Consume(sum);
    }

    [Benchmark]
    public void SumTypeClass()
    {
        var sum = _dataSet.SumTypeClass<int, IntAdder>();
        _consumer.Consume(sum);
    }

    [Benchmark]
    public void SumGenericMath()
    {
        var sum = _dataSet.SumGenericMath();
        _consumer.Consume(sum);
    }
}

#pragma warning disable CA1050
public enum CollectionType
#pragma warning restore CA1050
{
    Array,
    ImmutableArray
}




Результат оказался достаточно интересным — LINQ обогнал наши сумматоры, поэтому я полез в исходный код, чтобы узнать, в чём дело. Оказалось, что для массивов и списков используются оптимизации с помощью Span и маршаллинга.



Дополнительные материалы


В дополнение к этой статье можно ознакомиться со следующими источниками.

Моя трилогия статей на Хабре про абстрактную алгебру в C#:

  1. Абстрактная алгебра в действии
  2. Властелин структур
  3. Обобщай это, обобщай то

Следующие две книжки:

  1. Кострикин А.И. Введение в алгебру
  2. Мишель Мину, Мишель Гондран. Graphs, Dioids and Semirings: New Models and Algorithms

Пара докладов про моноиды и применение алгебры в Stripe:

  • Life After Monoids

  • Add ALL the things


Ну и, наконец, можно прочитать и переписать на C# библиотеку твиттера для абстрактной алгебры на Scala, Algebird:


Итоги


Я разложил по полочкам и рассказал всё, что вы могли знать про Generic Math. Мы увидели, как язык двигается вперёд и какое множество новых возможностей даётся разработчикам.

Ещё я веду Telegram-канал StepOne, куда выкладываю много интересного контента про коммерческую разработку на C#, даю карьерные советы, рассказываю истории из личного опыта и раскрываю все тайны IT-индустрии.

© 2025 ООО «МТ ФИНАНС»

Telegram-канал со скидками, розыгрышами призов и новостями IT 💻
Теги:
Хабы:
+48
Комментарии10

Публикации

Информация

Сайт
ruvds.com
Дата регистрации
Дата основания
Численность
11–30 человек
Местоположение
Россия
Представитель
ruvds