Дженерики - мощная фича доступная во многих статически типизированных языках программирования. С их помощью можно писать код, который постоянно работает со множеством разных типов, делая упор на их общие особенности, нежели на сами типы. Они позволяют создавать гибкие и переиспользуемые компоненты без нужды в дублировании кода и жертвы безопасности типов.
Несмотря на то, что дженерики давно в 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> { /* ... */ }
Вывод
Несмотря на неоспоримую пользу дженериков их косная природа может усложнить использование обобщённых типов в некоторых случаях. В частности, при необходимости определить сигнатуры, инкапсулирующие большое количество различных комбинаций типовых параметров, можно обратиться к перегрузкам, что влечёт за собой определённые ограничения.
В качестве альтернативного решения, можно вкладывать дженерики друг в друга, создавая иерархическую структуру, которая позволит разработчикам комбинировать их в гибкой манере. Это позволяет совместить в процессе разработки очень тонкую настройку с наилучшим удобством использования.
Ещё я веду telegram канал StepOne, где оставляю небольшие заметки про разработку и мир IT.