Cette année je gère mes erreurs autrement

Clairement, la gestion des erreurs, ce n’est pas forcément ce que je préfère (et en plus on a toujours tendance à oublier des cas, sans parler de la manière de les traiter). Et puis, pendant les vacances de Noël, j’ai été amené à faire la revue de ce livre Functional Programming in Java (Il m’a fallu être très concentré - je suis plus JavaScript que Java ;) - mais c’était très instructif) où l’auteur Pierre-Yves Saumont présente notamment le type Optional de Java 8. En parallèle, Yannick Loiseau a mis en œuvre sur le projet sur le projet Golo le type Result et “augmenté” le type Optional de Java 8 (ce qui m’a considérablement aidé dans la compréhension du livre sus-cité).

Tout cela m’a apporté un regard différent sur la gestion des erreurs, et je commence à changer ma façon de coder. Je vous propose, par des petits bouts de code, de vous présenter ce que je suis en train de “creuser”, à travers différents langages.

Remarque: Si vous n’êtes pas d’accord avec ce que j’écris, ou si vous avez des remarques, des conseils, des amélioration à proposer, n’hésitez pas à le faire:

Java 8 et le type Optional

Mon cas d’usage va être tout simple, je vais avoir une fonction anonyme qui me permet de transformer une String en Integer. Mais au lieu de retourner directement un Integer, je vais retourner un Optional<Integer>.

Un Optional<T> est un type qui peut avoir 2 types de valeur, “quelque chose” (<T>), ou “rien” … Oui je sais, ça peut surprendre, mais avec un peu de code, ça va être plus clair:

Function<String, Optional<Integer>> toInt = (String val) -> {
  try {
    return Optional.of(java.lang.Integer.parseInt(val));
  } catch (Exception e) {
    return Optional.empty();
  }
};

Donc pour résumer:

  • j’essaye de transformer ma String en Integer: java.lang.Integer.parseInt(val)
  • si ça marche, je retourne un Optional qui prend pour valeur le résultat: Optional.of(java.lang.Integer.parseInt(val))
  • si ça foire, je retourne du vide (à l’origine Optional, c’est pour traiter les null): Optional.empty()

L’avantage de faire ça, c’est que le type Optional vient avec quelques méthodes bien pratiques qui vont vous permettre de faire des choses comme celles-ci:

// transforme moi "42" en 42, si ça marche affiche "Succeed!"
// et retourne 42
// si ça ne marche pas, tu retournes quand même 42
Integer result1 = toInt.apply("42").map((value)->{
  // value est la valeur contenue dans l'Optional retourné par toInt
  System.out.println("Succeed!");
  return value;
}).orElse(42);

// transforme moi "Quarante-Deux" en 42, si ça marche affiche "Succeed!"
// ... mais ça ne marchera pas
// et retourne 42
// si ça ne marche pas, tu retournes quand même 42
Integer result2 = toInt.apply("Quarante-Deux").map((value)->{
  System.out.println("Succeed!");
  return value;
}).orElse(42);

// transforme moi "Quarante-Deux" en 42, si ça marche affiche "Succeed!"
// et retourne 42
// si ça ne marche pas, tu retournes quand même 42
// mais tu affiches qu'il y a eu un problème
Integer result3 = toInt.apply("Quarante-deux").map((value)->{
  System.out.println("Succeed!");
  return value;
}).orElseGet(() -> {
  System.out.println("Huston? Failed!");
  return 42;
});

Vous vous apercevrez qu’en utilisant ceci, votre code va gagner en clarté, mais que en plus vous allez vous poser les bonnes questions en termes d’erreurs et finalement pensez plus facilement à toutes les possibilités.

Et là vous commencez à faire du fonctionnel (enfin, il me semble ;)). C’est à ce moment là que je me suis dit, il serait peut-être temps de rejeter un coup d’œil à Scala (!!!).

Scala et les types Option et Either

Le type Option

En Scala, ma super fonction de transformation String vers Integer, va ressembler à ceci:

def toInt(value: String): Option[Int] = {
  try {
    Some(Integer.parseInt(value))
  } catch {
    case e: NumberFormatException => None
  }
}

Donc pour faire court:

  • Option[Int] en Scala c’est comme Optional<Integer> en Java
  • Some(value) retourne un Option qui “contient” la valeur value
  • None, c’est un Option qui est “vide”

Et du coup, nous pouvons faire comme toute à l’heure:

val result1:Int = toInt("42").map((value: Int)=> {
  println("Succeed!")
  value
}).getOrElse(42)

val result2:Int = toInt("Quarante-Deux").map((value: Int)=> {
  println("Succeed!")
  value
}).getOrElse({
  println("failed!") // si tout va bien vous "passerez" par là
  42
})

Le type Either

Par contre à l’usage, on s’aperçoit que cela pourrait-être sympa de pouvoir stocker l’erreur au même titre que la valeur qui est stockée en cas de succès. Et là Scala marque un point (@loic_d si tu me lis, ça vaut une bière ça!) par rapport à Java. En effet, Scala propose le type Either qui permet de faire ça.

du coup ma super fonction de transformation String vers Integer, va maintenant ressembler à ceci:

def toInt(value: String): Either[String, Int] = {
  try {
    Right(Integer.parseInt(value))
  } catch {
    case e: NumberFormatException => Left("Huston? Failed!")
  }
}

Donc:

  • Right(Integer.parseInt(value)) cela retourne un Either avec une valeur (un contenu à droite et rien à gauche: Either[rien, valeur])
  • Left("Huston? Failed!") cela retourne un Either sans valeur mais avec la possibilité de stocker quelque chose “à gauche” (Either[message d'erreur, rien])

Et je vais maintenant pouvoir l’utiliser comme ceci:

val result:Int = toInt("Quarante-Deux").fold((errMessage)=>{
  println(errMessage) // affichera "Huston? Failed!"
  42
},(value)=>{
  println("Succeed!")
  value
})

Donc vous l’avez compris, fold prend 2 fonctions anonymes en paramètre, la 1ère (la plus à gauche) qui permet de récupérer le message d’erreur de Left, et la 2ème (la plus à droite donc) qui permet de récupérer la valeur de Right en cas de succès.

Et si vous voulez vous la jouer un peu, vous pouvez “faire du pattern-matching” (c’te classe!):

val result:Int = toInt("42") match {
  case Right(value) =>
    println("Succeed!")
    value
  case Left(errMessage) =>
    println(errMessage)
    42
}

… presque je trouve ça sympa de faire du Scala ;) …

On continue?

Lorsque que Yannick a proposer sa pull-request, le commentaire était le suivant:

This introduce some objects, augmentations and functions to deal with
errors (null, exceptions, ...) functional style.

- `Result` type similar to Haskell `Either` or Rust `Result`
- augmentation on java `Optional`
- decorators to adapt function to return or deal with arguments of these
  types

Rust? ça fait plusieurs fois que l’on me parle de ce langage, il faut que j’y jette un coup d’œil.

Rust et le type Result

Alors, Rust propose un type Result qui ressemble beaucoup au Either de Scala (et probablement à celui d’Haskell, mais je ne suis pas allé voir).

Mais une des spécificités de Rust, c’est qu’en standard, sa méthode de transformation de String en entier, renvoie un Result!

Donc, j’écris une nouvelle fois ma fonction de transformation: (mes 1ers pas en Rust ;))

let to_int = |s: &str| {
  s.parse::<u32>()
};

C’est court! Hein! Et pour l’utiliser, nous ferons comme ceci:

let result = match to_int("Quarante-Deux") {
  Ok(n) => {
    println!("Succeed!: {}", n);
    n
  },
  Err(err) => {
    println!("Failed!: {:?}", err);
    42
  }
};

Oui, oui, c’est du pattern-matching.

Maintenant, cela fait plusieurs fois que je vous parle de la PR de @yannick_loiseau pour le projet Golo. Il s’avère que Golo s’inspire de ce que nous venons de voir pour son module de gestion des erreurs gololang.Errors.

Golo avec Some, None, Result et Error

Some et None

Sur le principe du type Optional de Java 8, ma fonction de transformation s’écrira de cette façon:

let toInt = |value| {
  try {
    return Some(java.lang.Integer.parseInt(value))
  } catch(e) {
    return None()
  }
}

Et nous l’utiliserons de cette manière:

let result = toInt("42"): either(|value| {
  println("Succeed!")
  return value
}, {
  println("Failed!")
  return 42
})

Ou bien de cette façon:

let result = toInt("Quarente-deux"): map(|value| {
  println("Succeed!")
  return value
}): orElse(42) #  valeur par défaut

Ou même: (si l’on souhaite déclencher un traitement en cas d’erreur)

let result = toInt("Quarante-deux"): map(|value| {
  println("Succeed!")
  return value
}): orElseGet({
  println("Failed!")
  return 42
})

C’est plutôt simple à utiliser, mais dans le cadre de gestion d’erreur (allant plus loin que la gestion de valeurs nulles), il est intéressant de pouvoir stocker l’état de l’erreur (comme nous l’avons fait avec Scala et Rust). Et avec Golo, “tout est possible”!

Result et Error

J’écris une nouvelle version de ma fonction de transformation:

let toInt = |value| {
  try {
    return Result(java.lang.Integer.parseInt(value))
  } catch(e) {
    return Error(e: getMessage())
  }
}

Donc par rapport à toute à l’heure, au lieu d’un None(), j’ai l’opportunité de retourner un Error(quelque_chose), qui n’est ni plus ni moins qu’un Result avec un Left “contenant” une valeur et un Right “vide” (rappelez vous le § sur Scala et Either).

Et maintenant, je peux utiliser toInt de cette façon:

let result = toInt("Quarante-deux"): either(
  mapping=|value| {
    println("Succeed!")
    return value
  },
  default=|err| {
    println("Failed!" + err)
    return 42
  })

Plutôt sympa, non? l’utilisation des paramètres nommés (mappinget default) n’est pas obligatoire, mais je trouve que l’on y gagne en lisibilité.

Et là, si tu es un développeur JavaScript, tu es triste, car tu voudrais bien faire ça. Pas de panique, il existe un framework topissime: Monet.js qui permet de se livrer aux joies de la gestion fonctionnelle des erreurs en JavaScript.

JavaScript: Maybe et Validation avec Monet.js

Pour faire court:

  • Maybe c’est comme Optional
  • Validation c’est comme Result

Maybe

Alors maintenant, vous avez l’habitude, je vais re-écrire ma fonction de transformation. Sauf que petite spécificité de JavaScript, c’est que l’équivalent du parseInt ne “plante” pas quand on lui balance une chaîne qui ne lui convient pas, mais retourne un NaN (not a number). (Sans compter que le parseInt de JavaScript n’a pas tout à fait le même comportement que celui de Java).

Mais je vais garder mon cas d’usage, en forçant une exception en cas de NaN. Donc, ma fonction ressemble à ça:

import monet from 'monet';

let toInt = (strValue) => {
  try {
    let res = Number.parseInt(strValue);
    if(Number.isNaN(res)) throw new Error("Huston? There is no number here!?");
    return monet.Maybe.Some(res);
  } catch(e) {
    return monet.Maybe.None();
  }
};

Et je l’utilserais comme ceci:

let result2 = toInt("Quarante-Deux").toEither("Oups!").cata((errMessage) => {
  console.log(errMessage); // affichera "Oups!"
  return 42;
}, (res) => {
  console.log("Succeed!", res);
  return res;
});

Comme avec Option ou Optional, je ne peux stocker l’état de l’erreur, mais toEither me permet de transformer mon Maybe en Either et de passer une message d’erreur via cata(left(err), right(value))

Validation

Monet.js propose aussi le principe de Result au travers du concept de Validation. J’écris donc une dernière fois ma fonction anonyme toInt:

import monet from 'monet';

let toInt = (strValue) => {
  try {
    let res = Number.parseInt(strValue);
    if(Number.isNaN(res)) throw new Error("Huston? There is no number here!");
    return monet.Validation.success(res);
  } catch(e) {
    return monet.Validation.fail(e.message);
  }
};

Donc cette fois-ci, je pourrais stocker l’état de mon erreur avec monet.Validation.fail(message) ou stocker un résultat avec monet.Validation.success(res), et l’utiliser de cette manière:

let result = toInt("Quarante-Deux").cata((errMessage) => {
  console.log(errMessage); // affichera "Huston? There is no number here!"
  return 42;
}, (res) => {
  console.log("Succeed!", res);
  return res;
});

En fait Validation est un Either.

Dans cet article je n’ai pas fait le tout de toutes les possibilités de Optional, Result, Either, etc… mais j’espère que cela vous donnera envie de creuser le sujet.

Sur ce, je vous souhaite un très bon week-end :)

blog comments powered by Disqus

Related posts