Pull to refresh

Simulating Return Type Inference in C#

Reading time10 min
Views11K
Original author: Oleksii Holub

Мне по-настоящему нравится больше чего-либо в разработке ПО делать фреймворки, позволяющие другим разработчикам создавать что-то крутое. Иногда, в погоне за идеальным кодом, мне на ум приходят странные идеи, при реализации которых 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, попутно открывая потенциально интересные архитектурные возможности.


Ещё я веду telegram канал StepOne, где оставляю небольшие заметки про разработку и мир IT.

Tags:
Hubs:
Total votes 30: ↑30 and ↓0+30
Comments24

Articles