Monades
Motivation
Une monade, comme on va le voir, est un patron de conception. Elle permet notamment de rendre un code qui gère des erreurs ou des entrées-sorties moins lourd. Voici un code lourdingue en TypeScript :
type User = { id: number; name: string; };
type Account = { userId: number; balance: number };
type Result<T, E> = { success: true; value: T } | { success: false; error: E};
Considérons les fonctions de traitement suivantes :
function fetchUser(userId: number): Result<User> {
if (userId <= 0) return { success: false, error: "ID utilisateur invalide" };
return { success: true, value: { id: userId, name: "Alice", isBanned: false } };
}
function fetchAccount(user: User): Result<Account> {
return { success: true, value: { userId: user.id, balance: 100 } };
}
function creditAccount(account: Account, amount: number): Result<Account> {
if (amount <= 0) return { success: false, error: "Montant invalide" };
return { success: true, value: { ...account, balance: account.balance + amount } };
}
La fonction principale est :
function processCredit(userId: number, amount: number): Result<Account, string> {
const userRes = fetchUser(userId);
if (userRes instanceof Err)
return userRes;
const accountRes = fetchAccount(userRes.value);
if (accountRes instanceof Err)
return accountRes;
const creditRes = creditAccount(amount)(accountRes.value);
if (creditRes instanceof Err)
return creditRes;
return creditRes;
}
Elle souffre du fait que l’on a des if répété. Pire, on pourrait pu écrire du code Hadouken :
function processCredit(userId: number, amount: number): Result<Account, string> {
const userRes = fetchUser(userId);
if (userRes instanceof Ok) {
const accountRes = fetchAccount(userRes.value);
if (accountRes instanceof Ok) {
const creditRes = creditAccount(amount)(accountRes.value);
return creditRes;
}
else
return accountRes; // Erreur compte
}
else
return userRes; // Erreur utilisateur
}
L’animation rigolotte obtenu avec https://reibitto.github.io/hadoukenify/ :
Réécriture du code
Avec des monades, on peut écrire du code monadique puis admirer l’élégance : 3 opérations = 3 lignes. La gestion d’erreur est cachée dans les types.
function processCredit(userId: number, amount: number): Result<Account, string> {
return fetchUser(userId)
.bind(fetchAccount)
.bind(creditAccount(amount));
}
On revoit les types comme suit :
type Result<T, E> = Ok<T, E> | Err<T, E>;
class Ok<T, E> {
constructor(public readonly value: T) {}
bind<U>(fn: (val: T) => Result<U, E>): Result<U, E> { return fn(this.value); }
}
class Err<T, E> {
constructor(public readonly error: E) {}
bind<U>(_fn: (val: T) => Result<U, E>): Result<U, E> {
return this as unknown as Result<U, E>; // on convertit le Result<T, E> en Result<U, E>
}
}
const ok = <T, E>(val: T): Result<T, E> => new Ok(val); // constructeur de succès
const err = <T, E>(err: E): Result<T, E> => new Err(err); // constructeur d'échec
La monade c’est le type Result<T, E>. Il s’agit d’un type somme. On notera que l’application d’une fonction fn est gérée dans la méthode bind des classes Ok et Err.
Ainsi, les fonctions de traitement sont sensiblement inchangées :
function fetchUser(userId: number): Result<User, string> {
if (userId <= 0) return err("ID invalide");
return ok({ id: userId, name: "Alice" });
}
function fetchAccount(user: User): Result<Account, string> {
return ok({ userId: user.id, balance: 100 });
}
function creditAccount(amount: number): (account: Account) => Result<Account, string> {
return (account) => {
if (amount <= 0) return err("Montant invalide");
return ok({ ...account, balance: account.balance + amount });
};
}
Le même exemple en Rust
#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq)]
struct User {
id: u32,
name: String,
}
#[derive(Debug, PartialEq)]
struct Account {
user_id: u32,
balance: f64,
}
fn fetch_user(user_id: u32) -> Result<User, String> {
if user_id == 0 {
Err("ID utilisateur invalide".to_string())
} else {
Ok(User { id: user_id, name: "Alice".to_string() })
}
}
fn fetch_account(user: User) -> Result<Account, String> {
Ok(Account { user_id: user.id, balance: 100.0 })
}
fn credit_account(account: Account, amount: f64) -> Result<Account, String> {
if amount <= 0.0 {
Err("Montant invalide".to_string())
} else {
Ok(Account { balance: account.balance + amount, ..account })
}
}
}
Puis le code Hadouken :
#![allow(unused)]
fn main() {
fn process_credit_hadouken(user_id: u32, amount: f64) -> Result<Account, String> {
match fetch_user(user_id) {
Ok(user) => {
match fetch_account(user) {
Ok(account) => {
match credit_account(account, amount) {
Ok(updated_account) => Ok(updated_account),
Err(err) => Err(err),
}
}
Err(err) => Err(err),
}
}
Err(err) => Err(err),
}
}
}
La version monadique :
#![allow(unused)]
fn main() {
fn process_credit_monadic(user_id: u32, amount: f64) -> Result<Account, String> {
fetch_user(user_id)
.and_then(fetch_account)
.and_then(|account| credit_account(account, amount))
}
}
En Rust, on a aussi un opérateur ? qui donne encore un code plus léger :
#![allow(unused)]
fn main() {
fn process_credit_idiomatic(user_id: u32, amount: f64) -> Result<Account, String> {
let user = fetch_user(user_id)?;
let account = fetch_account(user)?;
let updated_account = credit_account(account, amount)?;
Ok(updated_account)
}
}
L’opérateur ? est built-in dans le langage. Il déballe la valeur de type T d’un Result<T, E> s’il n’y a pas d’erreur. Sinon, il arrête la fonction courante qui rend l’erreur derrière le Result<T, E>. Historiquement, voir https://github.com/rust-lang/rust/issues/61000, on utilisait une macro try! et on écrivait :
#![allow(unused)]
fn main() {
fn process_credit_deprecated(user_id: u32) -> Result<Account, String> {
let user = try!(fetch_user(user_id));
let account = try!(fetch_account(user));
Ok(account)
}
}
avec la macro try! définie par :
#![allow(unused)]
fn main() {
macro_rules! try {
($expr:expr) => {
match $expr {
Ok(val) => val,
Err(err) => return Err(From::from(err)),
}
};
}
}
Revenons aux sources : Haskell
En Haskell, le type natif pour la gestion d’erreurs s’appelle Either e a :
Left e: le constructeur d’échec (contient l’erreure).Right a: le constructeur de succès (contient la valeura).
data User = User { userId :: Int, userName :: String } deriving (Show)
data Account = Account { accUserId :: Int, balance :: Double } deriving (Show)
fetchUser :: Int -> Either String User
fetchUser uid
| uid <= 0 = Left "ID utilisateur invalide"
| otherwise = Right (User uid "Alice")
fetchAccount :: User -> Either String Account
fetchAccount user = Right (Account (userId user) 100.0)
creditAccount :: Double -> Account -> Either String Account
creditAccount amount acc
| amount <= 0 = Left "Montant invalide"
| otherwise = Right (acc { balance = balance acc + amount })
Voici la version de la fonction principale que personne n’écrirait :
processCreditExplicit :: Int -> Double -> Either String Account
processCreditExplicit uid amount =
case fetchUser uid of
Left err -> Left err
Right user ->
case fetchAccount user of
Left err -> Left err
Right acc ->
case creditAccount amount acc of
Left err -> Left err
Right updatedAcc -> Right updatedAcc
En Haskell, le bind s’écrit >>=. C’est d’ailleurs le logo du langage :
processCreditMonadic :: Int -> Double -> Either String Account
processCreditMonadic uid amount =
fetchUser uid >>= fetchAccount >>= creditAccount amount
Haskell offre aussi la notation do qui est du sucre syntaxique :
processCredit :: Int -> Double -> Either String Account
processCredit uid amount = do
user <- fetchUser uid -- bind : déballe User si Right, s'arrête si Left
acc <- fetchAccount user -- bind : déballe Account si Right
updatedAcc <- creditAccount amount acc
return updatedAcc -- rempaquette dans Right
Monade pour les entrées sorties
Haskell est paresseux. Il suppose que les fonctions sont pures. Du coup, leur ordre d’exécution n’a pas d’importance. Ainsi, si on avait des fonctions “normales” pour les fonctions qui affichent des choses à l’écran, ça peut créer des problèmes. Par exemple dans le code suivant
-- Si c'était du code pur (SANS monade) :
print "Entrez votre nom :"
nom = readLine
print ("Bonjour " ++ nom)
rien ne garantirait que readLine n’est pas exécuté avant le premier print. C’est dommage car on nous demanderait d’écrire quelque chose sans que Entrez votre nom : soit affichée.
La solution en Haskell est d’introduire un type IO a qui représente une action, qui, une fois exécuté dans le monde réel, produit un élement de type a. Le type IO a est une monade.
main :: IO ()
main =
putStrLn "Entrez votre nom :" >>= \_ -> -- 1. Afficher
getLine >>= \nom -> -- 2. Lire (bloque jusqu'à la frappe)
putStrLn ("Bonjour " ++ nom) -- 3. Afficher en utilisant 'nom'
Le `` est le symbole pour définir une lambda-expression argument -> ce qu'on renvoie ou (arg1, arg2, ...) -> ce qu'on renvoie. Le texte Entrez votre nom : doit être affichée avant la lecture de la ligne. Pourquoi ? Car le premier appel au bind >>= prend deux arguments :
- la monade qui est le résultat de
putStrLn "Entrez votre nom :". En interne, elle contient l’état du monde après l’affichage deEntrez votre nom :. - la fonction à suivre
_ -> ...
Pour savoir s’il faut exécuter la fonction à suivre, il faut obtenir le résultat de putStrLn "Entrez votre nom :" et donc afficher Entrez votre nom : avant de passer à la suite.
Avec le sucre syntaxique du do, on obtient alors une fonction qui ressemble à ce que l’on aurait écrit dans un langage impératif :
main :: IO ()
main = do
putStrLn "Entrez votre nom :"
nom <- getLine
putStrLn ("Bonjour " ++ nom)
Mais il s’agit en fait bien d’une écriture monadique !
Définition générale d’une monade
Une monade est un type M<t> paramétré par t avec deux opérations :
- empaquettage :
return : t → M<t>, (ouunitouof) qui prend une valeur brute de typetet qui l’empaquette dansM<t> - application :
bind : (Mt, t → M<u>) → M<u>(ou>>=, ouand_then) qui prend unM<t>et une fonctionf : t → M<u>qui appliquefsur la valeur à l’intérieur duM<t>.
De plus, on doit respecter les contraintes suivantes :
- Appliquer directement
fou empaquetter puis appliquerfdonne la même chose - En appliquer l’empaquettage on obtient la même chose
- Appliquer
fpuisg, c’est la même chose que appliquer la fonction qui tout élémentxassocie l’application degsurf(x)