← Cours 2 : ADT Cours 4 : Parseurs →

Monades

le pipe qui avait des ambitions

1. Introduction

Dans le cours précédent, on a découvert l'opérateur |> (pipe) qui permet d'enchaîner des transformations de façon lisible :

OCaml
[1; 2; 3; 4; 5]
  |> List.filter (fun x -> x mod 2 = 0)
  |> List.map (fun x -> x * 2)
  |> List.fold_left (+) 0

C'est simple : chaque fonction renvoie une valeur, la suivante la prend en entrée. Mais que se passe-t-il si une fonction peut échouer ? Ou retourner plusieurs résultats ? Ou dépendre d'un état global ?

Les monades sont la généralisation du pipe à ces cas plus complexes. Si le pipe, c'est "prends une valeur et applique-lui une fonction", la monade, c'est "prends une valeur dans un contexte et applique-lui une fonction qui peut enrichir ce contexte".

✨ Définition éclair : Une monade, c'est trois choses : un type ('a t), une fonction return (mettre une valeur dans le contexte) et une fonction bind (enchaîner deux calculs dans le contexte). Le bind est le pipe des effets.

Le terme "monade" vient des mathématiques (théorie des catégories), mais pas de panique : on va les aborder par la pratique, sans formalismes inutiles.

Eugène Moggi (1991) a le premier montré que les monades pouvaient modéliser les effets de bord en programmation fonctionnelle. Philip Wadler (célèbre pour "Monads for Functional Programming", 1992) les a introduites en Haskell. Depuis, les monades sont partout : Option, Result, List, State, IO, Parser… Wadler dit souvent : "Les monades sont des burritos" (une blague devenue légendaire). Nous, on préfère dire que ce sont des pipes qui ont fait des études.

Dans ce cours, on va parcourir plusieurs monades, de la plus simple à la plus abstraite :

  1. Liste — le contexte du non-déterminisme (plusieurs résultats possibles).
  2. Option — le contexte de l'échec possible (liste à 0 ou 1 élément).
  3. State — le contexte de l'état mutable (simulé proprement).
  4. Environnement (Reader) — le contexte de la configuration.
  5. Continuation — le contexte du "et après ?" (la mère de toutes les monades).

2. Le problème

Imaginez qu'on veuille faire une division sécurisée : une fonction qui renvoie None si on divise par zéro.

OCaml
let safe_div x y =
  if y = 0 then None else Some (x / y)

Maintenant, on veut chaîner plusieurs divisions : calculer 12 / 2 / 3 (= 2) de façon sécurisée. Avec le pipe |>, on ne peut pas écrire :

OCaml
(* Ceci ne type PAS : safe_div attend des int, pas des option *)
Some 12 |> safe_div 2  (* erreur de type ! *)

On doit extraire manuellement la valeur de l'option :

OCaml
let resultat =
  match Some 12 with
  | None    -> None
  | Some x  ->
      match safe_div x 2 with
      | None    -> None
      | Some y  -> safe_div y 3

C'est la pyramide de la mort (callback hell). Pour chaque étape, on vérifie manuellement si tout va bien. C'est répétitif, verbeux, et source d'erreurs.

Cette pyramide de match imbriqués ressemble à la Tour de Babel : on voulait atteindre le ciel, on a juste obtenu de la mauvaise architecture. Les monades sont la solution pour aplatir ces tours en de belles lignes de code.

2.1 Pipe ne suffit plus

Le pipe |>, c'est tout simplement :

OCaml
let (|>) x f = f x

Il fonctionne quand x est une valeur brute et f prend une valeur brute. Mais quand x est une valeur dans un contexte (option, liste, …), on a besoin d'un opérateur qui comprend ce contexte.

Cet opérateur, c'est bind, noté >>= en OCaml (prononcez "bind"). Sa signature :

Et return (parfois pure) :

Avec ces deux opérateurs, on peut enfin écrire proprement :

OCaml
// Le bind pour option *)
let (>>=) opt f =
  match opt with
  | None   -> None
  | Some x -> f x

(* return pour option *)
let return x = Some x

(* La division sécurisée en une ligne ! *)
return 12
  >>= safe_div 2
  >>= safe_div 3
- : int option = Some 2

Magique ! Si une division échoue, le None se propage automatiquement. Plus de pyramide, plus de match manuels.

OCaml (depuis 4.08) permet même d'utiliser le sucre let* pour écrire encore plus lisiblement :

OCaml (≥ 4.08)
// On déclare let* comme bind pour option *)
let (let*) = (>>=)

(* Et on peut écrire : *)
let resultat =
  let* x = return 12 in
  let* y = safe_div x 2 in
  safe_div y 3

Ça ressemble à du code impératif, mais c'est purement fonctionnel. Chaque let* "dépaquette" la valeur du contexte et la rend disponible pour la suite.

let*, c'est le petit frère élégant de match : là où match vous force à ouvrir la boîte vous-même, let* ouvre la boîte pour vous, et si elle est vide, il annule toute la suite. C'est un peu comme avoir un majordome qui vérifie que le courrier n'est pas du spam avant de vous le donner.

3. La Monade Liste

Maintenant qu'on a vu le principe général, attaquons la monade Liste — la star de ce cours, et celle qui relie le plus naturellement au cours 2 sur les ADT.

L'idée : une liste représente plusieurs résultats possibles pour un calcul (non-déterminisme). bind sur une liste, c'est "pour chaque élément de la liste, applique la fonction, et rassemble tous les résultats".

3.1 bind & return pour les listes

OCaml
// bind pour les listes : flat_map / concat_map *)
let (>>=) lst f = List.flatten (List.map f lst)

// return pour les listes : singleton *)
let return x = [x]

Testons :

OCaml
[1; 2; 3] >>= (fun x -> [x; x * 10])
- : int list = [1; 10; 2; 20; 3; 30]

Comprenez ce qui se passe : pour chaque élément de la liste d'entrée, on génère une liste de sortie, puis on concatène le tout. C'est le flat_map des listes (ou concat_map).

Avec let* :

OCaml
let (let*) = (>>=)

let exemple =
  let* x = [1; 2; 3] in
  let* y = [10; 100] in
  return (x + y)
- : int list = [11; 101; 12; 102; 13; 103]

On vient de faire un produit cartésien ! Chaque let* "choisit" une valeur dans la liste, et on combine les choix. Ça revient à écrire :

OCaml
// Équivalent sans monade, avec deux boucles imbriquées *)
List.flatten (
  List.map (fun x ->
    List.map (fun y -> x + y) [10; 100])
  [1; 2; 3])
Fonctoriel vs Monadique : map applique une transformation simple à chaque élément ('a -> 'b). bind applique une transformation qui peut changer la structure ('a -> 'b t). C'est la différence entre repeindre chaque pièce d'une maison (map) et permettre à chaque pièce de se diviser en plusieurs sous-pièces (bind). Le map ne peut pas aplatir, bind si.

3.2 Compréhensions de listes

Avec let* sur les listes, on a une syntaxe très proche des compréhensions de listes (list comprehensions) qu'on trouve en Python, Haskell, ou Scala.

En Python : [x+y for x in [1,2,3] for y in [10,100]]

En OCaml avec la monade liste :

OCaml
// Toutes les combinaisons de (x, y) où x ∈ {1,2,3}, y ∈ {a,b} *)
let paires =
  let* x = [1; 2; 3] in
  let* y = ["a"; "b"] in
  return (x, y)
- : (int * string) list = [(1, "a"); (1, "b"); (2, "a"); (2, "b"); (3, "a"); (3, "b")]

On peut aussi filtrer avec une condition (comme le if dans les compréhensions Python) :

OCaml
// "Yeux de la coccinelle" : combinaisons de (x, y, z) où x+y+z = 10 *)
let yeux =
  let* x = [1; 2; 3] in
  let* y = [1; 2; 3] in
  let* z = [1; 2; 3] in
  let* _ = if x + y + z = 10 then [()] else [] in
  return (x, y, z)
- : (int * int * int) list = [(2, 3, 5); (3, 2, 5); ...]

Le filtre if utilise une astuce : si la condition est vraie, on retourne [()] (une liste avec un élément, qui "laisse passer"), sinon [] (liste vide, qui "bloque" la combinaison). C'est exactement le guard des compréhensions.

La monade liste, c'est le "J'essaie toutes les combinaisons" de l'informatique. Vous lui donnez des choix, elle les explore tous. C'est comme un enfant dans un magasin de bonbons : "je prends un bonbon rouge ou bleu ou vert" — avec la monade liste, il les prend tous. Et son parent (bind) paie l'addition.

Application concrète : On a une table d'employés et une table de départements, on veut lister tous les employés avec leur département :

OCaml
type employe = { nom: string; dept_id: int }
type departement = { id: int; nom_dept: string }

let employes = [
  { nom = "Alice"; dept_id = 1 };
  { nom = "Bob";   dept_id = 2 };
  { nom = "Eve";   dept_id = 1 };
]

let departements = [
  { id = 1; nom_dept = "R&D" };
  { id = 2; nom_dept = "Marketing" };
]

// Jointure avec la monade liste *)
let repertoire =
  let* emp   = employes in
  let* dept  = departements in
  let* _    = if emp.dept_id = dept.id then [()] else [] in
  return (emp.nom, dept.nom_dept)
- : (string * string) list = [("Alice", "R&D"); ("Bob", "Marketing"); ("Eve", "R&D")]

C'est une requête de base de données écrite en OCaml pur ! Le let* fait la jointure, le if fait la condition de jointure.

SQL ~ Monade Liste : La similarité entre la monade liste et SQL n'est pas une coïncidence. Les FROM en SQL sont comme des let* (produit cartésien), les WHERE sont comme des guards, et SELECT est comme un return. C'est pour ça que des langages comme LINQ (C#) ou les compréhensions Haskell sont si puissants pour les requêtes : ils généralisent la monade liste à n'importe quelle source de données.

3.3 Option : la liste à 0 ou 1 élément

Voici une révélation : option est un cas particulier de list où la liste a au plus 1 élément.

ConceptListe (non-déterminisme)Option (échec possible)
Échec / Absence[] (liste vide)None
Succès / Présence[x] (singleton)Some x
Plusieurs résultats[x; y; z]impossible
bindflat_map (concatène)applique si Some
OCaml
// Option est une Liste avec au plus un élément *)
let option_to_list opt =
  match opt with
  | None   -> []
  | Some x -> [x]

// Le bind de l'option est le flat_map limité à 0 ou 1 :
// - Si None → aucun résultat → None
// - Si Some x → un résultat → f x, qui doit donner Some ou None

Cette analogie est profonde : la monade liste est la mère de toutes les monades de "choix". L'option en est une version appauvrie (0 ou 1) mais très utile. La monade Result est une généralisation avec un message d'erreur (Ok 'a | Error 'e).

Si l'option est un interrupteur (marche/arrêt), la list est un variateur (plein de positions). Et Result, c'est l'interrupteur avec un post-it "pourquoi ça marche pas ?" dessus.

Et avec les binding operators d'OCaml (≥ 4.08), on peut même avoir des let* qui fonctionnent pour toutes les monades en même temps si on définit le bon let* — ou on peut utiliser des opérateurs par monade (let* pour les listes, let+ pour les options, etc.).

4. La Monade State

Parfois, on a besoin de maintenir un état qui évolue au fil des calculs : un compteur, un générateur de nombres aléatoires, un cache de mémoïsation. En programmation impérative, on utilise une variable mutable. En programmation fonctionnelle, on passe l'état explicitement.

La monade State encapsule ce passage d'état pour que vous n'ayez pas à le faire à la main.

L'idée : un calcul stateful est une fonction qui prend un état et retourne une valeur et un nouvel état :

OCaml
// Un calcul stateful : 's → 'a * 's *)
type ('a, 's) state = State of ('s -> 'a * 's)

// Exécuter un calcul stateful *)
let run_state (State f) s = f s

Le return pour State : produit une valeur sans changer l'état.

OCaml
let return x = State (fun s -> (x, s))

Le bind pour State : exécute le premier calcul, récupère la valeur et le nouvel état, puis exécute le second calcul.

OCaml
let (>>=) (State m) f =
  State (fun s ->
    let (a, s') = m s in
    let (State g) = f a in
    g s')

On a aussi besoin de deux opérations de base :

OCaml
// Lire l'état courant *)
let get = State (fun s -> (s, s))

// Écrire un nouvel état *)
let put s = State (fun _ -> ((), s))

Exemple : un compteur. On veut numéroter automatiquement des éléments :

OCaml
// Incrémenter et retourner la nouvelle valeur *)
let tick =
  let* n = get in
  let* _ = put (n + 1) in
  return n

// Numéroter une liste *)
let numeroter lst =
  let* x    = lst in
  let* idx  = tick in
  return (idx, x)

// On lance le calcul avec état initial 0 *)
run_state (numeroter ["a"; "b"; "c"]) 0
- : (string * int) list * int = ([("a", 0); ("b", 1); ("c", 2)], 3)

Résultat : les éléments numérotés et l'état final (le compteur à 3).

4.1 Mémoïsation avec State

Un classique : Fibonacci avec mémoïsation. Naïvement, fib est exponentiel. Avec un cache (état), il devient linéaire :

OCaml
// Cache de Fibonacci : une table associant n → fib(n) *)
let fib_memo n =
  let rec fib_state n =
    let* cache = get in
    match Hashtbl.find_opt cache n with
    | Some v -> return v
    | None   ->
        let* v =
          if n <= 1 then return n
          else
            let* a = fib_state (n - 1) in
            let* b = fib_state (n - 2) in
            return (a + b)
        in
        let* _ = put (Hashtbl.add cache n v; cache) in
        return v
  in
  fst (run_state (fib_state n) (Hashtbl.create 100))

Ce code est purement fonctionnel (le cache est encapsulé dans la monade State) tout en étant aussi efficace qu'une version impérative avec cache mutable. Le meilleur des deux mondes !

State vs mutable : La monade State permet d'écrire du code qui ressemble à du code impératif (get, put, tick) tout en restant pur. C'est la différence entre "je modifie une variable" et "je crée un nouvel état à partir de l'ancien". Le compilateur peut optimiser, le code est parallélisable, et vous avez la garantie que l'état ne fuit pas ailleurs. En Haskell, c'est comme ça qu'on fait de l'impératif dans un monde pur (la monade IO est une forme de State).

5. La Monade Environnement (Reader)

Parfois, on a juste besoin de lire une configuration, sans la modifier. Un paramètre global (une base de données, des réglages, un environnement) qu'on ne veut pas passer explicitement à chaque fonction.

La monade Reader (ou Environnement) encapsule ce pattern :

OCaml
// Reader : un calcul qui lit dans un environnement 'r *)
type ('a, 'r) reader = Reader of ('r -> 'a)

let run_reader (Reader f) r = f r

// return : ignore l'environnement *)
let return x = Reader (fun _ -> x)

// bind : passe l'environnement à travers la chaîne *)
let (>>=) (Reader m) f =
  Reader (fun r ->
    let a = m r in
    let (Reader g) = f a in
    g r)

// ask : lire l'environnement *)
let ask = Reader (fun r -> r)

// local : exécuter un calcul dans un environnement modifié *)
let local f (Reader m) = Reader (fun r -> m (f r))

Vous remarquez la différence avec State ? Reader ne peut pas modifier l'environnement — il le passe juste à chaque étape. C'est ce qui le rend plus simple et plus sûr.

5.1 Évaluation contextuelle

Reprenons l'exemple des expressions arithmétiques du cours 2, mais avec des variables. L'environnement est une table qui associe des noms à des valeurs :

OCaml
// Notre AST avec variables *)
type expr =
  | Const  of int
  | Var    of string
  | Add    of expr * expr
  | Let    of string * expr * expr

// Environnement de type : string → int *)
type env = (string, int) Hashtbl.t

// Évaluateur avec la monade Reader *)
let rec eval e =
  match e with
  | Const n      -> return n
  | Var x        ->
      let* r = ask in
      return (Hashtbl.find r x)
  | Add (e1, e2)  ->
      let* a = eval e1 in
      let* b = eval e2 in
      return (a + b)
  | Let (x, e1, e2) ->
      let* v = eval e1 in
      local (fun r -> Hashtbl.add r x v; r) (eval e2)

// Et on lance :*)
run_reader (eval (Add (Var "x", Const 1)))
  (Hashtbl.of_list ["x", 42])
- : int = 43

Sans Reader, il faudrait passer l'environnement r manuellement à chaque appel récursif. Avec Reader, il est invisible : on l'obtient avec ask quand on en a besoin, et local permet de créer des sous-environnements.

Reader, c'est le majordome discret qui se tient dans un coin de la pièce. Vous ne le voyez pas, mais quand vous avez besoin de quelque chose, il est là (ask). Et si vous voulez changer de costume pour une scène, il vous habille (local). State, c'est le majordome qui en profite pour vous piquer votre portefeuille (put).
Reader vs State : Si vous avez besoin de lire et écrire, c'est State. Si vous avez seulement besoin de lire, c'est Reader. Reader est plus contraint, donc plus sûr. On peut toujours transformer un Reader en State (en ignorant la sortie d'état), mais pas l'inverse sans précaution.

6. La Monade Continuation

On arrive à la monade la plus abstraite (et la plus puissante) : la monade Continuation (Cont). Alors que List représente "plusieurs résultats", Option "peut-être un résultat", State "un résultat avec état", la Continuation représente "ce qu'on fait du résultat".

En programmation classique, une fonction retourne une valeur. En style par passages de continuation (CPS), une fonction prend un argument supplémentaire : une continuation — une fonction qui sera appelée avec le résultat.

OCaml
// Style direct *)
let add x y = x + y

// Style par passages de continuation *)
let add_cps x y k = k (x + y)

La monade Continuation encapsule ce pattern :

OCaml
// Continuation monad : ('a → 'r) → 'r *)
type ('a, 'r) cont = Cont of (('a -> 'r) -> 'r)

let run_cont (Cont f) k = f k

// return : passe la valeur à la continuation *)
let return x = Cont (fun k -> k x)

// bind : compose les continuations *)
let (>>=) (Cont m) f =
  Cont (fun k ->
    m (fun a ->
      let (Cont g) = f a in
      g k))

Mais à quoi ça sert ?

Avec la monade Continuation, on peut implémenter :

En fait, la monade Continuation est la plus expressive de toutes : on peut simuler toutes les autres monades avec elle.

6.1 Early exit et backtracking

Exemple : on cherche un élément dans une liste, mais on veut s'arrêter dès qu'on trouve le premier. Avec la monade Cont, on peut "court-circuiter" :

OCaml
// callcc : capturer la continuation courante *)
// (call-with-current-continuation de Scheme) *)
let callcc f =
  Cont (fun k ->
    let (Cont g) = f (fun a -> Cont (fun _ -> k a)) in
    g k)

// Trouver le premier élément qui satisfait un prédicat *)
let find_first p lst =
  callcc (fun exit ->
    let* x = lst in
    let* _ = if p x then exit x else return () in
    return None)

callcc capture la continuation comme un "point de retour". Quand on appelle exit x, on saute directement à ce point, peu importe où on est dans le calcul. C'est un break ou un return impératif, mais purement fonctionnel !

call/cc (call-with-current-continuation) est une fonction célèbre du langage Scheme (un dialecte de Lisp). C'est l'une des constructions de contrôle les plus puissantes jamais inventées. Avec call/cc, vous pouvez implémenter des exceptions, des générateurs, des coroutines, et même des threads. La monade Continuation en OCaml est la version typée et pure de cette idée. Petite blague de programmeur fonctionnel : "call/cc est tellement puissant qu'il devrait être illégal dans certains États."

7. Les lois des monades

Pour qu'un type avec return et >>= soit vraiment une monade, il doit respecter trois lois. Elles sont simples mais importantes (surtout si vous créez votre propre monade).

Les 3 lois des monades

  1. Identité à gauche : return x >>= f = f x
    Mettre une valeur dans le contexte puis l'enchaîner avec f doit revenir au même que d'appliquer f directement.
  2. Identité à droite : m >>= return = m
    Enchaîner un calcul avec return ne doit pas le modifier.
  3. Associativité : (m >>= f) >>= g = m >>= (fun x -> f x >>= g)
    La façon dont on parenthèse les enchaînements ne doit pas changer le résultat.

Vérifions pour la monade Liste :

OCaml
(* Identité à gauche : return x >>= f = f x *)
(* return x = [x]; [x] >>= f = flatten (map f [x]) = f x ✓ *)

(* Identité à droite : m >>= return = m *)
(* m >>= return = flatten (map return m) = flatten [[a];[b];...] = m ✓ *)

(* Associativité : (m >>= f) >>= g = m >>= (fun x -> f x >>= g) *)
(* Les deux appliquent f à chaque élément puis g à chaque résultat ✓ *)

Ces lois garantissent que les monades se comportent de façon prévisible. C'est ce qui permet au programmeur de raisonner sur son code et au compilateur de faire des optimisations. Sans ces lois, on ne pourrait pas utiliser le sucre let* en toute sécurité.

Les lois des monades, c'est comme le code de la route : personne ne les apprend par cœur, mais tout le monde est content qu'elles existent quand il s'agit d'éviter les accidents.

8. Conclusion

On a parcouru un sacré bout de chemin !

Monade Contexte return bind / >>=
Liste Non-déterminisme [x] flat_map
Option Échec possible Some x applique si Some
State État mutable fun s -> (x, s) chaîne avec état
Reader Configuration fun _ -> x passe l'env
Cont Contrôle (CPS) fun k -> k x compose les k

D'autres monades existent (et vous les rencontrerez sûrement) :

Le lien avec Parsec : Les combinateurs de parseurs (comme la bibliothèque Angstrom ou Menhir) sont construits autour d'une monade. Un parseur, c'est une fonction qui prend une entrée et produit soit un résultat (avec le reste de l'entrée), soit une erreur. Ça ressemble à une combinaison de State (l'entrée qui progresse), de Option (l'échec possible), et de Reader (les règles de grammaire). Dans le prochain cours, on construira un petit parseur pour comprendre comment ça marche.

En attendant, retenez ceci :

L'essentiel

  • Une monade, c'est juste un motif de composition : on a une valeur dans un contexte, on veut lui appliquer une fonction qui produit une valeur dans le même contexte.
  • return met une valeur dans le contexte par défaut.
  • >>= (bind) enchaîne deux calculs contextuels.
  • let* est le sucre syntaxique pour bind (OCaml ≥ 4.08).
  • Les lois garantissent que tout se passe bien.
  • Les monades sont partout : listes, options, état, parsing, async…

"A monad is just a monoid in the category of endofunctors, what's the problem?"
— Blague de mathématicien·ne·s qui fait pleurer les développeur·se·s

← Cours 2 : ADT Cours 4 : Parseurs →

Fait avec ❤️, beaucoup de bind, et un return bien mérité

© 2026 — laurent.thiry@uha.fr