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

Несмотря на то, что дженерики давно в C#, мне всё же удаётся найти новые интересные способы их применения. Например, в одной из моих предыдущих статей я написал об уловке, позволяющей добиться return type inference, что может облегчить работу с контейнерными union types.

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

После нескольких экспериментов, я нашёл способ решить проблему элегантно, используя подход схожий с паттерном проектирования fluent interface, который был применён не к объектам, а к типам. Мой подход предлагает domain-specific language, который позволяет разработчику построить нужный тип за несколько логических шагов, последовательно его "конфигурируя".

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


Fluent Interfaces

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

Среди прочего, паттерн используется для упрощения операций, требующих большого количества (возможно необязательных) входных данных. Fluent interfaces дают возможность настраивать каждый отдельный аспект в отдельности друг от друга вместо ожидания всех входных параметров сразу.

В качестве примера, рассмотрим следующий код:

var result = RunCommand(
    "git",
    "/my/repository",
    new Dictionary<string, string>
    {
        ["GIT_AUTHOR_NAME"] = "John",
        ["GIT_AUTHOR_EMAIL"] = "john@email.com"
    },
    "pull"
);

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

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

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

Наш пример можно улучшить, используя паттерн fluent interface:

var result = new Command("git")
    .WithArguments("pull")
    .WithWorkingDirectory("/my/repository")
    .WithEnvironmentVariable("GIT_AUTHOR_NAME", "John")
    .WithEnvironmentVariable("GIT_AUTHOR_EMAIL", "john@email.com")
    .Run();

Таким образом разработчик может создать объект класса Command детально контролируя его состояние. Сначала мы указываем имя исполняемого модуля, затем используя доступные методы, свободно конфигурируем другие опции согласно надобности. Результирующее выражение не только стало заметно более читабельным, но и более гибким за счёт отказа от ограничений параметров метода в пользу паттерна fluent interface.

Определение fluent type

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

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

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

Такой тип данных мог бы быть смоделирован следующим образом:

public abstract class Endpoint<TReq, TRes> : EndpointBase
{
    public abstract Task<ActionResult<TRes>> ExecuteAsync(
        TReq request,
        CancellationToken token = default
    );
}

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

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

public class SignInRequest
{
    public string Username { get; init; }
    public string Password { get; init; }
}

public class SignInResponse
{
    public string Token { get; init; }
}

public class SignInEndpoint : Endpoint<SignInRequest, SignInResponse>
{
    [HttpPost("auth/signin")]
    public override async Task<ActionResult<SignInResponse>> ExecuteAsync(
        SignInRequest request,
        CancellationToken token = default)
    {
        var user = await Database.GetUserAsync(request.Username);

        if (!user.CheckPassword(request.Password))
        {
            return Unauthorized();
        }

        return Ok(new SignInResponse
        {
            Token = user.GenerateToken()
        });
    }
}

Компилятор автоматически выводит корректную сигнатуру целевого метода при наследовании типа Endpoint<SignInRequest, SignInResponse>. Очень удобно, когда тебе помогают избегать ошибок и делать структуру приложения более согласованной.

Несмотря на то, что класс SignInEndpoint идеально вписывается в архитектуру, не все эндпоинты обязательно имеют запрос и ответ. Например, можно придумать класс SignUpEndpoint, который не возвращает тело ответа, и класс SignOutEndpoint, не требующий каких-то данных в запросе.

Чтобы приспособить архитектуру к такого рода эндпоинтам, мы можем расширить описание типов, добавив несколько дополнительных обобщённых перегрузок:

public abstract class Endpoint<TReq, TRes> : EndpointBase
{
    public abstract Task<ActionResult<TRes>> ExecuteAsync(
        TReq request,
        CancellationToken cancellationToken = default
    );
}

public abstract class Endpoint<TReq> : EndpointBase
{
    public abstract Task<ActionResult> ExecuteAsync(
        TReq request,
        CancellationToken cancellationToken = default
    );
}

public abstract class Endpoint<TRes> : EndpointBase
{
    public abstract Task<ActionResult<TRes>> ExecuteAsync(
        CancellationToken cancellationToken = default
    );
}

public abstract class Endpoint : EndpointBase
{
    public abstract Task<ActionResult> ExecuteAsync(
        CancellationToken cancellationToken = default
    );
}

Поначалу может показаться, что решение проблемы найдено. Однако, код, приведённый выше, не скомпилируется. Причина тому неоднозначность между Endpoint<TReq> и Endpoint<TRes>, поскольку нет возможности определить означает типовой параметр запрос или ответ.

Ровно как и с методом RunCommand ранее в статье существует несколько прямолинейных способов исправить ошибку, но не очень элегантных. Например, простейшим решением будет переименование типов таким образом, чтобы обозначить их предназначение и избежать коллизий:

public abstract class Endpoint<TReq, TRes> : EndpointBase
{
    public abstract Task<ActionResult<TRes>> ExecuteAsync(
        TReq request,
        CancellationToken cancellationToken = default
    );
}

public abstract class EndpointWithoutResponse<TReq> : EndpointBase
{
    public abstract Task<ActionResult> ExecuteAsync(
        TReq request,
        CancellationToken cancellationToken = default
    );
}

public abstract class EndpointWithoutRequest<TRes> : EndpointBase
{
    public abstract Task<ActionResult<TRes>> ExecuteAsync(
        CancellationToken cancellationToken = default
    );
}

public abstract class Endpoint : EndpointBase
{
    public abstract Task<ActionResult> ExecuteAsync(
        CancellationToken cancellationToken = default
    );
}

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

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

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

public static class Endpoint
{
    public static class WithRequest<TReq>
    {
        public abstract class WithResponse<TRes>
        {
            public abstract Task<ActionResult<TRes>> ExecuteAsync(
                TReq request,
                CancellationToken cancellationToken = default
            );
        }

        public abstract class WithoutResponse
        {
            public abstract Task<ActionResult> ExecuteAsync(
                TReq request,
                CancellationToken cancellationToken = default
            );
        }
    }

    public static class WithoutRequest
    {
        public abstract class WithResponse<TRes>
        {
            public abstract Task<ActionResult<TRes>> ExecuteAsync(
                CancellationToken cancellationToken = default
            );
        }

        public abstract class WithoutResponse
        {
            public abstract Task<ActionResult> ExecuteAsync(
                CancellationToken cancellationToken = default
            );
        }
    }
}

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

В частности, типы содержащиеся внутри дженериков имеют доступ к типовым параметрам объявленным снаружи. Это позволяет расположить WithResponse<TRes> внутри WithRequest<TReq> и использовать оба типа: и TReq, и TRes.

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

Теперь, если пользователь хочет реализовать эндпоинт, он может сделать это следующим образом:

public class MyEndpoint
    : Endpoint.WithRequest<SomeRequest>.WithResponse<SomeResponse> { /* ... */ }

public class MyEndpointWithoutResponse
    : Endpoint.WithRequest<SomeRequest>.WithoutResponse { /* ... */ }

public class MyEndpointWithoutRequest
    : Endpoint.WithoutRequest.WithResponse<SomeResponse> { /* ... */ }

public class MyEndpointWithoutNeither
    : Endpoint.WithoutRequest.WithoutResponse { /* ... */ }

Вот как выглядит новая версия SignInEndpoint:

public class SignInEndpoint : Endpoint
    .WithRequest<SignInRequest>
    .WithResponse<SignInResponse>
{
    [HttpPost("auth/signin")]
    public override async Task<ActionResult<SignInResponse>> ExecuteAsync(
        SignInRequest request,
        CancellationToken cancellationToken = default)
    {
        // ...
    }
}

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

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

// Incomplete signature
// Error: Class Endpoint is sealed
public class MyEndpoint : Endpoint { /* ... */ }

// Incomplete signature
// Error: Class Endpoint.WithRequest<TReq> is sealed
public class MyEndpoint : Endpoint.WithRequest<MyRequest> { /* ... */ }

// Invalid signature
// Error: Class Endpoint.WithoutRequest.WithRequest<T> does not exist
public class MyEndpoint : Endpoint.WithoutRequest.WithRequest<MyRequest> { /* ... */ }

Вывод

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

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

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


  1. Daniil_Palii
    07.07.2022 20:28
    +2

    Элегантно. Имплементация намного проще чем я подумал вначале статьи. Мне бы это очень пригодилось на прошлом проекте с CQRS


    1. qw1
      07.07.2022 20:38
      +5

      Да ни черта не элегантно! fluent потому и fluent, что позволяет гибко управлять составом опций, применять их в произвольном порядке. А тут надо расписать полное дерево вариантов, это ж экспоненциальный взрыв. А если я захочу навесить ещё до 5 опциональных параметров — мне придётся дерево вариантов умножить в 32 раза, к другим опциям, что уже есть.

      А внутренности всех вариантов копипастить, если логика чуть сложнее, чем одной заглушки ExecuteAsync…

      А как эти инстанцированные типы будут выглядеть в отладчике, логах, стек-трейсах? Идейка хитроумная, как головоломка. В прод я бы такое не пустил.


      1. Daniil_Palii
        07.07.2022 20:43
        +2

        Альтернатива в виде отдельных классов ещё хуже. Ну и 5 дженерик параметров - это в любом случае сигнал о архитектурной проблеме


        1. qw1
          07.07.2022 20:46
          +4

          По сути, мы и заставляем разработчика библиотеки написать и вручную(!) поддерживать тысячу классов, но чтобы юзер не запутался, какой класс ему нужен, делаем для него дорожку к нужному классу, предлагая ответить на 10 вопросов.
          Проблема ручной поддержки всей 1000 классов никуда не уходит.


          1. AxeFizik
            07.07.2022 22:00
            +3

            SourceGenerators?


            1. qw1
              07.07.2022 22:08

              Возможно. Но тогда нужен какой-то мета-язык для описания, какой класс мы хотим построить при «применении» fluent-операторов к типу. Чтобы в генераторе не говнокодить

              if (feature1) {
                 WriteLine("public abstract Task<ActionResult> ExecuteAsync(");
              } else {
                 WriteLine(...); }


      1. Dimcore
        08.07.2022 03:23

        Как вариант, для упрощения можно все эти статические классы сделать абстрактными, в них содержать общую логику (дабы не копипастить), а их наследовать вложенными классами


        1. dopusteam
          08.07.2022 07:29
          +2

          Статические классы абстрактными?


          1. iamkisly
            09.07.2022 15:11
            +1

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


  1. leon_ul
    08.07.2022 08:38
    +2

    Есть у меня какая то чуйка, что если к этому добавить Source Generator, то будет что то стоющее..


  1. HencoDesu
    08.07.2022 08:38

    Очень интересная идея, надо бы попробовать что-то такое в каком то из своих петпроектов


  1. a-tk
    08.07.2022 09:40
    +4

    А потом появляется второй метод, и... Ой.


    1. ris58h
      08.07.2022 15:45

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

      Более того, если мы представим расширение функционала библиотеки
      (например, добавление  не асинхронных обработчиков), то станет очевидно,
      что такая архитектура плохо масштабируется.

      Затем, в своём решении, он героически НЕ решает озвученную проблему.


  1. UnclShura
    08.07.2022 15:24

    Выгладит отвратительно честно говоря. Не надо в сройный язык тянуть абсолютно чуждые концепциии. Во-первых и главных оно выглядит (читается) очень плохо. Оно вводит некий "язык" описания дженериков, которого не было до того. А какую проблему при этом решает? Немного чего-то-там переиспользовать? А зачем? Только чтоб переиспользовать. Чем тут плохи просто тупо уникальные классы?
    Это абсолютно та-же гадость что тянется из DI контейнетов - вызовы методов через точку. Плохо тем, что приходится учить совершенно искусственный синтаксис, который нигде больше не используется. В LINQ вызов через точку хоть оправдан - там цепь вызовов и типы возврящаемого значения отличаются. А тут зачем?


    1. ris58h
      08.07.2022 15:50
      +1

      Выгладит отвратительно

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


      1. UnclShura
        08.07.2022 16:10

        Ключевое как мне видится это вот:

        var result = new Command("git")     
            .WithArguments("pull")     
            .WithWorkingDirectory("/my/repository")  

        Какое возвращаемое значение у WithArguments и какой вход у WithWorkingDirectory? В случае с DI контейнером мне вываливается сотня-другая абслолютно бесполезных подсказок, среди которых еще и нет того, что я ищу (потому, что забыт using). Или как тут - допустим я помню, что надо вызвать WithArguments, но не помю у кого. Что мне теперь поможет?

        В случае с дженериками из статьи еще и не понятно зачем оно вообще? Так часто что-ли ендпоинты с одним методом делаются? Да никогда практически. Ну и какую проблему оно решает? А если часто, то чем оно лучше Endpoint<SomeRequest, SomeResponce>?

        А если еще дальше смотреть, то накой мне вообще этот public abstract метод?


        1. a-tk
          08.07.2022 16:23

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


        1. a-tk
          08.07.2022 16:26

          Если бы такие методы возвращали "частично структурированный" объект с соответствующим типом, то было бы хорошо. Но просто накидать методов для именования аргументов - так себе решение.


          1. UnclShura
            08.07.2022 16:28
            +1

            Вот и я про то-же. По-моему вот так оно выглядит лучше и читается проще:

            var result = new Command("git")     
                {
                   Arguments = {"pull"},
                   WorkingDirectory = "/my/repository"
                };


            1. a-tk
              08.07.2022 20:12

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

              var cmd = Command.Pull(workingDirectory: "/my/repository");


            1. FanToMaS-api
              08.07.2022 22:24

              Да, тоже об этом подумал, когда читал


        1. iamkisly
          09.07.2022 15:56

          Слишком душно.

          Какое возвращаемое значение у WithArguments и какой вход у WithWorkingDirectory?

          Мое мнение, это придирка ради придирки. У вас есть тысячи мест, где тип возвращаемого значения неизвестен исходя из названия потому что нейминг не всегда может решить эту проблему. Тем более что в конкретно этом месте некаких проблем нет, это концептуально производное от стандартного Builder паттерна, использование которого вы каждый день видите:

          await Host.CreateDefaultBuilder(args)
              .ConfigureServices(services =>
              {
                  services.AddHostedService<SampleHostedService>();
              })
              .Build()
              .RunAsync();


  1. Zadir7
    08.07.2022 17:07

    Увидел скриншот. Подумал "а нафига это нужно если есть MediatR?". Пробежал глазами пост. Подумал то же самое еще раз.


    1. dopusteam
      08.07.2022 18:56
      +3

      Перечитайте статью, при чем тут mediatr?


      1. Zadir7
        08.07.2022 20:18
        +1

        При том, что все что описал автор статьи уже примерно воплощено в интерфейсе IRequestHandler<TRequest, TResponse>. И fluent ни разу не делает код проще, а наоборот усложняет, и непонятно, ради какой выгоды.


        1. dopusteam
          09.07.2022 13:00
          +2

          Пример с request/response - просто пример. Статья немного о другом.


  1. qqeekk
    09.07.2022 18:54
    +1

    Причина тому неоднозначность между Endpoint<TReq> и Endpoint<TRes>, поскольку нет возможности определить означает типовой параметр запрос или ответ.

    А могли бы определить один единственный тип Endpoint<TReq, TRes> и подставлять на место позиционных параметров фиктивный тип Unit тогда, когда запроса или ответа не ожидается.

    Подобный подход использует MediatR, например, как правильно выше заметили, с его IRequestHandler.

    Hidden text

    P.S. в функциональщине unit-типы вообще куда натуральнее смотрятся чем отсутствие типа или void. Посмотрите как изящно в том же F# там сделаны нативные делегаты. Вместо 100500 типов а-ля

    Action, Action<T1>, ..Action<T1, ..T7>, Func<R>, ..Func<T1,..T7, R>

    там один единственный

    FSharpFunc<T, R>

    и прекраснопричём работает.


  1. Exemption
    09.07.2022 20:48
    +1

    Впервые увидел этот подход вот тут (ardalis/ApiEndpoints). С одной стороны, возникает бОльшая типизация и разделение вместо микса пачки методов в одном контроллере, а с другой - изобретение велосипеда, который вместо паттерна через пару лет может стать техническим долгом, который непонятно как переводить на рельсы новой версии .NET, и который нужно будет объяснять каждому новому разработчику