Монада «Maybe» через async/await в C# (без Task-oв!)

Автор оригинала: Dmitry Tikhonov
  • Перевод


Обобщенные асинхронные типы возвращаемых значений — это новая возможность появившаяся в C# 7, которая позволяет использовать не только Task в качестве возвращаемого типа асинхронных (async/await) методов, но также и любые другие типы (классы или структуры), удовлетворяющие определенным требованиям.


В то же время async/await — это способ последовательно вызвать некий набор функций внутри некоторого контекста, что является сущностью шаблона проектирования Монада. Возникает вопрос, можем ли мы использовать async/await для написания кода, который будет вести себя так же, как если бы мы использовали монады? Оказывается, что да (с некоторыми оговорками). Например, приведенный ниже код компилируется и работает:


async Task Main()
{
  foreach (var s in new[] { "1,2", "3,7,1", null, "1" })
  {
      var res = await Sum(s).GetMaybeResult();
      Console.WriteLine(res.IsNothing ? "Nothing" : res.GetValue().ToString());
  }
  // 3, 11, Nothing, Nothing
}

async Maybe<int> Sum(string input)
{
    var args = await Split(input);//Нет проверки результата
    var result = 0;
    foreach (var arg in args)
        result += await Parse(arg);//Нет проверки результата
    return result;
}

Maybe<string[]> Split(string str)
{
  var parts = str?.Split(',').Where(s=>!string.IsNullOrWhiteSpace(s)).ToArray();
  return parts == null || parts.Length < 2 ? Maybe<string[]>.Nothing() : parts;
}

Maybe<int> Parse(string str)
    => int.TryParse(str, out var result) ? result : Maybe<int>.Nothing();

Далее я объясняю, как работает этот код ...


Обобщенные асинхронные типы возвращаемых значений


Прежде всего давайте выясним, что требуется для использования нашего собственного типа (например, класс MyAwaitable<T>) в качестве типа результата некоторой асинхронной функции. Документация говорит, что такой тип должен иметь:


  1. GetAwaiter() метод, который возвращает объект типа, который реализует интерфейс INotifyCompletion, а также имеет свойство bool IsCompleted и метод T GetResult() ;


  2. [AsyncMethodBuilder(Type)] — aтрибут указывающий на тип, который будет выступать в роли "Построителя метода" ("Method Builder"), например MyAwaitableTaskMethodBuilder<T>. Этот тип должен содержать в следующие методы:


    • static Create()
    • Start(stateMachine)
    • SetResult(result)
    • SetException(exception)
    • SetStateMachine(stateMachine)
    • AwaitOnCompleted(awaiter, stateMachine)
    • AwaitUnsafeOnCompleted(awaiter, stateMachine)
    • Task


Пример простой реализации MyAwaitable и MyAwaitableTaskMethodBuilder
[AsyncMethodBuilder(typeof(MyAwaitableTaskMethodBuilder<>))]
public class MyAwaitable<T> : INotifyCompletion
{
    private Action _continuation;

    public MyAwaitable()
    { }

    public MyAwaitable(T value)
    {
        this.Value = value;
        this.IsCompleted = true;
    }

    public MyAwaitable<T> GetAwaiter() => this;

    public bool IsCompleted { get; private set; }

    public T Value { get; private set; }

    public Exception Exception { get; private set; }

    public T GetResult()
    {
        if (!this.IsCompleted) throw new Exception("Not completed");
        if (this.Exception != null)
        {
            ExceptionDispatchInfo.Throw(this.Exception);
        }
        return this.Value;
    }

    internal void SetResult(T value)
    {
        if (this.IsCompleted) throw new Exception("Already completed");
        this.Value = value;
        this.IsCompleted = true;
        this._continuation?.Invoke();
    }

    internal void SetException(Exception exception)
    {
        this.IsCompleted = true;
        this.Exception = exception;
    }

    void INotifyCompletion.OnCompleted(Action continuation)
    {
        this._continuation = continuation;
        if (this.IsCompleted)
        {
            continuation();
        }
    }
}

public class MyAwaitableTaskMethodBuilder<T>
{
    public MyAwaitableTaskMethodBuilder() 
        => this.Task = new MyAwaitable<T>();

    public static MyAwaitableTaskMethodBuilder<T> Create() 
    => new MyAwaitableTaskMethodBuilder<T>();

    public void Start<TStateMachine>(ref TStateMachine stateMachine) 
        where TStateMachine : IAsyncStateMachine 
        => stateMachine.MoveNext();

    public void SetStateMachine(IAsyncStateMachine stateMachine) { }

    public void SetException(Exception exception) 
        => this.Task.SetException(exception);

    public void SetResult(T result) 
        => this.Task.SetResult(result);

    public void AwaitOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, 
        ref TStateMachine stateMachine) 
        where TAwaiter : INotifyCompletion 
        where TStateMachine : IAsyncStateMachine
        => this.GenericAwaitOnCompleted(ref awaiter, ref stateMachine);

    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, 
        ref TStateMachine stateMachine) 
        where TAwaiter : ICriticalNotifyCompletion 
        where TStateMachine : IAsyncStateMachine
        => this.GenericAwaitOnCompleted(ref awaiter, ref stateMachine);

    public void GenericAwaitOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, 
        ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine         
        => awaiter.OnCompleted(stateMachine.MoveNext);

    public MyAwaitable<T> Task { get; }
}

Теперь мы можем использовать MyAwaitable как тип результата асинхронных методов:


private async MyAwaitable<int> MyAwaitableMethod()
{
    int result = 0;
    int arg1 = await this.GetMyAwaitable(1);
    result += arg1;
    int arg2 = await this.GetMyAwaitable(2);
    result += arg2;
    int arg3 = await this.GetMyAwaitable(3);
    result += arg3;
    return result;
}

private async MyAwaitable<int> GetMyAwaitable(int arg)
{
    await Task.Delay(1);//Эмуляция асинхронного исполнения 
    return await new MyAwaitable<int>(arg);
}

Этот код работает, но для понимания сути требований к классу MyAwaitable давайте посмотрим, что препроцессор C# делает с методом MyAwaitableMethod. Если вы запустите какой-нибудь декомпилятор .NET-сборок (например, dotPeek), вы увидите, что оригинальный метод был изменен следующим образом:


private MyAwaitable<int> MyAwaitableMethod()
{
    var stateMachine = new MyAwaitableMethodStateMachine();
    stateMachine.Owner = this;
    stateMachine.Builder = MyAwaitableTaskMethodBuilder<int>.Create();
    stateMachine.State = 0;
    stateMachine.Builder.Start(ref stateMachine);
    return stateMachine.Builder.Task;
}

MyAwaitableMethodStateMachine

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


sealed class MyAwaitableMethodStateMachine : IAsyncStateMachine
{
    public int State;
    public MyAwaitableTaskMethodBuilder<int> Builder;
    public BuilderDemo Owner;
    private int _result;
    private int _arg1;
    private int _arg2;
    private int _arg3;
    private MyAwaitableAwaiter<int> _awaiter1;
    private MyAwaitableAwaiter<int> _awaiter2;
    private MyAwaitableAwaiter<int> _awaiter3;

    private void SetAwaitCompletion(INotifyCompletion awaiter)
    {
        var stateMachine = this;
        this.Builder.AwaitOnCompleted(ref awaiter, ref stateMachine);
    }

    void IAsyncStateMachine.MoveNext()
    {
        int finalResult;
        try
        {
            label_begin:
            switch (this.State)
            {
                case 0:
                    this._result = 0;
                    this._awaiter1 = this.Owner.GetMyAwaitable(1).GetAwaiter();
                    this.State = 1;

                    if (!this._awaiter1.IsCompleted)
                    {
                        this.SetAwaitCompletion(this._awaiter1);
                        return;
                    }
                    goto label_begin;

                case 1:// awaiter1 должен быть завершен
                    this._arg1 = this._awaiter1.GetResult();
                    this._result += this._arg1;

                    this.State = 2;
                    this._awaiter2 = this.Owner.GetMyAwaitable(2).GetAwaiter();

                    if (!this._awaiter2.IsCompleted)
                    {
                        this.SetAwaitCompletion(this._awaiter2);
                        return;
                    }
                    goto label_begin;

                case 2:// awaiter2 должен быть завершен

                    this._arg2 = this._awaiter2.GetResult();
                    this._result += this._arg2;

                    this.State = 3;
                    this._awaiter3 = this.Owner.GetMyAwaitable(3).GetAwaiter();

                    if (!this._awaiter3.IsCompleted)
                    {
                        this.SetAwaitCompletion(this._awaiter3);
                        return;
                    }
                    goto label_begin;

                case 3:// awaiter3 должен быть завершен

                    this._arg3 = this._awaiter3.GetResult();
                    this._result += this._arg3;

                    finalResult = this._result;
                    break;
                default:
                    throw new Exception();
            }
        }
        catch (Exception ex)
        {
            this.State = -1;
            this.Builder.SetException(ex);
            return;
        }

        this.State = -1;
        this.Builder.SetResult(finalResult);
    }
}

Изучив сгенерированный код, мы видим, что Method Builder имеет следующие обязанности:


  1. Организация вызова метода MoveNext() который переводит сгенерированный конечный автомат в следующее состояние.
  2. Создание объекта, который будет представлять контекст асинхронной операции (public MyAwaitable<T> Task { get; })
  3. Реагирование на перевод сгенерированного конечного автомата в финальные состояния: SetResult или SetException.

Другими словами, с помощью Method Builder мы можем получить контроль над тем, как выполняются асинхронные методы, и это выглядит как та возможность, которая поможет нам достичь нашей цели — реализации поведения монады Maybe.


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


Монада Maybe


Вкратце, монада Maybe — это шаблон проектирования, который позволяет прерывать цепочку вызовов функций, если какая-то функция из цепочки не может вернуть осмысленный результат (например, недопустимые входные параметры).


Исторически императивные языки программирования решали эту проблему двумя способами:


  1. Болшое количество условной логики
  2. Исключения

Оба способа имеют очевидные недостатки, поэтому был предложен альтернативный подход:


  1. Создать тип, который может находиться в двух состояниях: "Некое значение" и "Нет значения" ("Nothing") — назовем его Maybe
  2. Создать функцию (назовем ее SelectMany) которая принимает 2 аргумента:
    2.1. Объект типа Maybe
    2.2. Следующую функцию из списка вызовов. Эта функция так же должна вернуть объект типа Maybe, который может содержать какое-то результирующее значение или находиться в состоянии Nothing, если результат не может быть получен (например, в функцию были переданы некорректные параметры)
  3. Функция SelectMany проверяет объект типа Maybe и если он содержит результирующее значение, то этот результат извлекается и передается в качестве аргумента следующей функции из цепочки вызовов (переданной в качестве второго аргумента). Если же объект типа Maybe находится в состоянии Nothing, то SelectMany сразу же вернет Nothing.


В C# это может быть реализовано следующим образом:


public struct Maybe<T>
{
    public static implicit operator Maybe<T>(T value) => Value(value);

    public static Maybe<T>  Value(T value) => new Maybe<T>(false, value);

    public static readonly Maybe<T> Nothing = new Maybe<T>(true, default);

    private Maybe(bool isNothing, T value)
    {
        this.IsNothing = isNothing;
        this._value = value;
    }

    public readonly bool IsNothing;

    private readonly T _value;

    public T GetValue() => this.IsNothing ? throw new Exception("Nothing") : this._value;
}

public static class MaybeExtensions
{
    public static Maybe<TRes> SelectMany<TIn, TRes>(
        this Maybe<TIn> source, 
        Func<TIn, Maybe<TRes>> func)

        => source.IsNothing ? 
            Maybe<TRes>.Nothing : 
            func(source.GetValue());
}

и пример использования:


static void Main()
{
    for (int i = 0; i < 10; i++)
    {
        var res = Function1(i).SelectMany(Function2).SelectMany(Function3);
        Console.WriteLine(res.IsNothing ? "Nothing" : res.GetValue().ToString());
    }

    Maybe<int> Function1(int acc) => acc < 10 ? acc + 1 : Maybe<int>.Nothing;

    Maybe<int> Function2(int acc) => acc < 10 ? acc + 2 : Maybe<int>.Nothing;

    Maybe<int> Function3(int acc) => acc < 10 ? acc + 3 : Maybe<int>.Nothing;
}

Почему "SelectMany"?

Думаю, что некоторые из вас могут задаться вопросом: "Почему автор назвал эту функцию "SelectMany"? На самом деле, для этого есть причина — в C# препроцессор вставляет вызов Select Many при обработке выражений записанных в Query Notation, которая, по сути, является “Синтаксическим сахаром” для сложных цепочек вызовов. (Вы можете найти более подробную информацию об этом в моей предыдущей статье).


На самом деле, мы можем переписать предыдущий код следующим образом:


var res = Function1(i)
    .SelectMany(x2 => 
        Function2(x2).SelectMany(x3 => 
            Function3(x3.SelectMany<int, int>(x4 => 
                x2 + x3 + x4)));

получив таким образом доступ к промежуточному состоянию (x2, x3), что в некоторых случаях может быть весьма удобно. К сожалению, читать такой код весьма затруднительно, но к счастью, в C# есть Query Notation с помощью которой подобный код будет выглядеть гораздо проще:


var res = from x2 in Function1(i)
    from x3 in Function2(x2)
    from x4 in Function3(x3)
    select x2 + x3 + x4;

Для того, чтобы сделать этот код компилируемым, нам понадобится слегка расширить функцию Select Many:


public static Maybe<TJ> SelectMany<TIn, TRes, TJ>(
    this Maybe<TIn> source, 
    Func<TIn, Maybe<TRes>> func, 
    Func<TIn, TRes, TJ> joinFunc)
{
    if (source.IsNothing)
        return Maybe<TJ>.Nothing;

    var res = func(source.GetValue());
    return res.IsNothing 
        ? Maybe<TJ>.Nothing 
        : joinFunc(source.GetValue(), res.GetValue());
}

Вот так будет выглядеть код из заголовка статьи, если его переписать с использованием "классической" реализации "Maybe"
static void Main()
{
    foreach (var s in new[] {"1,2", "3,7,1", null, "1"})
    {
        var res = Sum(s);
        Console.WriteLine(res.IsNothing ? "Nothing" : res.GetValue().ToString());
    }

    Console.ReadKey();
}

static Maybe<int> Sum(string input)
    => Split(input).SelectMany(items => Acc(0, 0, items));

//Рекурсия используется для обработки списка объектов типа "Maybe"
static Maybe<int> Acc(int res, int index, IReadOnlyList<string> array) 
    => index < array.Count 
        ? Add(res, array[index])
            .SelectMany(newRes => Acc(newRes, index + 1, array)) 
        : res;

static Maybe<int> Add(int acc, string nextStr) 
    => Parse(nextStr).SelectMany<int, int>(nextNum => acc + nextNum);

static Maybe<string[]> Split(string str)
{
    var parts = str?.Split(',')
        .Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
    return parts == null || parts.Length < 2 ? Maybe<string[]>.Nothing : parts;
}

static Maybe<int> Parse(string value)
    => int.TryParse(value, out var result) ? result : Maybe<int>.Nothing;

Это код выглядит не очень элегантно, поскольку C# не был изначально спроектирован как функциональный язык, но в "настоящих" функциональных языка подобный подход весьма распространён.


Async Maybe


Суть монады Maybe заключается в управлении цепочкой вызовов функций, но это именно то, что делает async/await. Так что, давайте попробуем объединить их вместе. Во-первых, нам нужно сделать тип Maybe совместимым с асинхронными функциями, и мы уже знаем, как этого достичь:


[AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))]
public class Maybe<T> : INotifyCompletion
{
    ...
    public Maybe<T> GetAwaiter() => this;

    public bool IsCompleted { get; private set; }

    public void OnCompleted(Action continuation){...}

    public T GetResult() =>...
}

Теперь давайте посмотрим, как "классическую" реализацию Maybe можно переписать в виде конечного автомата, чтобы можно было найти какие-либо сходства:


static void Main()
{
    for (int i = 0; i < 10; i++)
    {
        var stateMachine = new StateMachine();
        stateMachine.state = 0;
        stateMachine.i = i;
        stateMachine.MoveNext();

        var res = stateMachine.Result;

        Console.WriteLine(res.IsNothing ? "Nothing" : res.GetValue().ToString());
    }

    Console.ReadKey();
}

class StateMachine
{
    public int state = 0;

    public int i;
    public Maybe<int> Result;

    private Maybe<int> _f1;
    private Maybe<int> _f2;
    private Maybe<int> _f3;

    public void MoveNext()
    {
        label_begin:
        switch (this.state)
        {
            case 0:
                this._f1 = Function1(this.i);
                this.state = Match ? -1 : 1;
                goto label_begin;
            case 1:
                this._f2 = Function2(this._f1.GetValue());
                this.state = this._f2.IsNothing ? -1 : 2;
                goto label_begin;
            case 2:
                this._f3 = Function3(this._f2.GetValue());
                this.state = this._f3.IsNothing ? -1 : 3;
                goto label_begin;
            case 3:
                this.Result = this._f3.GetValue();
                break;
            case -1:
                this.Result = Maybe<int>.Nothing;
                break;
        }
    }
}

Если мы сопоставим этот конечный автомат с сгенерированным препроцессором C# (см. выше «MyAwaitableMethodStateMachine»), мы можем заметить, что проверка состояния Maybe может быть реализована внутри:


this.Builder.AwaitOnCompleted(ref awaiter, ref stateMachine);

где ref awaiter — это объект типа Maybe. Проблема здесь в том, что мы не можем установить автомат в "финальное" (-1) состояние, но значит ли это, что мы не можем контролировать поток выполнения? На самом деле это не так. Дело в том, что для каждого асинхронного действия C# устанавливает функцию обратного вызова продолжения асинхронного действия через интерфейс INotifyCompletion, поэтому, если мы хотим разорвать поток выполнения, мы можем просто вызвать функцию обратного вызова в том случае, когда мы не можем продолжить цепочку асинхронных операций.
Другая проблема здесь состоит в том, что сгенерированный конечный автомат передает следующий шаг (как функцию обратного вызова) текущей последовательности асинхронных операций, но нам нужна функция обратного вызова для исходной последовательности, которая позволил бы обойти все оставшиеся цепочки асинхронных операций (с любого уровня вложенности):



Итак, нам нужно как-то связать текущее вложенное асинхронное действие со его создателем. Мы можем сделать это, используя наш Method Builder, который имеет ссылку на текущую асинхронную операцию — Task. Ссылки на все дочерние асинхронные операции будут передаваться в AwaitOnCompleted (ref awaiter) как awaiter, поэтому нам просто нужно проверить, является ли параметр экземпляром Maybe, и затем установить текущий Maybe как родительский для текущего дочернего действия:


[AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))]
public class Maybe<T> : IMaybe, INotifyCompletion
{
    private IMaybe _parent;

    void IMaybe.SetParent(IMaybe parent) => this._parent = parent;
    ...
}

public class MaybeTaskMethodBuilder<T>
{
    ...
    private void GenericAwaitOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter,
        ref TStateMachine stateMachine) 
        where TAwaiter : INotifyCompletion 
        where TStateMachine : IAsyncStateMachine
    {
        if (awaiter is IMaybe maybe)
        {
            maybe.SetParent(this.Task);
        }
        awaiter.OnCompleted(stateMachine.MoveNext);
    }  
    ...  
}

Теперь все объекты типа Maybe могут быть объединены в иерархию, в результате чего, мы получим доступ к завершающему вызову всей иерархии (метод Exit) из любого узла:


[AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))]
public class Maybe<T> : IMaybe, INotifyCompletion
{
    private Action _continuation;

    private IMaybe _parent;
    ...
    public void OnCompleted(Action continuation)
    {
        ...
        this._continuation = continuation;
        ...
    }
    ...
    void IMaybe.Exit()
    {
        this.IsCompleted = true;

        if (this._parent != null)
        {
            this._parent.Exit();
        }
        else
        {
            this._continuation();
        }
    }
    ...
}

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


Maybe<int> Parse(string str)
    => int.TryParse(str, out var result) ? result : Maybe<int>.Nothing();

Чтобы хранить состояние Maybe, создадим новую отдельную структуру:


public struct MaybeResult
{
    ...
    private readonly T _value;

    public readonly bool IsNothing;

    public T GetValue() 
        => this.IsNothing ? throw new Exception("Nothing") : this._value;
}

[AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))]
public class Maybe<T> : IMaybe, INotifyCompletion
{
    private MaybeResult? _result;
    ...
    internal Maybe() { }//Used in async method

    private Maybe(MaybeResult result) => this._result = result;// "Вычисленный" экземпляр
    ...
}

В момент когда асинхронный конечный автомат вызывает (через Method Builder) метод OnCompleted уже вычисленного экземпляра Maybe и он находится в состоянии Nothing, мы сможем разорвать весь поток:


public void OnCompleted(Action continuation)
{
    this._continuation = continuation;
    if(this._result.HasValue)
    {
        this.NotifyResult(this._result.Value.IsNothing);
    }
}

internal void SetResult(T result)
//Вызывается из "method builder" после завершения асинхронного метода
{
    this._result = MaybeResult.Value(result);
    this.IsCompleted = true;
    this.NotifyResult(this._result.Value.IsNothing);
}

private void NotifyResult(bool isNothing)
{
    this.IsCompleted = true;
    if (isNothing)
    {
        this._parent.Exit();//Разрываем весь поток выполнения
    }
    else
    {
        this._continuation?.Invoke();
    }
}

Теперь остается только один вопрос — как получить результат асинхронного Maybe вне его области действия (любой асинхронный метод, тип возвращаемого значения которого не Maybe). Если вы попытаетесь использовать только ключевое слово await с экземпляром Maybe, то тогда возникнет исключение, вызываемое этим кодом:


[AsyncMethodBuilder(typeof(MaybeTaskMethodBuilder<>))]
public class Maybe<T> : IMaybe, INotifyCompletion
{
    private MaybeResult? _result;

    public T GetResult() => this._result.Value.GetValue();
}
...
public struct MaybeResult
{
    ...
    public T GetValue() 
        => this.IsNothing ? throw new Exception("Nothing") : this._value;
}

Чтобы решить эту проблему, мы можем просто добавить новый awaiter, который будет возвращать всю структуру MaybeResult целиком, и тогда мы сможем написать такой код:


var res = await GetResult().GetMaybeResult();

if(res.IsNothing){
    ...
}
else{
    res.GetValue();
    ...
};

Пока это все. В примерах кода я опустил некоторые детали, чтобы сосредоточиться только на самых важных частях. Вы можете найти полную версию на github.


На самом деле, я бы не рекомендовал использовать вышеописанный подход в любом рабочем коде, поскольку он имеет одну существенную проблему — когда мы разрываем поток выполнения, вызывая продолжение корневой асинхронной операции (с типом Maybe), мы перерывам вообще ВСЕ! включая все блоки finally (это ответ на вопрос «Всегда ли вызываются блоки finally?»), поэтому все операторы using не будут работать должным образом, что может привести к утечке ресурсов. Эта проблема может быть решена, если вместо прямого вызова продолжения мы сгенерируем специальное исключение, которое будет неявным образом обрабатываться (здесь вы можете найти эту версию), но это решение, очевидно, имеет ограничение по производительности (что может быть приемлемо в некоторых сценариях). В текущей версии компилятора C# я не вижу другого решения, но возможно это когда-нибудь измениться в будущем.


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

Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 29

    +1
    1. Болшое количество условной логики
    2. Исключения
    Оба способа имеют очевидные недостатки

    Чуть подробнее если можно
      +2

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

        +1
        Я могу придумать кучу недостатков, но интересно что автор имеет в виду :)
          0

          А если подойти с другой стороны, то для чего автор тащит монады в c#? Какую проблему они решают? "Основное назначение — инкапсуляция функций с побочным эффектом от чистых функций, а точнее, их выполнений от вычислений." (Определение из википедии). Тем самым мы отделяем логику обработки ошибок от самих типов.

          +1
          Кроме того, можно и вовсе забыть сделать проверку возвращаемого значения, и получить какой-нибудь NullReferenceException в Runtime-е (это в лучшем случае)
            0
            А в случае если результат будет IsNothing(), то в рантайме мы что получим без его обработки?
              0

              IsNothing() — можно обработать только в самом конце цепочки вызовов, иначе пришлось бы делать проверку на каждом этапе, например:


              int? Sum(string arg1, string arg2, string arg3)
              {
                  int result = 0;
                  int tmp;
              
                  if(!int.TryParse(arg1, out tmp))
                  {                
                      return null;
                  }
                  result += tmp;
              
                  if(!int.TryParse(arg2, out tmp))
                  {                
                      return null;
                  }
                  result += tmp;
              
                  if(!int.TryParse(arg3, out tmp))
                  {                
                      return null;
                  }
                  result += tmp;
              
                  return result;
              }

              вместо:


              Maybe<int> Sum(string arg1, string arg2, string arg3)
              {
                  int result = 0;
              
                  result += await MaybeParse(arg1);
                  result += await MaybeParse(arg2);
                  result += await MaybeParse(arg3);            
              }
                0
                1. Так ошибка в рантайме всёравно будет (или на этапе передачи в СУБД или ещё куда-то) или надо где-то всёравно обработать IsNothing().
                2. А как понять источник «проблемы», если обнаружение этой проблемы происходит в самом конце?
                3.
                result += await MaybeParse(arg1);

                Операцию сложения int с Maybe я ведь должен определять каждый раз? Или все операции с IsNothing() дают IsNothing()?
                  0
                  1. Где-то обработать из IsNothing(), безусловно, придётся. Но это будет всего одна проверка вместо N+1
                  2. Паттерн "Монада Maybe" применяется там где отсутствие (правильного?) значения не является проблемой и допускается логикой программы. Но, кстати, ничто не мешает добавить описание причины остановки выполнения цепочки вызова в MaybeResult, например:
                    Maybe<int> Parse(string str)
                    => int.TryParse(str, out var result) 
                        ? result 
                        : Maybe<int>.Nothing($"Could not parse: {str}");
                  3. await MaybeParse(arg1) возвращает int
                    0
                    1. Так и исключение только один раз можно обработать
                    2.
                    там где отсутствие (правильного?) значения не является проблемой и допускается логикой программы

                    Т.е. там где стоит if() на такое значение?
                    ничто не мешает добавить описание причины остановки выполнения

                    Ну т.е. надо внедрять протоколирование активно, как обычно, вы только об этом не написали ничего. А потом пойди разбери откуда какой str пришёл.
                    Вы это всё на практике применяете ежедневно?
                    Напишите дополнение про боли которые это всё доставляет в продакшене. Не внедряется это всё безболезненно.
                    3. А когда же MaybeParse() вернёт Maybe? Мы же про уход от if(), а это влечёт работу с Maybe
                      0
                      1. Про исключения тут уже есть комментарии. Вкратце, не нужно их использовать для реализации программной логики.
                      2. Maybe помогает избавится от избыточной условной логики, там где она действительно избыточна. Безусловно, можно привести 100500 примеров (с обоснованием), где применять Maybe не стоит, поэтому не надо применять Maybe (как, в принципе, и любой другой паттерн, подход, фреймворк и т. д.) там, где ее применять не стоит.
                      3. MaybeParse() вернет Maybe, await MaybeParse() вернет либо int, либо завершит выполнение текущей асинхронной функции (в примере это "Sum") с результатом "Nothing" (если её тип Maybe). Если её тип не Maybe, а например Task то надо вызвать await MaybeParse().GetMaybeResult() что бы получить структуру в которой будет финальный результат или "Nothing".
                        0
                        1. Вы про производительность? Ну а не надо их бросать в цикле от которого ожидается высокая производительность.
                        Так эта статья о производительности?
                        2. Так в каких ситуациях это пользу принесёт? Или это теоретическая статья, вы про практику использования не написали.
                        Пожалейте новичков, пишите что это теория.
                        3. У вас был пример со сложением int + результат MaybeParse, почему-то вы не поняли в чём суть вопроса, забудем.

                        Постоянно пытаюсь из статей о ФП извлечь:
                        1. Чувство эстетического удовлетворения от применения ФП
                        2. И пользу для проектов и команды
                        Но каждая статья начинается с объяснения, что такое монады, зачем монады, примеры вида 1+2+3 и у меня ничего полезного не складывается в голове.
                        Хочется статью которая начинается с «Мы год использовали ФП, на таком то проекте… 2 члена команды чеканулись, 3 просветлели, ПМ стал улыбчивее, и мы сдали проект на 2 месяца раньше дедлайна».
                        Очень интересен именно такой опыт.
            +1

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


            Ну, для меня проблема. Не факт, что для автора оно так же.

              0
              Когда вариантов исключений / типов возможных возвращаемых ошибок (именно типов, а не значений некоторого типа) становится слишком много и их надо протащить через сигнатуру, это тоже становится проблемой.
                +3
                А зачем их видеть в сигнатурах функций? Исключения, должны (в теории) сигнализировать о нештатной работе программы, которая может быть вызвана:

                1) Ошибкой программе – тут поможет только выпуск новой версии с исправлением
                2) Ошибкой конфигурации – например, неправильный Connection String
                3) Внешней проблемой среды — например, отсутствует соединение с сетью или
                нехватка памяти.

                Все эти сценарии непредсказуемы и могут возникнуть при вызове абсолютно любой функции. Реакцией на них может быть только корректное завершение текущего логического действия (http запрос, событие onclick и т.д.) с извещением пользователя о нештатной ситуации.

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

            Что будет с using-ами внутри таких методов?
            Условно


            async MayBe<bool> WriteTo(string path)
            {
                  using(var f = File.OpenWrite(path);
                 {
                       f.Write(Header, 0, Header.Length);
                       var data = await GetData();
                       f.Write(data, 0, data);
                 }
            }
            

            где GetData возвращает MayBe<byte[]>. Правильно ли я понимаю, что предложенное решение оставит метод в "подвешенном состоянии" и Dispose на файле не будет вызван?

              0
              Если GetData() НЕ вернет Mabe.Nothing, то Dispose будет вызван, в противном же случае, вызова Dispose не будет. Об этом есть пара предложений в конце статьи
                +3

                Ну то есть предложенная методика вообще-то нарушает ряд гарантий языка и представляет из себя минное поле.

                  0
                  Предложенная методика — да, нарушает, но есть вариант который внутри себя использует исключения, и в нем все нормально с using-ами. Единственная его проблема — это, собственно, сам факт использования исключений.
                    +1

                    Не единственная. Там производительность идет далеко-далеко в лес.

                      +1
                      Об этом и речь
                        +1

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

                0

                Монады разве не разворачиваются как результат текущей монады + передача результата в следующую? Предположу что dispose будет работать как и следует, пока не получим исключение в цепочке вызовов. Скорее всего после выполнения последней цепочки и будет вызван dispose.

                +2
                ИМХО, именно Maybe лучше реализовывать без await. Я когда-то делал это используя вот эту статью как источник вдохновения — habr.com/ru/post/183150 [1]. Просто брал и максимально близко переписывал Haskell «методы» на C#

                Ваш пример мог бы выглядеть вот так:
                void Main()
                {
                  foreach (var s in new[] {"1,2", "3,7,1", null, "1"})
                  {
                      // Map - это fmap из [1]
                      //    Должно быть несколько перегрузок
                      //      Maybe<TR> Map<TR>(Func<T, TR> transform)
                      //      Maybe<TR> Map<TR>(Func<T, Maybe<TR>> transform)
                      //    Вторая перегрузка нужна чтобы не получался дабл-Maybe
                      //
                      // Or - это аналог C# оператора ?? для Maybe<T>
                      //   Опять же надо иметь несколько перегрузок
                      //     T Or(T valueIfNothing) - тут все очевидно
                      //     T Or(Func<T> valueFactoryIfNothing) - вызывать фабрику только ес. надо
                      //     Maybe<T> Or(Maybe<T> valueIfNothing) - chaining - mb1.Or(mb2).Or(val)
                      //     Maybe<T> Or(Func<Maybe<T>> valueFactoryIfNothing) - то же для фабрик
                      //
                      var res = Sum(s).Map(t => t.ToString()).Or("Nothing");       
                      Console.WriteLine(res); // 3, 11, Nothing, Nothing
                  }
                }
                
                Maybe<int> Sum(string input)
                {
                    // Здесь Map - это все еще fmap из [1], но теперь это extension method
                    // Принимает IEnumerable<Maybe<T>>, делает Map (см. выше) по-элементно
                    return Split(input).Map(Parse);
                }
                
                Maybe<string[]> Split(string str)
                {
                  var parts = str?.Split(',').Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
                
                  // Just.Nothing() - возвращает "пустую" (без полей) структуру Nothing
                  // Он должна быть implicitly convertible в "пустой" (без значения) Maybe<T>
                  // Не-дженерик Nothing тип это ОК: пустота и в Африке пустота ("не имеет" типа)
                  // 
                  // ** Не очень понятно почему Split("1") возвращает Nothing, но у Вас именно так. 
                  //    Скопировал эту логику.
                  return parts == null || parts.Length < 2 ? Just.Nothing() : parts;
                }
                
                Maybe<int> Parse(string str)
                {
                  return int.TryParse(str, out var result) ? result : Just.Nothing();
                }
                
                  +1

                  Поправочка для Sum — забыл сложение


                  Split(input).Map(int.Parse).Aggregate(Maybe.Apply((int x, int y) => x + y));


                  Maybe.Apply здесь возвращает функцию складывающую Maybe. Если любой из параметров пустой, оператор сложения не вызывается- вместо этого возвращается Nothing. Вместо сложения может быть что угодно.

                    0
                    «именно Maybe лучше реализовывать без await»

                    C этим утверждением согласен, поскольку именно Maybe на 100% правильно реализовать через await не удается, об этом сказано в конце статьи. Но, все же вариант с await, мне кажется, лучше, поскольку избавляет нас от лямбд, что положительно сказывается на читабельности программы.
                      0

                      Ну лямбды можно выносить в функции если не гнаться за однострочечностью


                      Split(input).Map(int.Parse).Aggregate(Maybe.Apply(Sum);


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


                      Split(input).Map(int.Parse).MaybeAggregate(Sum);


                      Или даже


                      Split(input).Map(int.Parse).MaybeSum()


                      Это в целом от стиля зависит: Maybe + Map + Apply + Bind + обвязка над стандартным LINQ типа MaybeAggregate позволяют творить LINQ-чудеса и склеивать функции принимающие Maybe и обычные аргументы произвольным образом. Я просто не люблю foreach и циклы для меня функциональный LINQ-style код читается сильно проще. Обратите внимание что последний пример читается почти как полноценное как английское преложение

                        0

                        Идея кстати та же — IEnumerable это монада, как и async/await

                    0

                    ФП это хорошо, но с учетом экосистемы и системы типов сишарпа писать на нем будет больно. Плюсы парадигмы не перевесят каждодневного ада. Все равно что ООП в pure C. Берите подходящий язык, и все будет хорошо. А иначе только боль и страдания.

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое