Мне по-настоящему нравится больше чего-либо в разработке ПО делать фреймворки, позволяющие другим разработчикам создавать что-то крутое. Иногда, в погоне за идеальным кодом, мне на ум приходят странные идеи, при реализации которых C# может дойти до предела своих возможностей.
Не так давно произошёл подобный случай, когда мы вместе с коллегой искали способ избежать передачи большого количества типовых параметров в тех местах, где компилятор должен был по идее их вывести. Однако, C# так устроен, что способен выводить типы в обобщённых вызовах только из передаваемых параметров метода.
В этой статье покажу небольшой трюк, который позволит симулировать вывод типов для возвращаемых значений, и немного примеров, где это может быть полезно.
Вывод типов
Вывод типов это способность компилятора автоматически распознавать тип выражения без его явного указания. Эта особенность работает через анализ контекста, в котором вычисляется выражение, учитывая ограничения, накладываемые окружающим потоком данных в программе.
Способность автоматически распознавать тип позволяет программистам на этом языке писать лаконичный код, пользуясь всеми преимуществами статической типизации. Поэтому, большинство популярных языков имеют в той или иной форме вывод типов.
C# относится к их числу. Простейший пример, демонстрирующий это, ключевое слово var
:
var x = 5; // int
var y = "foo"; // string
var z = 2 + 1.0; // double
var g = Guid.NewGuid(); // Guid
При использовании ключевого слова var в рамках объявления, совмещённого с присваиванием, не требуется указывать тип переменной. Компилятор способен определить его самостоятельно на основе выражения справа.
В том же духе, C# позволяет инициализировать массив без нужды в явном указании типа:
var array = new[] {"Hello", "world"}; // string[]
Здесь компилятор видит, что инициализируется массив с двумя строковыми элементами, из чего он может спокойно сделать вывод о том, что результирующий тип переменной string[]
. В некоторых особых случаях, он даже может вывести тип, основываясь на наиболее общем типе среди элементов:
var array = new[] {1, 2, 3.0}; // double[]
Всё же, самый интересный аспект вывода типов в C# это, конечно, обобщённые методы. При вызове такого метода можно пренебречь типовыми аргументами, поскольку они могут быть выведены из значений, передаваемых в параметры метода.
Например, можно определить обобщённый метод List.Create<T>
, который создаёт список из последовательности элементов:
public static class List
{
public static List<T> Create<T>(params T[] items) => new List<T>(items);
}
Который можно использовать следующим образом:
var list = List.Create(1, 3, 5); // List<int>
В примере выше можно было бы написать явно List.Create<int>(...)
, но в этом не было необходимости. Компилятор на основе параметров, переданных в метод, самостоятельно определил тип, от которого также зависит возвращаемое значение.
Достаточно интересно, что все примеры выше основаны на одной форме вывода типов, которая работает за счёт анализа ограничений, наложенных другими выражениями, тип которых уже известен. Другими словами, исследуется входящий поток данных и формируются выводы об исходящем.
Однако, также существуют сценарии, где мы хотим иметь вывод типов в обратном направлении. Посмотрим, где это может быть полезно.
Тип Option
Если раньше вам приходилось писать код в функциональном стиле, то, скорее всего, вы очень близко знакомы с типом Option<T>
. Это контейнер, содержащий значение вместе с фактом его наличия и позволяющий выполнять операции над ним без необходимости в наблюдении за состоянием.
В C# такой тип обычно определяют двумя полями - значением некого типа и флагом, указывающим на наличие этого значения. Это можно представить следующим образом:
public readonly struct Option<T>
{
private readonly T _value;
private readonly bool _hasValue;
private Option(T value, bool hasValue)
{
_value = value;
_hasValue = hasValue;
}
public Option(T value)
: this(value, true)
{
}
public TOut Match<TOut>(Func<T, TOut> some, Func<TOut> none) =>
_hasValue ? some(_value) : none();
public void Match(Action<T> some, Action none)
{
if (_hasValue)
some(_value);
else
none();
}
public Option<TOut> Select<TOut>(Func<T, TOut> map) =>
_hasValue ? new Option<TOut>(map(_value)) : new Option<TOut>();
public Option<TOut> Bind<TOut>(Func<T, Option<TOut>> bind) =>
_hasValue ? bind(_value) : new Option<TOut>();
}
Этот API достаточно прост. Реализация выше скрывает значение от его потребителей, оставляя на поверхности только метод Match(...)
, который обрабатывает оба возможных состояния контейнера. Есть дополнительные методы Select(...)
и Bind(...)
, которые используются для безопасных превращений значения вне зависимости от того, есть оно или нет.
Также, в этом примере, Option<T>
объявлен как readonly struct
. Учитывая, что в дальнейшем объекты этого типа будут либо возвращаться из методов, либо использоваться в локальных областях видимости, решение о таком объявлении было принято из соображений производительности.
Чтобы сделать использование типа удобнее, можно предоставить фабричные методы, которые помогут гибче создавать инстансы Option<T>
:
public static class Option
{
public static Option<T> Some<T>(T value) => new Option<T>(value);
public static Option<T> None<T>() => new Option<T>();
}
Пример использования:
public static Option<int> Parse(string number)
{
return int.TryParse(number, out var value)
? Option.Some(value)
: Option.None<int>();
}
Видно, что в случае вызова Option.Some<T>(...)
можно опустить типовой параметр, потому что компилятор может его вывести на основе типа value
, который является int
. С другой стороны, такой же подход не работает с методом Option.None<T>(...)
, потому что у него нет никаких параметров. В результате, нужно указывать тип вручную.
Несмотря на то, что типовой параметр для Option.None<T>(...)
, кажется очевидным из контекста, компилятор не способен его вывести. Потому что, как говорилось ранее, вывод типов в C# работает только за счёт анализа входящего потока данных, но никак не наоборот.
Конечно, в идеале, хотелось бы, чтобы компилятор сам выяснил тип T
в Option.None<T>(..)
, основываясь на возвращаемом типе выражения, который оно должно иметь согласно сигнатуре метода. Иначе, хотелось бы получить тип T
, как ветвь тернарного оператора, исходя из типа value
.
К несчастью, ни тот, ни другой сценарий невозможны в C#, потому что тогда системе типов пришлось бы работать с анализом исходящего потока данных, что она не умеет. Однако, можно ей помочь.
Можно симулировать return type inference, заставив Option.None
вернуть специальное значение не обобщённого типа, которое могло быть приведено к Option<T>
. Приблизительно так это могло бы выглядеть:
public readonly struct Option<T>
{
private readonly T _value;
private readonly bool _hasValue;
private Option(T value, bool hasValue)
{
_value = value;
_hasValue = hasValue;
}
public Option(T value)
: this(value, true)
{
}
// ...
public static implicit operator Option<T>(NoneOption none) => new Option<T>();
}
public readonly struct NoneOption
{
}
public static class Option
{
public static Option<T> Some<T>(T value) => new Option<T>(value);
public static NoneOption None { get; } = new NoneOption();
}
Как вы можете видеть, Option.None
возвращает пустышку типа NoneOption
, которая моделирует пустой контейнер, соответственно, и не важно какого типа. Тип NoneOption
не обобщённый, поэтому можно типовые параметры опустить и превратить Option.None
в свойство.
Также, в Option<T>
теперь есть неявное преобразование из NoneOption
. Хоть операторы и не могут быть обобщёнными в C#, они всё ещё могут использовать типовые параметры, объявленные в типе, содержащем оператор. Это позволяет определить преобразования ко всем возможным вариациям Option<T>
.
Всё это позволяет использовать Option.None
так, как планировалось изначально. С точки зрения разработчика выглядит так, будто в языке появился return type inference:
public static Option<int> Parse(string number)
{
return int.TryParse(number, out var value)
? Option.Some(value)
: Option.None;
}
Тип Result
Те же схемы, применённые к Option<T>, можно натянуть на тип Result<TOk, TError>
. Этот тип выполняет тоже назначение, за исключением того, что предоставляет целое значение для обработки негативных сценариев.
Таким образом можно было бы его реализовать:
public readonly struct Result<TOk, TError>
{
private readonly TOk _ok;
private readonly TError _error;
private readonly bool _isError;
private Result(TOk ok, TError error, bool isError)
{
_ok = ok;
_error = error;
_isError = isError;
}
public Result(TOk ok)
: this(ok, default, false)
{
}
public Result(TError error)
: this(default, error, true)
{
}
// ...
}
public static class Result
{
public static Result<TOk, TError> Ok<TOk, TError>(TOk ok) =>
new Result<TOk, TError>(ok);
public static Result<TOk, TError> Error<TOk, TError>(TError error) =>
new Result<TOk, TError>(error);
}
А вот так использовать:
public static Result<int, string> Parse(string input)
{
return int.TryParse(input, out var value)
? Result.Ok<int, string>(value)
: Result.Error<int, string>("Invalid value");
}
Здесь ситуация с выводом типов и вовсе внушает ужас. Ни Result.Ok<TOk, TError>(...)
, ни Result.Error<TOk, TError>(...)
не имеют достаточно аргументов, чтобы вывести типовые параметры. Поэтому, мы вынуждены явно их указывать в обоих случаях.
Необходимость явно писать эти типы влечёт за собой визуальный шум, дублирование кода и в целом плохие практики разработки. Попробуем исправить это, используя те же методики, что и раньше:
public readonly struct Result<TOk, TError>
{
private readonly TOk _ok;
private readonly TError _error;
private readonly bool _isError;
private Result(TOk ok, TError error, bool isError)
{
_ok = ok;
_error = error;
_isError = isError;
}
public Result(TOk ok)
: this(ok, default, false)
{
}
public Result(TError error)
: this(default, error, true)
{
}
public static implicit operator Result<TOk, TError>(DelayedResult<TOk> ok) =>
new Result<TOk, TError>(ok.Value);
public static implicit operator Result<TOk, TError>(DelayedResult<TError> error) =>
new Result<TOk, TError>(error.Value);
}
public readonly struct DelayedResult<T>
{
public T Value { get; }
public DelayedResult(T value)
{
Value = value;
}
}
public static class Result
{
public static DelayedResult<TOk> Ok<TOk>(TOk ok) =>
new DelayedResult<TOk>(ok);
public static DelayedResult<TError> Error<TError>(TError error) =>
new DelayedResult<TError>(error);
}
Похожим образом определили тип DelayedResult<T>
, моделирующий инициализацию Result<TOk, TError>
. Опять же, используется неявное приведение типов для перехода от отложенной инициализации к желаемому контейнеру.
Всё сделанное позволяет переписать код следующим образом:
public static Result<int, string> Parse(string input)
{
return int.TryParse(input, out var value)
? (Result<int, string>) Result.Ok(value)
: Result.Error("Invalid value");
}
Чуть лучше, но не идеально. Проблема в том, что тернарный оператор в C# не приводит ветви к "общему знаменателю". Из-за этого приходится явно кастить к Result<int, string>
ветвь "истины". (до C# 9)
Однако, такого поведения можно избежать если использовать классическое условное выражение:
public static Result<int, string> Parse(string input)
{
if (int.TryParse(input, out var value))
return Result.Ok(value);
return Result.Error("Invalid value");
}
Такая конфигурация более удовлетворительна. Можно полностью опустить типовые параметры, не изменяя сигнатуры и пользуясь типобезопасностью. И снова создаётся иллюзия того, что эти параметры выводятся на основе ожидаемого возвращаемого типа.
При этом, вы могли заметить баг в текущей реализации. Если TOk
и TError
будут одинаковыми, то возникнет неоднозначность: какой именно вариант DelayedResult<T>
использовать.
Представим, в качестве примера, такой сценарий с использованием нашего типа:
public interface ITranslationService
{
Task<bool> IsLanguageSupportedAsync(string language);
Task<string> TranslateAsync(string text, string targetLanguage);
}
public class Translator
{
private readonly ITranslationService _translationService;
public Translator(ITranslationService translationService)
{
_translationService = translationService;
}
public async Task<Result<string, string>> TranslateAsync(string text, string language)
{
if (!await _translationService.IsLanguageSupportedAsync(language))
return Result.Error($"Language {language} is not supported");
var translated = await _translationService.TranslateAsync(text, language);
return Result.Ok(translated);
}
}
Здесь Result.Error<TError>(...)
и Result.Ok<TOk>(...)
оба возвращают DelayedResult<string>. Так что компилятор затрудняется выяснить, что с этим делать:
Cannot convert expression type 'DelayedResult<string>' to return type 'Result<string,string>'
К счастью, исправить это просто - надо только каждое состояние представить отдельным типом:
public readonly struct Result<TOk, TError>
{
private readonly TOk _ok;
private readonly TError _error;
private readonly bool _isError;
private Result(TOk ok, TError error, bool isError)
{
_ok = ok;
_error = error;
_isError = isError;
}
public Result(TOk ok)
: this(ok, default, false)
{
}
public Result(TError error)
: this(default, error, true)
{
}
public static implicit operator Result<TOk, TError>(DelayedOk<TOk> ok) =>
new Result<TOk, TError>(ok.Value);
public static implicit operator Result<TOk, TError>(DelayedError<TError> error) =>
new Result<TOk, TError>(error.Value);
}
public readonly struct DelayedOk<T>
{
public T Value { get; }
public DelayedOk(T value)
{
Value = value;
}
}
public readonly struct DelayedError<T>
{
public T Value { get; }
public DelayedError(T value)
{
Value = value;
}
}
public static class Result
{
public static DelayedOk<TOk> Ok<TOk>(TOk ok) =>
new DelayedOk<TOk>(ok);
public static DelayedError<TError> Error<TError>(TError error) =>
new DelayedError<TError>(error);
}
Вернувшись к коду, написанному ранее, увидим, что он работает, как того и требовалось:
public class Translator
{
private readonly ITranslationService _translationService;
public Translator(ITranslationService translationService)
{
_translationService = translationService;
}
public async Task<Result<string, string>> TranslateAsync(string text, string language)
{
if (!await _translationService.IsLanguageSupportedAsync(language))
return Result.Error($"Language {language} is not supported");
var translated = await _translationService.TranslateAsync(text, language);
return Result.Ok(translated);
}
}
Вывод
Хоть у вывода типов в C# и есть ограничения, язык можно заставить их немного отодвинуть с помощью неявного преобразования типов. Используя простой трюк, показанный в статье, можно симулировать return type inference, попутно открывая потенциально интересные архитектурные возможности.
Комментарии (24)
mvv-rus
29.06.2022 03:14+2Мое мнение о выводе типов вообще: вывод типов с одной стороны ускоряет написание кода (особенно — теми, кто медленно печатает), но, с другой стороны — нередко создает серьезные проблемы тем, кто читает этот код. Особенно, если это — тип переменной, выводимой из сложного выражения. Поэтому, если вы заботитесь о читаемости написанного кода — используйте вывод типов (в том числе — и в лямбдах), только там, где тип очевиден сразу.
Отсюда и оценка статьи — небезынтересно, но описанный метод следует применять с осторожностью.dedmagic
29.06.2022 08:40+9Явное указание типов при чтении мешает.
В кодеvar employees = GetEmployeesByDepartment(departmentId);
прекрасно видно бизнес-смысл переменной -- список сотрудников. Какая мне разница, что там за тип:
List<Employee>
,Employee[]
или вообщеDictionary
?Вот если потребуется глубокое погружение в код с целью его модификации (что уже не чтение, а изучение), то тип подскажет IDE.
Myz17
29.06.2022 13:40+5Мешает для простых типов. У нас в команде есть правило, что для простых значимых типов, а также string, DateTime, Guid и некоторых других нельзя использовать var. Классический пример - есть какое-то математическое выражение: var result = Какая-то формула с вызовом несколькими вызовами Math.*. На рецензировании тяжело определить какой тип в итоге у result и совпадает ли он с предполагаемым автором. Ошибки округления тут точно будут.
mvv-rus
29.06.2022 14:20+1Какая мне разница, что там за тип: List, Employee[] или вообще Dictionary?
Хороший пример: в нем, если нет разницы, то явное указание IList<Emloyee> (или вообще IEnumerable<Emloyee>) вместо var подскажет, как именно автор кода собирается с этим работать дальше, т.е. какие именно методы следует ожидать увидеть в дальнейшем. Ну а Dictionary тут вообще не сильно похоже на два предыдущих.
PS Для меня обычно чтение=изучение, не обязательно с целью модификации: иногда надо просто понять, что именно делает код, потому как документация не идеальна.
С другой стороны, c var возможны внезапные сюрпризы. Например, если мы видим в коде настройки контейнера сервисов на ASP.NET что-то типа serviceCollection.что-то-там.AddOptions() (где serviceCollection имеет тип IServiceCollection), то занчение выражения остается имеющим тип IServiceCollection, а если мы видим очень похожее на него выражение serviceCollection.что-то-там.AddOptions<T>() то внезапно тип меняется на OptionsBuilder<T>. Сюрприз!dedmagic
29.06.2022 14:40+3подскажет, как именно автор кода собирается с этим работать дальше, т.е. какие именно методы следует ожидать увидеть в дальнейшем
Хм, а зачем нужно предсказывать будущее? Зачем чего-то ожидать?
В процессе дальнейшего чтения я увижу, какие методы вызываются.надо просто понять, что именно делает код
Вот для этого точное знание типов вообще не нужно. Достаточно понимать бизнес-смысл происходящего.
mvv-rus
29.06.2022 14:49+1Вот для этого точное знание типов вообще не нужно. Достаточно понимать бизнес-смысл происходящего.
Ну, если вам действительно достаточно приблизительно понимать бизнес-смысл, а не как именно он реализуется — то таки да, детали реализации вам не нужны.
Но так можно проглядеть и что-то существенное — особенно если код писал «творец».
PS Я там в предыдущем комментарии кое-что вспомнил, и немного подправил — про то, как тип длинного выражения, записанного через точку может внезапно поменяться — и глядя на внезапно появившиеся методы, которых быть тут не должно, у читающего возникает когнитивный диссонанс.
Wolfdp
29.06.2022 07:37+2Идея статьи интересная, и возможно даже имеет место применения, но конкретные примеры я бы скорее всего не использовал. Option<T> для случаев когда T - структура полностью заменяется Nullable<T>
int? Parse(string val) => int.TryParse(val, out var result) ? result : null;
Result<TOk, TError> уже выглядит поинтересней, но конкретно финальная реализация немного смущает тем, что static class Result начинает возвращать не Result<TOk, TError>, а DelayedOk, что не всегда позволяет его использовать как create-метод, так как нельзя будет сделать например такое
var result = Result.Ok(1); result = Parse("1");
а нужно будет явно объявлять
Result<int, int> result = Result.Ok(1); result = Parse("1");
Но повторюсь: скорее всего такой финт ушами действительно может пригодится, просто в менее распространенных задачах.
NN1
29.06.2022 08:40+1Проблема в том, что тернарный оператор в C# не приводит ветви к "общему знаменателю"
Начиная с C# 9.0 явное приведение типа не требуется.
Stefanio Автор
29.06.2022 10:39Как раз в конфигурации с
DelayedResult<T>
код:public static Result<int, string> ParseToResult(string input) { return int.TryParse(input, out var value) ? Result.Ok(value) : Result.Error("Invalid value"); }
не скомпилится
Когда же мы разделяем отложенную инициализацию на два состоянияDelayedOk
иDelayedError
, неявное приведение работает:
ahimenid
29.06.2022 10:32-2начинаешь читать
"Мне по-настоящему нравитЬся больше чего-либо в разработке ПО делать фреймворки ..."
прекращаешь читатьbuild_your_web
29.06.2022 11:31Такое принято писать в личку.
mvv-rus
29.06.2022 14:26+4Да нет, такое принято выделять прямо в тексте и жать Ctrl+Enter: в личко оно напишет само.
Проверено: обычно помогает.
yeputons
29.06.2022 19:53+1В C++ есть аналогичный трюк с аналогичной реализацией: https://en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Return_Type_Resolver
MonkAlex
Это как то стрёмно =)
У DelayedOk и DelayedError нет никакой информации о втором возвращаемом типе и в итоге они могут приводиться так же неявно к любой комбинации и своего типа и любого другого:
Не знаю, правда, мешает ли это хоть в чём то, на простых примерах в статье - да, смотрится удобно. Стоит ли оно того - хз =)
Если это генерируемый код - вроде какая разница, генерировать с явным типом или оставлять выведение на компилятор. Если пишется руками - то IDE давно уж отлично подсказывает
return new Result<string, string>
как раз зная возвращаемый результат =)UPD: плюсик статье воткнул, в целом забавно всё равно =)
Stefanio Автор
Интересное замечание! Но конечно эти промежуточные типы не предполагают по своему смыслу ручного использования. И это хорошо бы ограничить. Например, натянув ещё одну промежуточную прокладку неявных преобразований:
ARad
Тогда уж проще и правильнее конструкторы было сделать внутренними
internal
.