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


  1. MonkAlex
    28.06.2022 20:54
    +2

    Это как то стрёмно =)

    У DelayedOk и DelayedError нет никакой информации о втором возвращаемом типе и в итоге они могут приводиться так же неявно к любой комбинации и своего типа и любого другого:

    Result<string, int> example = new DelayedOk<string>("123");

    Не знаю, правда, мешает ли это хоть в чём то, на простых примерах в статье - да, смотрится удобно. Стоит ли оно того - хз =)

    Если это генерируемый код - вроде какая разница, генерировать с явным типом или оставлять выведение на компилятор. Если пишется руками - то IDE давно уж отлично подсказывает return new Result<string, string> как раз зная возвращаемый результат =)

    UPD: плюсик статье воткнул, в целом забавно всё равно =)


    1. Stefanio Автор
      28.06.2022 22:21
      +1

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

      public readonly struct DelayedOk<T>
      {
          public T Value { get; }
      
          private DelayedOk(T value) => Value = value;
      
          public static implicit operator DelayedOk<T>(T value) => new(value);
      }
      
      public readonly struct DelayedError<T>
      {
          public T Value { get; }
      
          private DelayedError(T value) => Value = value;
      
          public static implicit operator DelayedError<T>(T value) => new(value);
      }
      
      public static class Result
      {
          public static DelayedOk<TOk> Ok<TOk>(TOk ok) => ok;
      
          public static DelayedError<TError> Error<TError>(TError error) => error;
      }
      


      1. ARad
        29.06.2022 03:01
        +2

        Тогда уж проще и правильнее конструкторы было сделать внутренними internal.


  1. mvv-rus
    29.06.2022 03:14
    +2

    Мое мнение о выводе типов вообще: вывод типов с одной стороны ускоряет написание кода (особенно — теми, кто медленно печатает), но, с другой стороны — нередко создает серьезные проблемы тем, кто читает этот код. Особенно, если это — тип переменной, выводимой из сложного выражения. Поэтому, если вы заботитесь о читаемости написанного кода — используйте вывод типов (в том числе — и в лямбдах), только там, где тип очевиден сразу.
    Отсюда и оценка статьи — небезынтересно, но описанный метод следует применять с осторожностью.


    1. Naf2000
      29.06.2022 06:13
      +1

      А разве тем кто читает IDE не подсказывает тип?


      1. mvv-rus
        29.06.2022 14:12
        +1

        IDE-то подсказывает, но читать приходится не только в IDE. Например, с Github уже сложнее, а с чем-нибудь типа Far или блокнота — ещё сложнее.


        1. Szer
          29.06.2022 15:02
          +3

          А если закрытыми глазами, так вообще очень трудно!


    1. dedmagic
      29.06.2022 08:40
      +9

      Явное указание типов при чтении мешает.
      В коде

      var employees = GetEmployeesByDepartment(departmentId);
      

      прекрасно видно бизнес-смысл переменной -- список сотрудников. Какая мне разница, что там за тип: List<Employee>, Employee[] или вообще Dictionary?

      Вот если потребуется глубокое погружение в код с целью его модификации (что уже не чтение, а изучение), то тип подскажет IDE.


      1. Myz17
        29.06.2022 13:40
        +5

        Мешает для простых типов. У нас в команде есть правило, что для простых значимых типов, а также string, DateTime, Guid и некоторых других нельзя использовать var. Классический пример - есть какое-то математическое выражение: var result = Какая-то формула с вызовом несколькими вызовами Math.*. На рецензировании тяжело определить какой тип в итоге у result и совпадает ли он с предполагаемым автором. Ошибки округления тут точно будут.


      1. 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>. Сюрприз!


        1. dedmagic
          29.06.2022 14:40
          +3

          подскажет, как именно автор кода собирается с этим работать дальше, т.е. какие именно методы следует ожидать увидеть в дальнейшем

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

          надо просто понять, что именно делает код

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


          1. mvv-rus
            29.06.2022 14:49
            +1

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

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

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


  1. 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");

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


  1. AIgin
    29.06.2022 07:37
    +4

    1. KReal
      30.06.2022 15:40

      Ого, впечатляет!


  1. NN1
    29.06.2022 08:40
    +1

    Проблема в том, что тернарный оператор в C# не приводит ветви к "общему знаменателю"

    Начиная с C# 9.0 явное приведение типа не требуется.


    1. 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, неявное приведение работает:


      1. NN1
        29.06.2022 22:35

        Конечно соберётся. SharpLab
        Или я не понял каким должен быть код.


        1. Stefanio Автор
          30.06.2022 13:17

          Да, вы правы, отметил это в статье. Однако, проблема всё же возникнет при использовании Result<T, T>. В этом основной поинт)


  1. ahimenid
    29.06.2022 10:32
    -2

    начинаешь читать
    "Мне по-настоящему нравитЬся больше чего-либо в разработке ПО делать фреймворки ..."
    прекращаешь читать


    1. Stefanio Автор
      29.06.2022 10:32

      исправлено)


    1. build_your_web
      29.06.2022 11:31

      Такое принято писать в личку.


      1. mvv-rus
        29.06.2022 14:26
        +4

        Да нет, такое принято выделять прямо в тексте и жать Ctrl+Enter: в личко оно напишет само.
        Проверено: обычно помогает.


  1. yeputons
    29.06.2022 19:53
    +1

    В C++ есть аналогичный трюк с аналогичной реализацией: https://en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Return_Type_Resolver