Skip to content

2. Le type Result

1 Que renvoie une fonction qui échoue ?

Tu veux écrire dernier, qui rend le dernier élément d'une liste. Tout va bien, jusqu'au cas où tu reçois Vide : il n'y a rien à renvoyer, et il faut pourtant renvoyer quelque chose.

pub fn dernier(lst: Liste(a)) -> ???

Trois réponses classiques, et ce qu'elles coûtent.

Réponse Ce qui cloche
une valeur spéciale, -1 ou "" elle est ambiguë : -1 peut être un vrai dernier élément
None en Python, null en Java invisible dans le type : le programme casse à l'exécution, parfois chez l'utilisateur
une exception invisible dans la signature, et l'appelant n'est jamais obligé de la traiter

L'erreur à un milliard de dollars

Tony Hoare a inventé la référence nulle en 1965, pour le langage ALGOL. En 2009, dans une conférence intitulée Null References: The Billion Dollar Mistake, il présente ses excuses :

« I call it my billion-dollar mistake. It has caused a billion dollars of pain and damage in the last forty years. »

Le problème n'est pas null en lui-même, c'est qu'il est invisible : n'importe quelle variable peut en être un, et rien ne le signale avant que le programme ne casse.

La réponse des langages récents, Gleam comme Rust, Swift ou Kotlin, est toujours la même : écrire l'échec dans le type. Si une fonction peut échouer, sa signature le dit, et le compilateur refuse le code qui l'ignore.

En Gleam, ce type s'appelle Result.

2 Le type

pub type Result(a, e) {
  Ok(a)
  Error(e)
}

Un Result est soit Ok qui contient un résultat, soit Error qui contient de quoi dire ce qui a échoué. C'est la même forme que ta Liste : deux constructeurs, et une valeur est l'un ou l'autre.

Deux paramètres de type, et non un seul :

  • a est le type du résultat, quand ça marche
  • e est le type de l'échec, quand ça ne marche pas.

Rien à importer

Result, Ok et Error sont dans le prélude de Gleam, comme True et False : ils sont là sans aucun import.

3 L'écrire

Un cas par constructeur de la liste, comme d'habitude. Ce qui change est seulement ce qu'on met dans chaque branche.

pub fn premier(lst: Liste(a)) -> Result(a, Nil) {
  case lst {
    Vide -> Error(Nil)
    Cons(tete, _) -> Ok(tete)
  }
}

pub fn dernier(lst: Liste(a)) -> Result(a, Nil) {
  case lst {
    Vide -> Error(Nil)
    Cons(tete, Vide) -> Ok(tete)
    Cons(_, queue) -> dernier(queue)
  }
}

Nil, quand il n'y a rien à dire de l'échec

Nil est la valeur qui ne porte aucune information. Result(a, Nil) se lit : « soit un a, soit un échec sur lequel il n'y a rien à ajouter ». Une liste vide n'a pas de dernier élément, et il n'y a pas d'autre explication à donner.

Le Result se propage naturellement dans une fonction récursive : le cas de base le fabrique, et le cas récursif n'a souvent qu'à le transmettre.

pub fn minimum(lst: Liste(Int)) -> Result(Int, Nil) {
  case lst {
    Vide -> Error(Nil)
    Cons(tete, queue) ->
      case minimum(queue) {
        Error(Nil) -> Ok(tete)
        Ok(m) ->
          case tete < m {
            True -> Ok(tete)
            False -> Ok(m)
          }
      }
  }
}

Le lutin du minimum : « Si la queue n'a pas de minimum, c'est que je suis seul, et le minimum c'est moi. Sinon, je compare ma tête au minimum de la queue et je garde le plus petit. »

4 Le lire

Un Result ne s'utilise pas directement : il faut l'ouvrir, avec un case à deux cas.

pub fn message(lst: Liste(Int)) -> String {
  case minimum(lst) {
    Ok(m) -> "le minimum est " <> int.to_string(m)
    Error(Nil) -> "la liste est vide"
  }
}
message(Cons(3, Cons(1, Vide)))   // "le minimum est 1"
message(Vide)                     // "la liste est vide"

(int.to_string demande import gleam/int en tête du fichier.)

C'est le compilateur qui tient la promesse

Oublie le cas Error, et le programme ne compile pas :

error: Inexhaustive patterns

This case expression does not have a pattern for all possible values. If it
is run on one of the values without a pattern then it will crash.

The missing patterns are:

    Error(_)

C'est toute la différence avec None en Python : là-bas, rien ne t'oblige à y penser, et l'erreur se découvre quand le programme tourne. Ici, elle se découvre avant.

Un Result qu'on laisse tomber est signalé aussi

Si tu appelles une fonction qui rend un Result sans rien faire de sa valeur, Gleam t'avertit : The Result value created here is unused. Ce n'est qu'un avertissement, le programme tourne, mais il dit exactement ce qu'il faut entendre : tu as fabriqué une réponse qui pouvait être un échec, et tu ne l'as pas regardée.

5 Dire pourquoi ça a échoué

C'est là que Result va plus loin qu'un simple « il n'y a rien ». Le e de Result(a, e) est un type comme un autre : tu peux y mettre le tien, avec un constructeur par sorte d'échec.

Un exemple : lire une note saisie au clavier. Deux choses peuvent clocher, et ce ne sont pas les mêmes.

pub type ErreurNote {
  PasUnNombre
  HorsIntervalle(Int)
}

pub fn lire_note(texte: String) -> Result(Int, ErreurNote) {
  case int.parse(texte) {
    Error(Nil) -> Error(PasUnNombre)
    Ok(n) ->
      case n >= 0 && n <= 20 {
        True -> Ok(n)
        False -> Error(HorsIntervalle(n))
      }
  }
}
lire_note("14")     // Ok(14)
lire_note("zéro")   // Error(PasUnNombre)
lire_note("25")     // Error(HorsIntervalle(25))

Deux choses à remarquer. HorsIntervalle transporte la valeur fautive, ce qui permet d'écrire un vrai message à l'utilisateur. Et int.parse rend lui-même un Result(Int, Nil) : on ouvre son échec sans détail pour le remplacer par le nôtre, qui en dit plus.

6 Trois fonctions de gleam/result

Ouvrir un Result par un case à chaque fois devient vite lourd. Le module gleam/result fait les trois gestes les plus fréquents.

import gleam/result

result.unwrap, pour se contenter d'une valeur par défaut.

pub fn minimum_ou_zero(lst: Liste(Int)) -> Int {
  result.unwrap(minimum(lst), or: 0)
}

result.map, pour transformer le résultat sans ouvrir le Result. Si c'est un Error, il passe sans être touché.

pub fn double_du_premier(lst: Liste(Int)) -> Result(Int, Nil) {
  result.map(premier(lst), fn(x) { x * 2 })
}

result.try, pour enchaîner deux fonctions qui peuvent chacune échouer. Sans elle, on obtient un Result dans un Result.

// le premier élément du dernier élément, pour une liste de listes
pub fn premier_du_dernier(listes: Liste(Liste(a))) -> Result(a, Nil) {
  dernier(listes) |> result.try(premier)
}

Le premier échec rencontré est celui qui ressort, et la suite n'est pas calculée. (L'opérateur |> est l'objet de la page suivante.)

7 Et Option dans tout ça ?

Gleam a aussi un type Option, avec Some(valeur) et None, qui dit l'absence sans dire pourquoi. Tu le croiseras dans d'autres langages sous le nom de Maybe ou Optional.

En Gleam, l'usage courant est Result, et la bibliothèque standard le montre d'elle-même. Toutes ces fonctions, qui pourraient « ne rien rendre », rendent un Result :

list.first(list)     // Result(a, Nil)
list.last(list)      // Result(a, Nil)
list.reduce(list, f) // Result(a, Nil)
dict.get(dict, cle)  // Result(v, Nil)
int.parse(texte)     // Result(Int, Nil)

La règle à retenir : écris Result par défaut, avec Nil quand l'échec n'a rien à raconter, et un type à toi dès qu'il a quelque chose à dire.

8 Exercices

avant_dernier

Écris avant_dernier(lst: Liste(a)) -> Result(a, Nil). Une liste de moins de deux éléments n'en a pas.

Indice

Il y a deux façons d'échouer, donc deux cas de base : la liste vide, et la liste à un seul élément. Le motif Cons(_, Vide) attrape la seconde.

Corrigé
pub fn avant_dernier(lst: Liste(a)) -> Result(a, Nil) {
  case lst {
    Vide -> Error(Nil)
    Cons(_, Vide) -> Error(Nil)
    Cons(tete, Cons(_, Vide)) -> Ok(tete)
    Cons(_, queue) -> avant_dernier(queue)
  }
}

Les cas sont essayés dans l'ordre, donc Cons(_, Vide) ne vole pas le cas suivant : il ne reconnaît qu'une liste à un seul élément.

maximum

Écris maximum(lst: Liste(Int)) -> Result(Int, Nil).

Corrigé
pub fn maximum(lst: Liste(Int)) -> Result(Int, Nil) {
  case lst {
    Vide -> Error(Nil)
    Cons(tete, queue) ->
      case maximum(queue) {
        Error(Nil) -> Ok(tete)
        Ok(m) ->
          case tete > m {
            True -> Ok(tete)
            False -> Ok(m)
          }
      }
  }
}

C'est minimum avec > à la place de <.

trouver

Écris trouver(lst: Liste(a), predicat: fn(a) -> Bool) -> Result(a, Nil), qui rend le premier élément qui satisfait la condition.

trouver(Cons(2, Cons(3, Cons(4, Vide))), fn(x) { x > 2 })   // Ok(3)
trouver(Cons(2, Cons(3, Cons(4, Vide))), fn(x) { x > 9 })   // Error(Nil)
Corrigé
pub fn trouver(lst: Liste(a), predicat: fn(a) -> Bool) -> Result(a, Nil) {
  case lst {
    Vide -> Error(Nil)
    Cons(tete, queue) ->
      case predicat(tete) {
        True -> Ok(tete)
        False -> trouver(queue, predicat)
      }
  }
}

lire_moyenne

Écris lire_moyenne(lst: Liste(Int)) -> Result(Int, Nil), qui rend la moyenne entière des éléments. Tu auras besoin de somme et de taille, et la division entière s'écrit /.

Indice

Le seul cas d'échec est la liste vide, et c'est aussi celui où la division serait impossible. Traite-le avant de diviser.

Corrigé
pub fn lire_moyenne(lst: Liste(Int)) -> Result(Int, Nil) {
  case lst {
    Vide -> Error(Nil)
    Cons(_, _) -> Ok(somme(lst) / taille(lst))
  }
}

Gleam ne casse pas sur une division par zéro, il rend 0. Raison de plus pour écarter la liste vide soi-même : 0 serait une moyenne fausse qui a l'air juste.

9 Ce qu'il faut retenir

  • Une fonction qui peut échouer le dit dans son type, avec Result(a, e).
  • Ok(valeur) quand ça marche, Error(raison) sinon.
  • Le case à deux cas est obligatoire, le compilateur le vérifie.
  • Nil comme raison quand il n'y a rien à dire, un type à toi quand il y a quelque chose à dire.
  • result.unwrap, result.map et result.try évitent d'ouvrir le Result à la main.