La monade peut-être via async / attendre en C # (sans tâche ov!)


Les types de retour asynchrones génériques sont une nouvelle fonctionnalité introduite dans C # 7 qui vous permet d'utiliser non seulement Task comme type de retour des méthodes asynchrones ( asynchrones / attendent ), mais également tout autre type (classes ou structures) qui satisfait certaines exigences.


En même temps, async / wait est un moyen d'appeler séquentiellement un certain ensemble de fonctions dans un certain contexte, ce qui est l'essence du modèle de conception Monad . La question est, pouvons-nous utiliser async / wait pour écrire du code qui se comporte comme si nous utilisions des monades? Il s'avère que oui (avec quelques réserves). Par exemple, le code ci-dessous compile et fonctionne:


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

Ensuite, j'explique comment ce code fonctionne ...


Types de retour asynchrones génériques


Tout d'abord, découvrons ce qui est nécessaire pour utiliser notre propre type (par exemple, la classe MyAwaitable <T> ) comme type de résultat d'une fonction asynchrone. La documentation indique que ce type devrait avoir:


  1. GetAwaiter () , qui retourne un objet de type qui implémente l'interface INotifyCompletion , et a également la propriété bool IsCompleted et la méthode T GetResult () ;


  2. [AsyncMethodBuilder (Type)] - un attribut indiquant le type qui agira comme le " Générateur de méthodes ", par exemple MyAwaitableTaskMethodBuilder <T> . Ce type doit contenir les méthodes suivantes:


    • statique Create ()
    • Démarrer (stateMachine)
    • SetResult (résultat)
    • SetException (exception)
    • SetStateMachine (stateMachine)
    • AwaitOnCompleted (serveur, stateMachine)
    • AwaitUnsafeOnCompleted (serveur, stateMachine)
    • Tâche


Un exemple d'une implémentation simple de MyAwaitable et 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; } } 

Maintenant, nous pouvons utiliser MyAwaitable comme type de résultat des méthodes asynchrones:


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

Ce code fonctionne, mais pour comprendre l'essence des exigences de la classe MyAwaitable, voyons ce que fait le préprocesseur C # avec la méthode MyAwaitableMethod . Si vous exécutez un décompilateur de compilateur .NET (par exemple, dotPeek), vous verrez que la méthode d'origine a été modifiée comme suit:


 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

Il s'agit en fait de code simplifié, où je saute de nombreuses optimisations pour rendre le code généré par le compilateur lisible


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

Après avoir examiné le code généré, nous constatons que le Générateur de méthodes a les responsabilités suivantes:


  1. Organisation d'un appel à la méthode MoveNext () qui transfère la machine d'état générée à l'état suivant.
  2. Création d'un objet qui représentera le contexte d'une opération asynchrone ( public MyAwaitable<T> Task { get; } )
  3. Réponse à la traduction de la machine d'état générée en états finaux: SetResult ou SetException .

En d'autres termes, avec l'aide de Method Builder, nous pouvons contrôler la façon dont les méthodes asynchrones sont exécutées, et cela ressemble à une opportunité qui nous aidera à atteindre notre objectif - la mise en œuvre du comportement Peut - être de la monade.


Mais qu'est-ce qui est si bon avec cette monade? ... En fait, vous pouvez trouver de nombreux articles sur cette monade sur Internet, donc ici je ne décrirai que les bases.


Peut-être Monade


En bref, Maybe monad est un modèle de conception qui vous permet d'interrompre la chaîne d'appels de fonction si une fonction de la chaîne ne peut pas retourner un résultat significatif (par exemple, des paramètres d'entrée non valides).


Historiquement, les langages de programmation impératifs ont résolu ce problème de deux manières:


  1. Beaucoup de logique conditionnelle
  2. Exceptions

Les deux méthodes ont des inconvénients évidents, donc une approche alternative a été proposée:


  1. Créez un type qui peut être dans deux états: "Une certaine valeur" et "Aucune valeur" (" Rien ") - appelons-le peut-être
  2. Créez une fonction (appelons-la SelectMany ) qui prend 2 arguments:
    2.1. Peut-être un objet
    2.2. La fonction suivante de la liste d'appels. Cette fonction doit également renvoyer un objet de type Maybe , qui peut contenir une sorte de valeur résultante ou être à l'état Nothing si le résultat ne peut pas être obtenu (par exemple, des paramètres incorrects ont été transmis à la fonction)
  3. La fonction SelectMany vérifie un objet de type Maybe et s'il contient la valeur résultante, alors ce résultat est extrait et passé en argument à la fonction suivante de la chaîne d'appel (passé en tant que deuxième argument). Si l'objet Maybe est à l'état Nothing , SelectMany renvoie immédiatement Nothing .


En C #, cela peut être implémenté comme suit:


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

et exemple d'utilisation:


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

Pourquoi "SelectMany"?

Je pense que certains d'entre vous peuvent se demander: "Pourquoi l'auteur a-t-il appelé cette fonction" SelectMany "? En fait, il y a une raison à cela - en C #, le préprocesseur insère un appel Select Many lors du traitement des expressions écrites dans Query Notation , qui, en substance, est «Sucre syntaxique» pour les chaînes d'appels complexes (vous trouverez plus d' informations à ce sujet dans mon précédent article ).


En fait, nous pouvons réécrire le code précédent comme suit:


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

accéder ainsi à l'état intermédiaire (x2, x3), ce qui dans certains cas peut être très pratique. Malheureusement, la lecture d'un tel code est très difficile, mais heureusement, C # a une notation de requête à l'aide de laquelle ce code sera beaucoup plus facile:


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

Afin de compiler ce code, nous devons développer légèrement la fonction Sélectionner plusieurs :


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

Voici à quoi ressemblera le code du titre de l'article si vous le réécrivez en utilisant l'implémentation «classique» de 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; 

Ce code n'a pas l'air très élégant, car C # n'a pas été conçu à l'origine comme un langage fonctionnel, mais cette approche est assez courante dans les "vrais" langages fonctionnels.


Async peut-être


L'essence de Maybe monad est de contrôler la chaîne d'appels de fonctions, mais c'est exactement ce que fait async / wait . Essayons donc de les combiner ensemble. Tout d'abord, nous devons rendre le type Maybe compatible avec les fonctions asynchrones, et nous savons déjà comment y parvenir:


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

Voyons maintenant comment l'implémentation «classique» peut-être peut être réécrite comme une machine à états finis afin que nous puissions trouver des similitudes:


 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 nous comparons cette machine à états avec le préprocesseur C # généré (voir «MyAwaitableMethodStateMachine» ci-dessus), nous pouvons remarquer que la vérification d'état peut-être peut être implémentée à l'intérieur:


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

où l' ref awaiter est un objet de type peut-être . Le problème ici est que nous ne pouvons pas mettre la machine à l'état «final» (-1), mais cela signifie-t-il que nous ne pouvons pas contrôler le flux d'exécution? Ce n'est en fait pas le cas. Le fait est que pour chaque action asynchrone, C # configure une fonction de rappel pour continuer l'action asynchrone via l'interface INotifyCompletion , donc si nous voulons interrompre le flux d'exécution, nous pouvons simplement appeler la fonction de rappel lorsque nous ne pouvons pas continuer la chaîne d'opérations asynchrones.
Un autre problème ici est que la machine d'état générée passe l'étape suivante (en tant que fonction de rappel) à la séquence actuelle d'opérations asynchrones, mais nous avons besoin d'une fonction de rappel pour la séquence d'origine qui nous permettrait de contourner toutes les chaînes restantes d'opérations asynchrones (à partir de n'importe quel niveau d'imbrication) :



Donc, nous devons en quelque sorte associer l'action asynchrone imbriquée actuelle à son créateur. Nous pouvons le faire en utilisant notre Générateur de méthodes , qui a un lien vers l'opération asynchrone actuelle - Tâche . Les liens vers toutes les opérations asynchrones enfants seront transmis à AwaitOnCompleted (ref awaiter) tant qu'attendant , il nous suffit donc de vérifier si le paramètre est une instance de Maybe , puis de définir le Maybe actuel en tant que parent pour l'action enfant en cours:


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

Maintenant, tous les objets de type Maybe peuvent être combinés dans une hiérarchie, à la suite de quoi, nous aurons accès à l'appel final de toute la hiérarchie (méthode Exit ) à partir de n'importe quel nœud:


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

La méthode Exit doit être appelée lorsque, lors de la navigation dans la hiérarchie, nous trouvons l'objet Maybe déjà calculé à l'état Nothing . De tels objets peut-être peuvent être retournés par des méthodes comme celle-ci:


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

Pour stocker l'état Maybe , créez une nouvelle structure distincte:


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

Au moment où la machine d'état asynchrone appelle (via le Générateur de méthodes ) la méthode OnCompleted de l'instance Maybe déjà calculée et qui est à l'état Nothing , nous pouvons interrompre le flux entier:


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

Maintenant, il ne reste qu'une question - comment obtenir le résultat du Maybe asynchrone en dehors de sa portée (toute méthode asynchrone dont le type de retour n'est pas Maybe ). Si vous essayez d'utiliser uniquement le mot clé wait avec l'instance Maybe , une exception sera levée par ce code:


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

Pour résoudre ce problème, nous pouvons simplement ajouter un nouvel attendant qui renverra la structure entière de MaybeResult dans son ensemble, puis nous pouvons écrire ce code:


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

C'est tout pour l'instant. Dans les exemples de code, j'ai omis certains détails pour ne me concentrer que sur les parties les plus importantes. Vous pouvez trouver la version complète sur github .


En fait , je ne recommanderais pas d'utiliser l'approche ci-dessus dans n'importe quel code de travail, car elle a un problème important - lorsque nous interrompons le thread d'exécution, provoquant la poursuite de l'opération asynchrone racine (avec le type Peut-être ), nous cassons TOUT! y compris tous les blocs finally (c'est la réponse à la question «Les blocs finalement sont-ils toujours appelés?»), donc toutes les instructions using ne fonctionneront pas correctement, ce qui pourrait entraîner une fuite de ressources. Ce problème peut être résolu si au lieu d'appeler directement la continuation, nous lèverons une exception spéciale qui sera implicitement gérée ( ici vous pouvez trouver cette version ), mais cette solution a évidemment une limite de performance (qui peut être acceptable dans certains scénarios). Dans la version actuelle du compilateur C #, je ne vois pas d'autre solution, mais cela changera peut-être un jour à l'avenir.


Cependant, ces restrictions ne signifient pas que toutes les techniques décrites dans cet article sont complètement inutiles, elles peuvent être utilisées pour implémenter d'autres monades qui ne nécessitent pas de modifications dans les threads, par exemple, "Reader". Comment implémenter cette monade "Reader" via async / wait Je vais montrer dans le prochain article .

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


All Articles