O Maybe Monad via assíncrono / espera em C # (sem a tarefa Ov!)


Os tipos de retorno assíncrono genérico são um novo recurso introduzido no C # 7 que permite usar não apenas a Tarefa como o tipo de retorno dos métodos assíncronos ( assíncronos / aguardar ), mas também quaisquer outros tipos (classes ou estruturas) que atendam a certos requisitos.


Ao mesmo tempo, assíncrono / espera é uma maneira de chamar seqüencialmente um certo conjunto de funções dentro de um determinado contexto, que é a essência do padrão de design da Mônada . A questão é: podemos usar async / waitit para escrever código que se comporta como se estivéssemos usando mônadas? Acontece que sim (com algumas reservas). Por exemplo, o código abaixo compila e funciona:


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

Em seguida, explico como esse código funciona ...


Tipos de retorno assíncrono genérico


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


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


  2. [AsyncMethodBuilder (Type)] - um atributo que indica o tipo que atuará como o " Method Builder ", por exemplo MyAwaitableTaskMethodBuilder <T> . Este tipo deve conter nos seguintes métodos:


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


Um exemplo de uma implementação simples de 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 MyAwaitable como o tipo de resultado dos 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);//   return await new MyAwaitable<int>(arg); } 

Esse código funciona, mas para entender a essência dos requisitos da classe MyAwaitable, vamos ver o que o pré-processador C # faz com o método MyAwaitableMethod . Se você executar algum descompilador do compilador .NET (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, esse é um código simplificado, onde pulo muitas otimizações para tornar o 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    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); } } 

Após examinar o código gerado, vemos que o Construtor de Métodos tem as seguintes responsabilidades:


  1. Organização de uma chamada para o método MoveNext () que transfere a máquina de estado gerada para o próximo estado.
  2. Criando um objeto que representará o contexto de uma operação assíncrona ( public MyAwaitable<T> Task { get; } )
  3. Respondendo à conversão da máquina de estados gerada nos estados finais: SetResult ou SetException .

Em outras palavras, com a ajuda do Method Builder , podemos obter controle sobre como os métodos assíncronos são executados, e isso parece uma oportunidade que nos ajudará a alcançar nosso objetivo - a implementação do comportamento da mônada Maybe .


Mas o que há de tão bom nessa mônada? ... Na verdade, 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 mônada seja um padrão de design que permita interromper a cadeia de chamadas de função se alguma função da cadeia não puder retornar um resultado significativo (por exemplo, parâmetros de entrada inválidos).


Linguagens de programação historicamente imperativas resolveram esse problema de duas maneiras:


  1. Muita lógica condicional
  2. Exceções

Ambos os métodos têm desvantagens óbvias; portanto, uma abordagem alternativa foi proposta:


  1. Crie um tipo que possa estar em dois estados: "Algum valor" e "Nenhum valor" (" Nada ") - vamos chamá-lo Talvez
  2. Crie uma função (vamos chamá-la SelectMany ) que recebe 2 argumentos:
    2.1 Talvez objeto
    2.2 A próxima função da lista de chamadas. Essa função também deve retornar um objeto do tipo Maybe , que pode conter algum tipo de valor resultante ou estar no estado Nothing se o resultado não puder ser obtido (por exemplo, parâmetros incorretos foram passados ​​para a função)
  3. A função SelectMany verifica um objeto do tipo Maybe e se ele contém o valor resultante, esse resultado é extraído e passado como argumento para a próxima função da cadeia de chamadas (passada como o segundo argumento). Se o objeto Talvez estiver no estado Nada , o SelectMany retornará imediatamente o Nada .


Em C #, isso pode ser implementado da seguinte maneira:


 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 exemplo de 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 o "SelectMany"?

Eu acho que alguns de vocês podem se perguntar: "Por que o autor chamou essa função de" SelectMany "? Na verdade, há uma razão para isso - em C # o pré-processador insere uma chamada Select Many ao processar expressões escritas na Query Notation , que, em essência, é “Açúcar sintático” para cadeias complexas de chamadas (você pode encontrar mais informações sobre isso no meu artigo anterior ).


De fato, podemos reescrever o código anterior da seguinte maneira:


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

obtendo acesso ao estado intermediário (x2, x3), que em alguns casos pode ser muito conveniente. Infelizmente, a leitura desse código é muito difícil, mas felizmente, o C # possui uma Notação de Consulta com a ajuda da qual esse código parecerá muito mais fácil:


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

Para compilar esse código, precisamos expandir um pouco a função 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()); } 

É assim que o código do título do artigo será exibido se você o reescrever usando a 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)); //       "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; 

Esse código não parece muito elegante, pois o C # não foi originalmente projetado como uma linguagem funcional, mas essa abordagem é bastante comum em linguagens funcionais "reais".


Async talvez


A essência da Mônada Maybe é controlar a cadeia de chamadas de função, mas é exatamente isso que o 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 ver como a implementação "clássica" talvez possa ser reescrita como uma máquina de estados finitos, para que possamos 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 compararmos esta máquina de estado com o pré-processador C # gerado (consulte “MyAwaitableMethodStateMachine” acima), podemos notar que Talvez a verificação de status possa ser implementada dentro:


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

onde ref awaiter é um objeto do tipo Talvez . O problema aqui é que não podemos definir a máquina para o estado "final" (-1), mas isso significa que não podemos controlar o fluxo de execução? Este não é realmente o caso. O fato é que, para cada ação assíncrona, o C # configura uma função de retorno de chamada para continuar a ação assíncrona por meio da interface INotifyCompletion , portanto, se queremos interromper o fluxo de execução, podemos simplesmente chamar a função de retorno de chamada quando não podemos continuar a cadeia de operações assíncronas.
Outro problema aqui é que a máquina de estados gerada passa a próxima etapa (como uma função de retorno de chamada) para a sequência atual de operações assíncronas, mas precisamos de uma função de retorno de chamada para a sequência original que nos permita ignorar todas as cadeias restantes de operações assíncronas (de qualquer nível de aninhamento) :



Portanto, precisamos associar de alguma forma a ação assíncrona aninhada atual ao seu criador. Podemos fazer isso usando nosso Method Builder , que possui um link para a 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, em seguida, definir o Maybe atual como pai da ação filho atual:


 [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 do tipo Talvez podem ser combinados em uma hierarquia, como resultado, teremos acesso à chamada final de toda a hierarquia (método Exit ) a partir de qualquer nó:


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

O método Exit deve ser chamado quando, ao navegar pela hierarquia, encontramos o objeto Talvez já calculado no estado Nothing . Tais objetos Maybe podem ser retornados por métodos como este:


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

Para armazenar o estado Talvez , crie 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;// ""  ... } 

No momento em que a máquina de estado assíncrona chama (via Construtor de Métodos ) o método OnCompleted da instância Maybe já calculada e está no estado Nothing , podemos interromper o 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) //  "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(); } } 

Agora resta apenas uma pergunta - como obter o resultado do talvez assíncrono fora do seu escopo (qualquer método assíncrono cujo tipo de retorno não seja Talvez ). Se você tentar usar apenas a palavra-chave wait com a instância Maybe , uma exceção será lançada por 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 esse problema, podemos simplesmente adicionar um novo garçom que retornará toda a estrutura MaybeResult como um todo e, em seguida, podemos escrever este código:


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

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


Na verdade , eu não recomendaria o uso da abordagem acima em nenhum código de trabalho, pois ele tem um problema significativo - quando interrompemos o encadeamento de execução, causando a continuação da operação assíncrona raiz (com o tipo Maybe ), interrompemos ALL! incluindo todos os blocos finalmente (esta é a resposta para a pergunta “Os blocos finalmente são sempre chamados?”), portanto, todas as instruções de uso não funcionarão corretamente, o que pode resultar em um vazamento de recursos. Esse problema pode ser resolvido se, em vez de chamar diretamente a continuação, criaremos uma exceção especial que será tratada implicitamente ( aqui você pode encontrar esta versão ), mas essa solução obviamente possui um limite de desempenho (que pode ser aceitável em alguns cenários). Na versão atual do compilador C #, não vejo outra solução, mas talvez isso mude um dia no futuro.


No entanto, essas restrições não significam que todas as técnicas descritas neste artigo são completamente inúteis; elas podem ser usadas para implementar outras mônadas que não exigem alterações nos encadeamentos, por exemplo, "Leitor". Como implementar esta mônada "Reader" através de async / waitit , mostrarei no próximo artigo .

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


All Articles