Les monades en C# : une exploration rigoureuse avec des exemples concrets

Blog post cover image
Le concept de « monade » évoque le plus souvent les paradigmes de la programmation fonctionnelle, et en particulier des langages comme Haskell. Pourtant, les abstractions monadiques s'appliquent très efficacement en C#. Dans cet exposé, nous explorons rigoureusement la notion de monade et nous en démontrons l'application en C# à travers un exemple détaillé. L'objectif : comprendre en profondeur comment ce concept, souvent perçu comme ésotérique, permet de structurer des calculs dans des situations réelles.

▸ Définir la monade dans le contexte C#

Les monades passent souvent pour abstraites et complexes, alors qu'on peut les comprendre comme un design pattern qui facilite la composition de calculs de façon prévisible et modulaire. Pour l'essentiel, une monade encapsule des valeurs, autorise des transformations et gère des effets de bord, comme le logging ou la propagation d'erreurs, tout en préservant un enchaînement élégant d'opérations.
En C#, on peut considérer des constructions comme Task<T> ou Nullable<T> comme monadiques : elles ajoutent une couche de fonctionnalité supplémentaire par-dessus une valeur sous-jacente. Les monades permettent d'opérer sur la valeur encapsulée sans casser l'intégrité du calcul. En encapsulant les transformations et en gérant leurs effets de bord, elles garantissent qu'une séquence d'opérations reste cohérente et traçable. Passons à un exemple concret.

▸ Une implémentation monad-like en C#

Pour éclairer le concept, considérons une série de fonctions qui transforment un entier tout en journalisant les étapes intermédiaires. Cette approche aide non seulement à comprendre les transformations de valeur, mais aussi à suivre la séquence des opérations appliquées, et donc à voir comment gérer les effets de bord de façon contrôlée.
Le code C# suivant montre comment appliquer des transformations à une valeur en s'appuyant sur des mécanismes d'encapsulation et de chaînage monad-like. L'implémentation repose sur l'idée d'emballer une valeur avec ses logs associés, de la transformer via une séquence de fonctions, et de conserver les logs à chaque étape.
Console.WriteLine("Monads in C#");
Console.WriteLine();

// Running a series of transformations on the initial value of 5
// Here we use multiple functions: AddOne, Square, and MultiplyByThree
var result1 = RunWithLogsMultiple(WrapWithLogs(5), AddOne, Square, MultiplyByThree);
PrintResult(result1);

Console.WriteLine();

// Running a single transformation on the initial value of 5
// Here we use only the AddOne function
var result2 = RunWithLogs(WrapWithLogs(5), AddOne);
PrintResult(result2);

return;

// Function to print the final result and the logs collected during transformations
void PrintResult(NumberWithLogs result)
{
    Console.WriteLine($"Logs: {string.Join(", ", result.Logs)}");
    Console.WriteLine($"Result: {result.Result}");
}

// Function to square a number and return the result along with a log message
NumberWithLogs Square(int x)
    => new(x * x, [$"Squared {x} to get {x * x}"]);

// Function to add one to a number and return the result along with a log message
NumberWithLogs AddOne(int x)
    => new(x + 1, [$"Added 1 to {x} to get {x + 1}"]);

// Function to multiply a number by three and return the result along with a log message
NumberWithLogs MultiplyByThree(int x)
    => new(x * 3, [$"Multiplied {x} by 3 to get {x * 3}"]);

// Function to wrap an integer value with an empty log, used to initialize the monad
NumberWithLogs WrapWithLogs(int x)
    => new(x, []);

// Function to apply a single transformation to a NumberWithLogs instance
// Combines the input logs with the logs from the transformation
NumberWithLogs RunWithLogs(NumberWithLogs input, Func<int, NumberWithLogs> transform)
{
    // Apply the transformation to get the new result
    var transformed = transform(input.Result);
    // Combine the logs from the input and the transformation
    return transformed with
    {
        Logs = input.Logs.Concat(transformed.Logs).ToArray()
    };
}

// Function to apply multiple transformations to a NumberWithLogs instance
// Uses Aggregate to sequentially apply each function and collect all logs
NumberWithLogs RunWithLogsMultiple(NumberWithLogs input, params Func<int, NumberWithLogs>[] transforms)
    => transforms.Aggregate(input, RunWithLogs);

// Record type to hold the result and associated logs
internal record NumberWithLogs(int Result, string[] Logs);

▸ Disséquer le pattern monad-like

Dans l'exemple ci-dessus, NumberWithLogs est un record qui encapsule à la fois la valeur numérique (Result) et les effets de bord accumulés (Logs). Ce record fonctionne par analogie avec une monade : il encapsule la donnée et les comportements associés. Encapsuler à la fois la donnée et son contexte, ici les logs, est fondamental pour comprendre le rôle d'une monade.
Les fonctions de transformation, à savoir AddOne, Square et MultiplyByThree, retournent chacune une instance de NumberWithLogs qui conserve la valeur résultante et un log descriptif de la transformation. Ce pattern garantit que chaque opération est suivie de manière cohérente et traçable. En maintenant un journal clair de chaque étape, les transformations deviennent auditables, ce qui est particulièrement précieux pour déboguer et maintenir des chaînes de calcul complexes.
La fonction WrapWithLogs joue le rôle de l'opération « unit » en terminologie monadique : elle emballe une valeur brute dans notre contexte monadique. Concrètement, elle initialise une instance de NumberWithLogs avec la valeur fournie et un log vide, et sert de point d'entrée dans le pipeline. L'idée du « unit » est justement de fournir un point d'entrée qui convertit une valeur ordinaire en monade, pour qu'elle puisse participer à la chaîne de calculs.
La fonction RunWithLogs applique une transformation à une instance existante de NumberWithLogs, en combinant les logs de l'entrée avec ceux produits par la transformation. Ce mécanisme est l'analogue de l'opération « bind » (>>= en Haskell), utilisée pour chaîner des calculs à l'intérieur d'une monade. Le bind garantit que chaque transformation est appliquée en séquence, en conservant le contexte, c'est-à-dire les logs, tout au long des étapes.

▸ Examiner la sortie

Voici la sortie produite par l'exécution de l'exemple :

Sortie

Monads in C#

Logs: Added 1 to 5 to get 6, Squared 6 to get 36, Multiplied 36 by 3 to get 108
Result: 108

Logs: Added 1 to 5 to get 6
Result: 6
Dans le premier cas, la série de transformations (AddOne, Square, MultiplyByThree) est appliquée à la valeur initiale 5. Chaque étape est journalisée, ce qui fournit une piste d'audit claire de la séquence, jusqu'à la valeur finale 108. Les étapes intermédiaires sont cruciales : elles rendent transparent le chemin qui mène au résultat, ce qui aide à comprendre et à déboguer le calcul.
Dans le second cas, une seule transformation (AddOne) est appliquée, ce qui donne la valeur 6 et une entrée de log qui décrit cette opération. Cela illustre la modularité de l'approche monadique : des séquences simples comme complexes se traitent avec la même clarté.

▸ L'utilité des monades en C#

Exploiter des monades ou des constructions monad-like en C# améliore sensiblement la composabilité et la clarté du code, en particulier lorsqu'il faut gérer des transformations complexes ou des effets de bord comme le logging, la gestion d'erreurs ou la gestion d'état. En fournissant une interface uniforme pour chaîner les opérations, les monades facilitent l'écriture d'un code modulaire et compréhensible. Cette composabilité permet de construire des pipelines de transformations où chaque étape est testable et vérifiable indépendamment.
Dans l'exemple donné, la gestion des logs est une illustration directe du traitement d'un effet de bord au sein d'une structure monadique. Mais dans des applications plus larges, les monades se révèlent très utiles pour gérer d'autres effets : workflows asynchrones, propagation d'état, ou même gestion des échecs. Elles abstraient la complexité de la gestion des effets de bord et permettent au développeur de se concentrer sur la logique métier de la transformation plutôt que sur les préoccupations périphériques.
Les monades introduisent aussi un niveau de prévisibilité et de sûreté dans la base de code. Face à des opérations impliquant des effets de bord, comme les I/O, les exceptions ou l'état mutable, elles offrent une manière contrôlée et prévisible de les gérer. Ce mécanisme aide à maintenir un code à la fois thread-safe et plus facile à raisonner, puisque les effets de bord sont traités explicitement au lieu d'être implicitement dispersés dans le code.

Pour aller plus loin

Si le sujet vous intéresse, plusieurs ressources d'excellente qualité existent :

▸ Conclusion

La notion de monade peut sembler abstraite et insaisissable au premier abord, mais ses applications pratiques améliorent significativement la qualité, la modularité et la prévisibilité du code. L'exemple présenté ici fournit un cadre conceptuel pour comprendre comment construire des structures monad-like en C# afin de gérer valeurs et effets de bord de manière élégante et systématique. En encapsulant à la fois la valeur et son contexte, les monades permettent d'écrire un code propre, composable et facile à déboguer.
Pour ceux qui veulent creuser, il vaut la peine d'examiner d'autres monades courantes, comme Maybe (pour encapsuler des valeurs optionnelles) ou Either (pour représenter des calculs susceptibles d'échouer). Ces abstractions sont de puissants outils pour gérer l'incertitude et les erreurs de façon prévisible. Même si C# ne supporte pas nativement les monades comme entités de première classe, adopter des patterns monadiques renforce l'expressivité et la maintenabilité d'une base de code C#.
Enfin, l'usage des patterns monadiques ouvre la voie à la création de langages dédiés (DSL) et d'API fluides qui encapsulent des opérations complexes dans des composants simples, lisibles et réutilisables. En s'appuyant sur les monades, les développeurs C# peuvent bâtir des systèmes plus résilients, capables par construction de gérer effets de bord et transitions d'état de façon cohérente.
Bon code, et que vos transformations soient toujours précises et composables !

Does this resonate with your team?

Let's talk about how Atypical Consulting can help you move forward.

Contact me