Mônada “Talvez” através de assíncrono / espera em C # (sem tarefas!)


Tipos de retorno assíncronos generalizados - é um novo recurso do C # 7 que permite usar não apenas a Tarefa como um tipo de retorno de métodos assíncronos , mas também outros tipos (classes ou estruturas) que atendem a alguns requisitos específicos.


Ao mesmo tempo, assíncrono / espera é uma maneira de chamar um conjunto de funções de "continuação" dentro de algum contexto, que é a essência de outro padrão de design - o Monad . Então, podemos usar async / waitit para escrever um código que se comportará da mesma maneira como se usássemos mônadas? Acontece que - sim (com algumas reservas). Por exemplo, o código abaixo é compilável e está funcionando:


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);//No result checking var result = 0; foreach (var arg in args) result += await Parse(arg);//No result checking 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(); 

Além disso, explicarei como o código funciona ...


Tipos de retorno assíncrono generalizado


Antes de tudo, vamos descobrir o que é necessário para usar nosso próprio tipo (por exemplo, MyAwaitable <T> ) como resultado do tipo de alguma função assíncrona. A documentação diz que esse tipo deve ter:


  1. GetAwaiter () que retorna um objeto de um tipo que implementa a interface INotifyCompletion e possui a propriedade booleana IsCompleted e o método T GetResult () ;


  2. Atributo [AsyncMethodBuilder (Type)] que aponta para uma classe ( ou estrutura ) de "construtor de métodos", por exemplo, MyAwaitableTaskMethodBuilder <T> com os seguintes métodos:


    • estático Create ()
    • Iniciar (stateMachine)
    • SetResult (resultado)
    • SetException (exceção)
    • SetStateMachine (stateMachine)
    • AwaitOnCompleted (garçom, stateMachine)
    • AwaitUnsafeOnCompleted (garçom, stateMachine)
    • Tarefa


Aqui está uma implementação simples do MyAwaitable e 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; } } 

Agora podemos usar o MyAwaitable como resultado do tipo de métodos assíncronos:


 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);//Simulate asynchronous execution return await new MyAwaitable<int>(arg); } 

O código funciona conforme o esperado, mas para entender uma finalidade dos requisitos para MyAwaitable, vamos dar uma olhada no que o pré-processador C # faz com MyAwaitableMethod . Se você executar algum utilitário de descompilação (por exemplo, dotPeek), verá que o método original foi alterado da seguinte maneira:


 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

Na verdade, é um código simplificado no qual omito muitas otimizações para tornar um código gerado pelo compilador legível


 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 should be completed 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 should be completed 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 should be completed 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); } } 

Revendo o código gerado, podemos ver que o "construtor de métodos" tem as seguintes responsabilidades:


  1. Planejando a chamada do método MoveNext () da máquina de estado quando uma operação assíncrona filha é concluída (no cenário mais simples, passamos MoveNext () para OnCompleted () do garçom de operação assíncrona).
  2. Criação de um objeto de contexto de operação assíncrona ( public MyAwaitable<T> Task { get; } )
  3. Reagindo nos estados finais das máquinas de estado geradas: SetResult ou SetException .

Em outras palavras, com os "construtores de métodos", podemos controlar como os métodos assíncronos são executados e parece um recurso que nos ajudará a alcançar nosso objetivo - uma implementação do comportamento da mônada Maybe . Mas o que há de bom nessa mônada? Bem ... você pode encontrar muitos artigos sobre essa mônada na Internet, então aqui vou descrever apenas o básico.


Talvez mônada


Em resumo, talvez a mônada seja um padrão de design que permita a interrupção de uma cadeia de chamadas de funções se alguma função da cadeia não puder produzir um resultado valioso (por exemplo, erro de análise).


Historicamente, linguagens de programação imperativas têm resolvido o problema de duas maneiras:


  1. Lógica condicional
  2. Exceções

As duas maneiras têm desvantagens óbvias, então uma terceira maneira foi inventada:


  1. Crie um tipo que pode estar em 2 estados: "Some Value" e "Nothing" - vamos chamá-lo de "Maybe"
  2. Crie uma função (vamos chamá-la de "SelectMany") que recupera 2 argumentos:
    2.1 Um objeto do tipo "Talvez"
    2.2 Uma próxima função do conjunto de chamadas - a função também deve retornar um objeto "Maybe" que conterá um resultado ou "Nothing" se o resultado não puder ser avaliado (por exemplo, os parâmetros da função não estão no formato correto)
  3. A função "SelectMany" verifica se "Maybe" tem algum valor e, em seguida, chama a próxima função usando o valor (extraído de "Maybe") como argumento e, em seguida, retorna seu resultado, caso contrário, retorna um objeto "Maybe" no estado "Nothing" .


Em C #, pode ser implementado assim:


 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()); } 

e uso:


 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; } 

Por que 'SelectMany'?

Eu acho que alguns de vocês podem fazer uma pergunta: "Por que o autor chamou a função" SelectMany "? É um nome tão estranho". É, mas há uma razão para isso. No C #, SelectMany é usado pelo pré-processador para oferecer suporte à notação de consulta que simplifica o trabalho com cadeias de chamadas (você pode encontrar mais detalhes no meu artigo anterior ).


De fato, podemos alterar a cadeia de chamadas da seguinte maneira:


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

para que possamos ter acesso a todos os resultados intermediários, o que é conveniente, mas o código é difícil de ler.


Aqui a notação de consulta nos ajuda a:


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

Para tornar o código compilável, precisamos de uma versão aprimorada de "Selecionar muitos"


 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()); } 

Vamos implementar o programa a partir do cabeçalho do artigo usando esta implementação 'clássica' Talvez '
 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)); //Recursion is used to process a list of "Maybes" 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; 

O código não parece ótimo, pois o C # não foi projetado como uma linguagem funcional, mas em linguagens funcionais "verdadeiras" como Haskell, essa abordagem é muito comum.


Async talvez


A essência da Mônada Maybe é controlar uma cadeia de chamadas de funções, mas é exatamente o que "async / waitit" faz. Então, vamos tentar combiná-los. Primeiro, precisamos tornar o tipo Maybe compatível com funções assíncronas e já sabemos como fazer isso:


 [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() =>... } 

Agora, vamos dar uma olhada em como o "Talvez clássico" pode ser reescrito como uma máquina de estado para encontrar semelhanças:


 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; } } } 

Se combinarmos essa máquina de estado com a gerada pelo pré-processador C # (veja acima - 'MyAwaitableMethodStateMachine'), podemos notar que Talvez a verificação de estado possa ser implementada dentro:


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

onde ref awaiter é um objeto do tipo Talvez . O único problema aqui é que não podemos colocar a máquina no estado "final" (-1). Isso significa que não podemos controlar o fluxo de execução? Na verdade, não. O fato é que, para cada ação assíncrona, o C # define uma ação de retorno de chamada através da interface INotifyCompletion , portanto, se queremos interromper um fluxo de execução, podemos chamar a ação de retorno de chamada em um caso em que não podemos continuar o fluxo.
Outro desafio aqui é que a máquina de estados gerada passa a próxima ação (como retorno de chamada de continuação) de um fluxo atual, mas precisamos de um retorno de chamada de continuação do fluxo inicial que permita ignorar o restante das operações assíncronas:



Portanto, precisamos conectar de alguma forma uma ação assíncrona filho com seus ancestrais. Podemos fazer isso usando nosso "construtor de métodos", que possui um link para uma operação assíncrona atual - Task . Os links para todas as operações assíncronas AwaitOnCompleted(ref awaiter serão passados ​​para AwaitOnCompleted(ref awaiter como garçom , portanto, basta verificar se o parâmetro é uma instância de Maybe e se é então definir o atual Maybe como pai da criança:


 [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); } ... } 

Agora todos os objetos Talvez podem ser unidos em uma árvore e, como resultado, teremos acesso à continuação da raiz Maybe (método Exit ) de qualquer nó descendente:


 [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(); } } ... } 

Esse método Exit deve ser chamado quando (durante a movimentação sobre a árvore) encontramos um objeto Talvez já resolvido no estado Nothing . Tais objetos Talvez podem ser retornados por um método como este:


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

Para armazenar um estado de resolvido Talvez vamos apresentar uma nova estrutura separada:


 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;// "Resolved" instance ... } 

Quando uma máquina de estado assíncrona chama (por meio do construtor de métodos) o método OnCompleted de uma instância Maybe já resolvida e ela está no estado Nothing , poderemos interromper um fluxo inteiro:


 public void OnCompleted(Action continuation) { this._continuation = continuation; if(this._result.HasValue) { this.NotifyResult(this._result.Value.IsNothing); } } internal void SetResult(T result) //Is called by a "method builder" when an async method is completed { 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();//Braking an entire flow } else { this._continuation?.Invoke(); } } 

Agora, a única coisa que resta - como obter um resultado de uma assíncrona Talvez fora do seu escopo (qualquer método assíncrono cujo tipo de retorno não seja Talvez ). Se você tentar usar just wait palavra-chave com uma instância Maybe , uma exceção será lançada devido a este código:


 [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; } 

Para resolver o problema, basta adicionar um novo Awaiter que retornaria uma estrutura MaybeResult inteira e poderemos escrever um código como este:


 var res = await GetResult().GetMaybeResult(); if(res.IsNothing){ ... } else{ res.GetValue(); ... }; 

Por enquanto é tudo. Nos exemplos de código, omito alguns detalhes para focar apenas nas partes mais importantes. Você pode encontrar uma versão completa no github .


No entanto , eu não recomendaria o uso desta versão em nenhum código de produção, pois há um problema significativo - quando travamos um fluxo de execução chamando uma continuação da raiz Talvez possamos ignorar TUDO! incluindo todos os blocos finalmente (é uma resposta à pergunta "Os blocos são finalmente chamados sempre?"), portanto, todos os operadores que usam não funcionarão conforme o esperado e isso pode levar ao vazamento de recursos. O problema pode ser resolvido se, em vez de chamar o retorno de chamada de continuação inicial, lançarmos uma exceção especial que será tratada internamente ( aqui você pode encontrar a versão ), mas essa solução aparentemente possui imitações de desempenho (o que pode ser aceitável em alguns cenários). Com a versão atual do compilador C #, não vejo outra solução, mas isso poderá ser alterado no futuro.


Essas limitações não significam que todos os truques descritos neste artigo são completamente inúteis, eles podem ser usados ​​para implementar outras mônadas que não exigem alterações nos fluxos de execução, por exemplo "Leitor". Como implementar essa mônada "Reader" através de assíncrono / espera , mostrarei no próximo artigo .

Source: https://habr.com/ru/post/pt458692/


All Articles