The Maybe Monad a través de async / wait en C # (¡sin Task ov!)


Los tipos de retorno asincrónicos genéricos son una nueva característica introducida en C # 7 que le permite usar no solo Task como el tipo de retorno de métodos asincrónicos ( asíncronos / en espera ), sino también cualquier otro tipo (clases o estructuras) que satisfaga ciertos requisitos.


Al mismo tiempo, async / await es una forma de llamar secuencialmente un cierto conjunto de funciones dentro de un determinado contexto, que es la esencia del patrón de diseño de Monad . La pregunta es, ¿podemos usar async / await para escribir código que se comporte como si estuviéramos usando mónadas? Resulta que sí (con algunas reservas). Por ejemplo, el siguiente código compila y 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(); 

A continuación, explico cómo funciona este código ...


Tipos de retorno asincrónico genérico


En primer lugar, descubramos qué se requiere para usar nuestro propio tipo (por ejemplo, la clase MyAwaitable <T> ) como el tipo de resultado de alguna función asincrónica. La documentación dice que este tipo debería tener:


  1. Método GetAwaiter () , que devuelve un objeto de tipo que implementa la interfaz INotifyCompletion , y también tiene la propiedad boCom IsCompleted y el método T GetResult () ;


  2. [AsyncMethodBuilder (Type)] : un atributo que indica el tipo que actuará como " Method Builder ", por ejemplo MyAwaitableTaskMethodBuilder <T> . Este tipo debe contener los siguientes métodos:


    • Crear estático ()
    • Inicio (stateMachine)
    • SetResult (resultado)
    • SetException (excepción)
    • SetStateMachine (stateMachine)
    • AwaitOnCompleted (camarero, stateMachine)
    • AwaitUnsafeOnCompleted (camarero, stateMachine)
    • Tarea


Un ejemplo de una implementación simple de MyAwaitable y 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; } } 

Ahora podemos usar MyAwaitable como el tipo de resultado de métodos asincrónicos:


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

Este código funciona, pero para comprender la esencia de los requisitos para la clase MyAwaitable, veamos qué hace el preprocesador de C # con el método MyAwaitableMethod . Si ejecuta algún descompilador de compilador .NET (por ejemplo, dotPeek), verá que el método original ha cambiado de la siguiente manera:


 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

Este es en realidad un código simplificado, donde omito muchas optimizaciones para que el código generado por el compilador sea legible


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

Después de examinar el código generado, vemos que Method Builder tiene las siguientes responsabilidades:


  1. Organización de una llamada al método MoveNext () que transfiere la máquina de estado generada al siguiente estado.
  2. Crear un objeto que representará el contexto de una operación asincrónica ( public MyAwaitable<T> Task { get; } )
  3. Respondiendo a la traducción de la máquina de estado generada a estados finales: SetResult o SetException .

En otras palabras, con la ayuda de Method Builder podemos obtener control sobre cómo se ejecutan los métodos asincrónicos, y esto parece una oportunidad que nos ayudará a lograr nuestro objetivo: la implementación del comportamiento de Mónada Quizás .


Pero, ¿qué tiene de bueno esta mónada? ... De hecho, puede encontrar muchos artículos sobre esta mónada en Internet, por lo que aquí describiré solo los conceptos básicos.


Quizás mónada


En resumen, Quizás monad es un patrón de diseño que le permite interrumpir la cadena de llamadas a funciones si alguna función de la cadena no puede devolver un resultado significativo (por ejemplo, parámetros de entrada no válidos).


Los lenguajes de programación históricamente imperativos han resuelto este problema de dos maneras:


  1. Mucha lógica condicional
  2. Excepciones

Ambos métodos tienen desventajas obvias, por lo que se ha propuesto un enfoque alternativo:


  1. Cree un tipo que pueda estar en dos estados: "Algún valor" y "Sin valor" (" Nada "). Llamémoslo Quizás
  2. Cree una función (llamémosla SelectMany ) que tome 2 argumentos:
    2.1. Tal vez objeto
    2.2. La siguiente función de la lista de llamadas. Esta función también debe devolver un objeto de tipo Maybe , que puede contener algún tipo de valor resultante o estar en el estado Nothing si no se puede obtener el resultado (por ejemplo, se pasaron parámetros incorrectos a la función)
  3. La función SelectMany verifica un objeto de tipo Quizás y si contiene el valor resultante, este resultado se extrae y se pasa como argumento a la siguiente función desde la cadena de llamadas (se pasa como el segundo argumento). Si el objeto Quizás está en el estado Nothing , entonces SelectMany inmediatamente devolverá Nothing .


En C #, esto se puede implementar de la siguiente manera:


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

y ejemplo 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 qué es "SelectMany"?

Creo que algunos de ustedes se preguntarán: "¿Por qué el autor llamó a esta función" SelectMany "? En realidad, hay una razón para esto: en C #, el preprocesador inserta una llamada Select Many al procesar expresiones escritas en Notación de consulta , que, en esencia, es "Azúcar sintáctico" para cadenas complejas de llamadas (puede encontrar más información sobre esto en mi artículo anterior ).


De hecho, podemos reescribir el código anterior de la siguiente manera:


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

obteniendo así acceso al estado intermedio (x2, x3), que en algunos casos puede ser muy conveniente. Desafortunadamente, leer dicho código es muy difícil, pero afortunadamente, C # tiene una notación de consulta con la ayuda de la cual dicho código se verá mucho más 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 este código, necesitamos expandir ligeramente la función Seleccionar Muchos :


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

Así es como se verá el código del título del artículo si lo reescribe usando la implementación "clásica" Quizás
 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; 

Este código no parece muy elegante, ya que C # no fue diseñado originalmente como un lenguaje funcional, pero este enfoque es bastante común en los lenguajes funcionales "reales".


Tal vez asíncrono


La esencia de Quizás monad es controlar la cadena de llamadas a funciones, pero esto es exactamente lo que hace async / wait . Entonces, intentemos combinarlos. Primero, debemos hacer que el tipo Quizás sea compatible con funciones asincrónicas, y ya sabemos cómo lograrlo:


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

Ahora veamos cómo la implementación "clásica" Quizás se puede reescribir como una máquina de estados finitos para que podamos encontrar cualquier similitud:


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

Si comparamos esta máquina de estado con el preprocesador C # generado (consulte "MyAwaitableMethodStateMachine" más arriba), podemos notar que la verificación de estado Quizás se puede implementar dentro de:


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

donde ref awaiter es un objeto de tipo Quizás . El problema aquí es que no podemos configurar la máquina en el estado "final" (-1), pero ¿significa esto que no podemos controlar el flujo de ejecución? Este no es realmente el caso. El hecho es que para cada acción asincrónica, C # configura una función de devolución de llamada para continuar la acción asincrónica a través de la interfaz INotifyCompletion , por lo que si queremos interrumpir el flujo de ejecución, simplemente podemos llamar a la función de devolución de llamada cuando no podemos continuar la cadena de operaciones asincrónicas.
Otro problema aquí es que la máquina de estado generada pasa el siguiente paso (como una función de devolución de llamada) a la secuencia actual de operaciones asincrónicas, pero necesitamos una función de devolución de llamada para la secuencia original que nos permita omitir todas las cadenas restantes de operaciones asincrónicas (desde cualquier nivel de anidación) :



Por lo tanto, necesitamos asociar de alguna manera la acción asincrónica anidada actual con su creador. Podemos hacer esto usando nuestro Method Builder , que tiene un enlace a la operación asincrónica actual - Tarea . Los enlaces a todas las operaciones asíncronas secundarias se pasarán a AwaitOnCompleted (ref awaiter) como camarero , por lo que solo debemos verificar si el parámetro es una instancia de Quizás y luego establecer la opción Tal como actual para la acción secundaria actual:


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

Ahora todos los objetos de tipo Quizás se pueden combinar en una jerarquía, como resultado de lo cual, tendremos acceso a la llamada final de toda la jerarquía (Método de salida ) desde cualquier nodo:


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

Se debe llamar al método Exit cuando, mientras navegamos a través de la jerarquía, encontramos el objeto Maybe ya calculado en el estado Nothing . Tales objetos tal vez pueden ser devueltos por métodos como este:


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

Para almacenar el estado Quizás , cree una nueva estructura 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;// ""  ... } 

En el momento en que la máquina de estado asíncrona llama (a través de Method Builder ) al método OnCompleted de la instancia tal vez ya calculada y está en el estado Nothing , podemos dividir todo el flujo:


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

Ahora solo queda una pregunta: cómo obtener el resultado del Asíncrono Quizás fuera de su alcance (cualquier método asíncrono cuyo tipo de retorno no sea Quizás ). Si intenta usar solo la palabra clave wait con la instancia de Maybe , este código generará una excepción:


 [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 este problema, simplemente podemos agregar un nuevo camarero que devolverá toda la estructura de QuizásResult como un todo, y luego podemos escribir este código:


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

Eso es todo por ahora. En los ejemplos de código, omití algunos detalles para centrarme solo en las partes más importantes. Puedes encontrar la versión completa en github .


De hecho , no recomendaría usar el enfoque anterior en ningún código de trabajo, ya que tiene un problema importante: cuando rompemos el hilo de ejecución, causando la continuación de la operación asincrónica de raíz (con el tipo Quizás ), ¡rompemos TODO! incluidos todos los bloques finalmente (esta es la respuesta a la pregunta "¿Se llaman siempre finalmente los bloques?"), por lo que todos los enunciados que funcionan no funcionarán correctamente, lo que podría provocar una pérdida de recursos. Este problema puede resolverse si en lugar de llamar directamente a la continuación, provocaremos una excepción especial que se manejará implícitamente ( aquí puede encontrar esta versión ), pero esta solución obviamente tiene un límite de rendimiento (que puede ser aceptable en algunos escenarios). En la versión actual del compilador de C #, no veo otra solución, pero tal vez esto cambie algún día en el futuro.


Sin embargo, estas restricciones no significan que todas las técnicas descritas en este artículo sean completamente inútiles, pueden usarse para implementar otras mónadas que no requieren cambios en los hilos, por ejemplo, "Reader". Cómo implementar esta mónada "Reader" a través de async / wait , lo mostraré en el próximo artículo .

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


All Articles