
Partager:
Guillaume est Senior Developer Advocate chez Vonage. Il travaille dans le domaine de .Net depuis près de 15 ans et s'est concentré sur la promotion du Software Craftsmanship au cours des dernières années. Ses sujets de prédilection sont la qualité du code, l'automatisation des tests, le mobbing et les katas du code. En dehors du travail, il aime passer du temps avec sa femme et sa fille, faire de l'exercice ou jouer.
L'invasion des monades - Partie 2 : Les monades en action !
Temps de lecture : 8 minutes
Bonjour les amis,
Dans notre précédent article "L'invasion des monades - Partie 1 : Qu'est-ce qu'une Monade ?"nous avons choisi une approche pragmatique pour introduire les Monades, en construisant progressivement les nôtres. A présent, vous devriez être familier avec le concept, comprenant comment transformer sa valeur avec soit .Map() ou .Bind() et comment l'extraire avec .Match().
Dans cet article, nous souhaitons démontrer les applications pratiques de divers Monads, et nous utiliserons un exemple tiré de l'application SDK .NET de Vonage. Mais tout d'abord, récapitulons rapidement.
Récapitulation rapide
Nous avons précédemment élaboré la Optional Monad, qui peut exister dans l'un des deux états suivants : Some, indiquant la présence d'une valeur, ou Noneindiquant l'absence de valeur.
private static SchrodingerBox<int> Half(int value) =>
value % 2 == 0
? SchrodingerBox<int>.Some(value / 2)
: SchrodingerBox<int>.None();
private static int Increment(int value) => value + 1;
SchrodingerBox<int>.Some(0)
.Map(Increment) // Some(0) becomes Some(1)
.Map(Increment) // Some(1) becomes Some(2)
.Bind(Half) // Some(2) becomes Some(1)
.Map(Increment) // Some(1) becomes Some(2)
.Match(some => $"The value is some {some}!", () => "The value is none")
.Should()
.Be("The value is some 2!");
SchrodingerBox<int>.Some(0)
.Map(Increment) // Some(0) becomes Some(1)
.Bind(Half) // Some(1) becomes None - the Monad's state has changed
.Map(Increment) // None remains None
.Match(some => $"The value is some {some}!", () => "The value is none")
.Should()
.Be("The value is none");
Notre boîte offre trois fonctionnalités essentielles :
.Map(operation)vous permet de générer une nouvelle valeur sur la base d'une valeur existante et d'envelopper la nouvelle valeur dans une nouvelle boîte. L'opération n'est exécutée que si la boîte est enSomeétat..Bind(operation)est un mécanisme de transformation similaire à.Map(). Cependant, il diffère en permettant la modification de l'état de la boîte puisque la fonction renvoie une boîte au lieu d'une valeur. Comme.Map()l'opération n'est exécutée que si la boîte est dans l'étatSomeétat..Match(some, none)évalue l'état de la boîte et invoque la fonction correspondante.
La boîte permet de manipuler une valeur sans connaître son état actuel.
Par essence, l'état n'a plus d'importance puisque votre séquence d'opérations reste la même. Comme vous l'avez probablement observé, le code ne comporte aucun branchement (if/else).
Quand cela vaut-il la peine d'utiliser une monade ?
An image for exposing two different states
Étant donné leur nature d'états opposés, les monades ne sont pas pertinentes dans les scénarios où les opérations produisent un résultat unique. Pour que les monades soient applicables, l'opération doit présenter au moins deux résultats distincts possibles.
Jusqu'à présent, nous avons travaillé avec la monade Optional mais il en existe beaucoup d'autres. Pour partager des exemples d'autres monades, explorons les monades de la bibliothèque Language-Ext et découvrons leurs contextes utiles.
Option
Compte tenu de notre implémentation précédente, vous devriez être assez familier avec celle-ci maintenant. Elle est idéale pour envelopper une valeur optionnelle ou une valeur qui peut (ou ne peut pas) exister.
private async Task<Option<User>> FindUser(Guid id)
{
var user = await this.repository.Users.FirstOrDefaultAsync(user => user.Id == id);
return user ?? Option<User>.None;
}
Essayer
La Try<T> représente une opération susceptible d'échouer :
Successindique que l'opération a réussi, donnant le résultat<T>.Exceptionindique que l'opération a donné lieu à une exception. La monade renvoie l'exception au lieu de la lever.
private static Try<decimal> Divide(decimal value, decimal divisor) =>
Try(() => value / divisor);
Dans l'exemple ci-dessus, nous pouvons encapsuler une opération potentiellement "risquée" dans un fichier Try.
L'invocation de
Divide(50,2)renverra un étatSuccessavec la valeur25.L'invocation de
Divide(50,0)renverra un étatExceptionavec unDivideByZeroException.
Soit
La monade Either<L, R> représente une opération qui peut retourner deux types distincts, définis par Leftou Right. Cette monade est extrêmement polyvalente, étant donné que <L, R> sont tous deux des types génériques. Néanmoins, l'état Left représente généralement des erreurs ou des cas exceptionnels.
private static Either<Error, decimal> Divide(decimal value, decimal divisor) =>
divisor == 0
? new Error("Cannot divide by 0.")
: value / divisor;
private record Error(string Message);
Conformément au scénario précédent :
L'invocation de
Divide(50,2)renverra un étatRightavec la valeur25.L'invocation de
Divide(50,0)renverra un étatLeftavec un enregistrementErrorexpliquant pourquoi il n'a pas été traité.
Validation
La monade Validation<Fail, Success> représente une opération de validation qui peut échouer pour de multiples raisons.
private record User(string Firstname, string Lastname, MailAddress Email);
private static Validation<string, User> CreateUser(string firstname, string lastname, string email)
{
var errors = new Seq<string>();
if (string.IsNullOrWhiteSpace(firstname))
{
errors = errors.Add("Firstname cannot be empty.");
}
if (string.IsNullOrWhiteSpace(lastname))
{
errors = errors.Add("Lastname cannot be empty.");
}
if (!MailAddress.TryCreate(email, out var address))
{
errors = errors.Add("Invalid mail address.");
}
return errors.Any()
? Validation<string, User>.Fail(errors)
: Validation<string, User>.Success(new User(firstname, lastname, address));
}
Dans cet exemple :
L'invocation de
CreateUser("Jane", "Doe", "jane.doe@email.com")renverra unSuccessavec une instanceUservalide.L'invocation de
CreateUser(null, null, null)(ou toute autre valeur invalide) renverra un messageFailureavec l'ensemble des erreurs rencontrées.
Transparence référentielle
An image to illustrate transparency
Ces monades ont toutes un point commun : elles sont toutes transparentes sur les résultats potentiels d'une opération, qu'il s'agisse d'un échec, d'une exception ou d'un retour de différentes valeurs. En tant que tel, le type de retour de la méthode doit communiquer le résultat de ces issues au lieu d'un mécanisme invisible et imprévisible (une exception).
Nous parlons ici de Transparence référentielle. Elle s'applique lorsque nous pouvons toujours remplacer une expression par sa valeur.
// Referentially Opaque examples
public User FindUser(Guid id) => this.users.First(user => user.Id == id);
public int Divide(int a, int b) => a/b;
public void SetName(string name)
{
ArgumentNullException.ThrowIfNull(name);
this.Name = name;
}
// Referentially Transparent examples
public Maybe<User> FindUser(Guid id) => this.users.FirstOrDefault(user => user.Id == id) ?? Maybe<User>.None;
public Try<decimal> Divide(decimal value, decimal divisor) => Try(() => value / divisor);
public int Add(int a, int b) => a+b;
public Either<Error, Unit> SetName(string name)
{
if (name is null)
{
return new Error("Name cannot be null);
}
this.Name = name;
return Unit.Value;
}
Vous avez probablement remarqué que
.SetName()renvoie unEither<Error, Unit>. Vous avez peut-être déjà rencontréUnitdans des bibliothèques comme MediatR ou Language-Ext. Il s'agit d'une construction simple représentant un type avec une seule valeur possible. Nous l'utilisons comme un espace réservé pour les opérations qui ne renvoient pas de valeur mais qui peuvent renvoyer un autre état. Dans notre exemple,.SetName()est une commande qui ne renvoie pas de valeur mais peut échouer. Par conséquent, la monadeEither<Error, Unit>comporte deux états possibles : Right (sans valeur) ou Left (avec une erreur).
Bien que la transparence référentielle ne résolve pas intrinsèquement des problèmes spécifiques, elle améliore considérablement la prévisibilité et la lisibilité du code. Le fait de tout expliciter dans la signature de la méthode minimise les risques d'imprévus. L'oncle Bob souligne dans son livre "Clean Code : A Handbook of Agile Software Craftsmanship", l'oncle Bob souligne que "...le rapport entre le temps passé à lire et celui passé à écrire est bien supérieur à 10 pour 1. Nous lisons constamment du vieux code dans le cadre de nos efforts pour en écrire du nouveau... Par conséquent, en facilitant la lecture, on facilite l'écriture". Cela souligne l'importance de la clarté et de la transparence du code pour faciliter la compréhension et l'efficacité du développement.
C'est ce qui a motivé la création de "Lancez des exceptions... hors de votre base de code".
Exemple de SDK Vonage
Jusqu'à présent, nous nous sommes concentrés sur des exemples relativement simples. Mais qu'en est-il de l'utilisation des monades dans des flux plus complexes ? Examinons un exemple tiré de notre SDK .NET impliquant notre API d'authentification à deux facteurs Verify.
Monades personnalisées
Auparavant, nous avons construit un Optional et montré un ensemble existant basé sur Language-Extmais plusieurs bibliothèques proposent également des implémentations de Monad. Dans le cas de notre SDKnous avons délibérément évité de nous appuyer sur des bibliothèques externes telles que Language-Ext. En effet, les monades font partie de l'API publique du SDK, et s'appuyer sur une bibliothèque externe introduirait une dépendance sur laquelle nous n'aurions qu'un contrôle limité.
Notre approche a consisté à créer nos propres implémentations de monades adaptées aux besoins spécifiques du SDK. Cette stratégie nous a permis de garder le contrôle sur la conception et la fonctionnalité de ces Monades tout en évitant de dépendre de bibliothèques externes.
En outre, notre objectif était de présenter une version légère et conviviale des Monads spécifiques, afin d'en faciliter l'adoption par les développeurs travaillant avec notre SDK.
Notre SDK met en œuvre les monades suivantes :
Result<T>Monade, similaire à uneEither<IFailure, T>avec une syntaxe plus concise - C# étant verbeux en ce qui concerne les génériques.Maybe<T>Monade, similaire à uneOption<T>.
Exemple avec l'authentification à deux facteurs
2FA est un processus en deux étapes. Nous lançons d'abord un processus d'authentification, ce qui entraîne la réception par le client d'un code de validation basé sur le flux de travail spécifié (SMS, WhatsApp, Email, Voice, SilentAuth). Une fois le code reçu, nous l'envoyons à notre API pour vérification.
var myPhoneNumber = ...
var result = await StartVerificationRequest.Build()
.WithBrand("Monads Inc.")
.WithWorkflow(SmsWorkflow.Parse(myPhoneNumber))
.Create() // Build the authentication request
.BindAsync(request => verifyClient.StartVerificationAsync(request)) // Process the authentication request
.BindAsync(VerifyCodeAsync) // Start the second step if it's a success
.Match(AuthenticationSuccessful, AuthenticationFailed);
private async Task<Result<Unit>> VerifyCodeAsync(StartVerificationResponse response)
{
var myCode = ... // Receive verification code based on the specified workflow
return await response
.BuildVerificationRequest(myCode) // Build the verification request
.BindAsync(request => verifyClient.VerifyCodeAsync(request)); // Process the verification request
}
Tout ce que nous avons couvert jusqu'à présent reste applicable car notre flux de travail contient la récupération des données utilisateur, l'analyse des opérations et les appels à l'API.
Une fois de plus, nous avons réussi à supprimer le branchement de notre code, en maintenant un flux cohérent quel que soit l'état de la monade. Il est intéressant de noter que notre processus peut échouer à cinq endroits distincts :
Lors de l'élaboration de la demande d'authentification
Lors du traitement de la demande d'authentification
Lors de l'élaboration de la demande de vérification
Lors du traitement de la demande de vérification
Lors de l'appel de la deuxième étape
VerifyCodeAsync
Si vous vous souvenez, dans notre précédent billet, nous avons parlé de garder une Monade active le plus longtemps possible. Dans ce scénario, la valeur reste dans notre Result<T> du début à la fin du flux, ce qui nous permet d'enchaîner toute la séquence d'opérations.
Être ou ne pas être... pur ?
An image to illustrate purity
Dans notre exemple précédent, vous avez peut-être remarqué que notre Monade n'est pas restée pure pendant le déroulement des opérations. En effet, nous avons fourni des données non pures à .Bind()comme request => verifyClient.StartVerificationAsync(request) ou request => verifyClient.VerifyCodeAsync(request). Cela pose-t-il un problème ?
Par définition, un pure pure ne fait référence à aucun état global et ne doit ne doit pas produire d'effet de bord. Elle produit de manière cohérente une sortie qui dépend uniquement de l'entrée, garantissant la même sortie pour une entrée spécifique - en d'autres termes, une grande prévisibilité.
Bien que le concept de "monade pure" puisse être discuté, il est essentiel de comprendre que l'utilisation de monades ne nécessite pas nécessairement que la monade elle-même soit pure. Au contraire, les monades sont souvent utilisées pour structurer des calculs impliquant des éléments impures impures tout en respectant les principes de la programmation fonctionnelle. En effet, les interactions avec des ressources externes, telles que les bases de données ou les API, introduisent des effets de bord.
Monades impures... Avec des exceptions ?
Le comportement par défaut garantit que nos monades ne lèveront aucune exception, même si la fonction paramètre qu'elles contiennent le fait. Ce choix de conception nous aide à maintenir referential transparency.
public Result<TB> Bind<TB>(Func<T, Result<TB>> bind)
{
try
{
return this.IsFailure
? Result<TB>.FromFailure(this.failure)
: bind(this.success);
}
catch (Exception exception)
{
return SystemFailure.FromException(exception).ToResult<TB>();
}
}
public Result<TB> Map<TB>(Func<T, TB> map)
{
try
{
return this.IsFailure
? Result<TB>.FromFailure(this.failure)
: Result<TB>.FromSuccess(map(this.success));
}
catch (Exception exception)
{
return SystemFailure.FromException(exception).ToResult<TB>();
}
}
Cependant, que se passe-t-il si vous préférez vous en tenir aux exceptions au lieu d'extraire la valeur avec .Match()? Nous voulions que nos monades soient polyvalentes, c'est pourquoi nous avons introduit une fonctionnalité GetSuccessUnsafe() fonctionnalité. Cette fonction lèvera une exception si la monade est dans l'état Failure état.
public T GetSuccessUnsafe() => this.IfFailure(value => throw value.ToException());
Le type et les données de l'exception dépendent de la valeur de défaillance sous-jacente :
ResultFailurelancera unVonageExceptionParsingFailurelancera unVonageExceptionHttpFailurelancera unVonageHttpRequestExceptionAuthenticationFailurelancera unVonageAuthenticationExceptionEt ainsi de suite...
Voici comment vous pouvez incorporer des exceptions dans votre flux monadique en utilisant le même exemple que précédemment :
try
{
await StartVerificationRequest.Build()
.WithBrand("Monads Inc.")
.WithWorkflow(SmsWorkflow.Parse(myPhoneNumber))
.Create()
.BindAsync(request => verifyClient.StartVerificationAsync(request))
.BindAsync(VerifyCodeAsync)
.GetSuccessUnsafe(); // Will throw exception if in the Failure state
AuthenticationSuccessful();
}
catch (Exception exception)
{
AuthenticationFailed(exception);
}
Que vous choisissiez l'approche monadique standard ou que vous optiez pour les exceptions, notre objectif est de prendre en compte différents styles de gestion des erreurs, en veillant à ce que nos monades s'alignent sur vos préférences en matière de codage.
Conclusion
Nous avons terminé le deuxième article de notre série "The Monads Invasion". Cet article avait pour but de présenter différents ensembles de Monads, y compris nos implémentations personnalisées, et d'illustrer leur utilisation dans des flux de travail étendus.
À ce stade, vous devez réaliser que Monads offre une approche alternative de la gestion des erreurs dans vos flux de travail - et ce n'est pas une coïncidence. En effet, notre prochain article apportera plus de lumière sur la méthodologie derrière l'enchaînement des opérations. Restez à l'écoute pour en savoir plus !
Si vous avez des questions ou si vous souhaitez discuter, n'hésitez pas à me contacter sur mon LinkedIn ou rejoignez-nous sur le Slack des développeurs de Vonage.
Bon codage, et à plus tard !
Partager:
Guillaume est Senior Developer Advocate chez Vonage. Il travaille dans le domaine de .Net depuis près de 15 ans et s'est concentré sur la promotion du Software Craftsmanship au cours des dernières années. Ses sujets de prédilection sont la qualité du code, l'automatisation des tests, le mobbing et les katas du code. En dehors du travail, il aime passer du temps avec sa femme et sa fille, faire de l'exercice ou jouer.