Contrôle et gestion des erreurs dans R

Tags : Autour de R, Ressources
Date :

Quand on utilise R, on est souvent confronté à des messages d’erreur ou des warnings, et dans la plupart des cas, ils sont extrêmement utiles au débogage et à la résolution de points bloquants dans notre code. Quand on développe des fonctions pour soi ou pour les autres, il convient de maitriser les cas qui peuvent générer des erreurs, pour les traiter ou, à minima, retourner un message clair à l’utilisateur. Comment gérer ces erreurs ? Comment faire pour que mon code puisse malgré tout continuer à tourner ?

Les différents niveaux d’alerte

Les personnes habituées à créer fonctions et packages sont – ou devraient être – des experts en la matière. Pour qu’une fonction soit robuste, il faut que le développeur ait pensé à tous les cas de figures. En effet l’adage “Never Trust User Input” s’applique à toutes fonctions, même la plus simple. Que doit-il se passer si l’utilisateur appelle de mauvais arguments à ma fonction ? Si je fais une division par 0 ? Si je calcule le log d’une valeur négative ? Ma fonction doit savoir gérer tout ça. On dénombre 3 niveaux d’alerte :

  • Les messages : c’est l’action la moins sévère. Ils permettent de donner une information à l’utilisateur quant à son appel à la fonction, via la fonction message. Ils peuvent être par exemple utiles lorsqu’un paramètre n’a pas été précisé lors de l’appel et que l’on souhaite rappeler à l’utilisateur sa valeur par défaut. La fonction renvoie bien un résultat mais il se peut que ce ne soit pas celui attendu, dans une moindre mesure. Par exemple :
my_hello_function <- function(language) {
  if ( language == "french" ) {
    res <- "Bonjour"
  } else if ( language == "english" ) {
    res <- "Hello"
  } else if ( language == "spanish" ) {
    res <- "Hola"
  } else {
    res <- "Hello"
    message("Unknown language: english choosen")
  }
  res
}
my_hello_function("french")
my_hello_function("german")

Le package {rlang} du {tidyverse} dispose d’une fonction équivalente à message() : inform().

  • Les warnings : c’est le juste milieu entre le message et l’erreur. Ils indiquent via la fonction warning() que quelque chose s’est mal passé malgré le fait que la fonction ait bien renvoyé un résultat. Par exemple :
my_sum <- function(x, y) {
  if ( any(is.na(c(x, y))) ) {
    warning("x or y is NA\n")
  }
  sum(x, y)
}
my_sum(1, NA)

Pour la version tidy, vous utiliserez la fonction warn() du package {rlang}.

Le soucis avec les warnings c’est qu’ils vont “s’accumuler” et être présentés à l’utilisateur à la fin de l’exécution de la fonction ou du script, ce qui est peu pratique à l’usage.

  • Les erreurs : il s’agit de l’action la plus radicale. La fonction stop(), comme son nom l’indique, va forcer l’arrêt de l’exécution de la fonction. Par exemple :
my_log <- function(x) {
  if (x < 0) { stop("x must be > 0") }
  log(x)
}
my_log(-1)

La fonction stopifnot() permet également de stopper l’exécution mais le message n’est pas personnalisable :

my_log2 <- function(x) {
  stopifnot(x >= 0)
  log(x)
}
my_log2(-1)

Contrôler les conditions nécessaires à la bonne exécution de ses fonctions est un must-do, il faudra par exemple :

  • tester les valeurs d’entrée
  • tester si les objets sont vides
  • mettre des valeurs par défaut

Pour la version tidy, vous utiliserez la fonction abort() du package {rlang}.

Note : Pour rédiger vos messages d’erreur dans les règles de l’art, je vous invite à consulter cette page sur les bonnes pratiques du tidyverse à appliquer.

Ignorer les alertes

Ces alertes sont certes très utiles, mais dans certains cas, on souhaiterait quand même s’en débarrasser, notamment si on boucle sur plusieurs éléments : pas envie de voir le même message ou warning des centaines de fois dans ma console. Attention, il ne faut pas faire n’importe quoi, ignorer des erreurs peut avoir de lourdes conséquences si vous n’avez pas une idée claire des exceptions que vous voulez mettre en place.

  • Pour ne plus afficher les messages, on utilisera la fonction suppressMessages(). Ceci n’aura pas d’impact sur votre code, et seule une perte d’information sera à déplorer.
suppressMessages( my_hello_function("german") )

  • La fonction suppressWarnings() permet quant à elle de ne pas afficher les warnings. Dans ce cas, il faudra faire un peu plus attention, car on risque de passer à côté d’une information importante.
suppressWarnings( my_sum(1, NA) )

  • Ignorer une erreur grâce à la fonction try, ce qui est à priori une mauvaise idée, va permettre à notre fonction de continuer à être exécutée. Reprenons notre exemple de fonction my_log :
my_log2 <- function(x) {
  try(my_log(x))
  log(x)
}
my_log2(-1)

Le message d’erreur produit par la fonction my_log() est bien affiché, mais on continue néanmoins à exécuter la fonction my_log2() avec l’appel à log(), l’affichage du warning, et le résultat produit NaN. Le paramètre silent permet de ne pas afficher le message d’erreur :

my_log3 <- function(x) {
  try( my_log(x), silent = TRUE )
  log(x)
}
my_log3(-1)

Autre cas de figure : je souhaite appliquer ma fonction à une liste d’éléments :

lapply(list(1, 2, 3, -1, "a", 10), function(x) {try(my_log(x))})
## Error in my_log(x) : x must be > 0
## Error in log(x) : argument non numérique pour une fonction mathématique
## [[1]]
## [1] 0
## 
## [[2]]
## [1] 0.6931472
## 
## [[3]]
## [1] 1.098612
## 
## [[4]]
## [1] "Error in my_log(x) : x must be > 0\n"
## attr(,"class")
## [1] "try-error"
## attr(,"condition")
## <simpleError in my_log(x): x must be > 0>
## 
## [[5]]
## [1] "Error in log(x) : argument non numérique pour une fonction mathématique\n"
## attr(,"class")
## [1] "try-error"
## attr(,"condition")
## <simpleError in log(x): argument non numérique pour une fonction mathématique>
## 
## [[6]]
## [1] 2.302585

Les messages d’erreur produits par les fonctions my_log() et log() sont bien affichés, mais on continue néanmoins à appliquer la fonction sur les éléments suivants de la liste.

On remarque par ailleurs que la fonction try() renvoie toujours quelque chose :

  • Si l’expression a pu être exéctuée, try() renvoie la dernière valeur de l’expression qu’on lui a donnée
  • Dans le cas d’une erreur, try() renvoie un objet de type try-error.

Gestion et capture des erreurs avec tryCatch()

Les fonctions précédentes nous permettent de purement et simplement ignorer les alertes. Mais que fait-on quand on a besoin d’aller plus loin et de spécifier les actions à mener en cas d’erreur par exemple ? Ou choisir une autre valeur de retour en cas de warning ? Réponse : on utilise la fonction tryCatch() qui nous ouvre le champ des possibles. Il s’agit d’une fonction plus générale qui gère les 3 types d’alerte (messages, warnings et erreurs), ainsi que les interruptions (quand l’utilisateur force l’interruption de l’exécution en tapant Esc.). Elle est principalement utilisée pour gérer les erreurs.

Mais concrètement, comment ça marche ?

tryCatch(expr,
         error = function(c) {"ce que je dois faire en cas d'erreur"},
         warning = function(c) {"ce que je dois faire en cas de warning"},
         message = function(c) {"ce que je dois faire en cas de message"},
         finally = expr2 # ce que je veux exécuter quoi qu'il arrive
)

Prenons l’exemple suivant :

my_fun <- function(x) {
  if (is.numeric(x)) {
    message("OK, x est numerique")
  } else if (is.character(x)) {
    warning("Attention, x est une chaine de caracteres\n")
    return(NA)
  } else {
    stop("x n'est ni numérique, ni un char")
  }
  paste0("la valeur de x est : ", x)
}
my_fun(1)
my_fun("a")
my_fun(TRUE)

Supposons maintenant que je ne peux pas modifier cette fonction, et que je ne peux pas m’en passer en en créant une autre, mais que ces messages ne me conviennent pas, et que j’ai besoin de renvoyer une valeur quand je lui donne un booléen. La fonction tryCatch() va me permettre d’utiliser la fonction my_fun() tout en contrôlant les différents cas de figure et la valeur retournée :

lapply(list(1, "a", TRUE), function(val) {
  tryCatch(my_fun(val), 
           message = function(c) {
             message("x est numerique et j'ai pu modifier mon message")
             suppressMessages(my_fun(val))
           },
           warning = function(c) {
             message("x est une chaine de caracteres et j'ai pu modifier mon message")
             paste0("ma nouvelle valeur : ", val)
           },
           error = function(c) {
             print(c)
             message("Avant j'avais une erreur mais je peux maintenant retourner ce que je veux")
             paste0("Valeur forcée : ", val)
           },
           finally = print("tout s'est bien passé"))
})

tryCatch() nous a permis de faire exactement ce que l’on souhaitait. On n’es évidemment pas obligés de rentrer tous les cas de figure pour que cela fonctionne : je peux tout à fait modifier le comportement de ma fonction seulement en cas d’erreur :

tryCatch(my_fun(TRUE), 
         error = function(c) {
           message("Avant j'avais une erreur mais je peux maintenant retourner ce que je veux")
           paste0("Valeur forcée : ", val)
         })

Note : Vous aurez remarqué que les fonctions données en paramètres de la fonction tryCatch() nécessitent un paramètre c : il s’agit de la condition, si elle existe, qui est exécutée via l’expression expr. C’est un objet condition qui se présente sous la forme d’une liste. Par exemple :

my_cond <- rlang::catch_cnd(stop("erreur !"))
str(my_cond)
## List of 2
##  $ message: chr "erreur !"
##  $ call   : language force(expr)
##  - attr(*, "class")= chr [1:3] "simpleError" "error" "condition"

withCallingHandlers(), une alternative plus adaptée aux warnings et messages

Contrairement à la fonction tryCatch(), la fonction withCallingHandlers() ne s’arrête pas au premier cas rencontrée. Pour bien comprendre la différence entre ces deux fonctions, rien ne vaut un exemple. Considérons la fonction suivante :

my_msg <- function(nb_msg) {
  sapply(seq(1, nb_msg), FUN = function(nb_msg) {
    message(paste0("Ceci est mon message ", nb_msg))
  })
  warning(paste0("Vous avez ", nb_msg, " message(s)"))
  nb_msg
}
my_msg(2)

Cette fonction affiche nb_msg, renvoie le nombre de messages affichés, et un warning nous indique combien de messages ont été affichés. Appliquons maintenant la fonction tryCatch() en essayant de remplacer messages et warnings :

tryCatch(
  my_msg(2), 
  message = function(c) {
    message("Nouveau message")
  },
  warning = function(c) {
    message("Ce message remplace le warning")
  }
)

Le warning a été supprimé et la valeur n’a pas été retournée : l’exécution s’est arrêtée au niveau du premier appel à la fonction message() de la fonction my_msg().

Si j’appelle maintenant la fonction withCallingHandlers() :

withCallingHandlers(
  my_msg(2), 
  message = function(c) {
    message("Nouveau message")
  },
  warning = function(c) {
    warning("Ceci est mon nouveau warning")
  }
)

Ici, toutes les lignes de la fonction my_msg() ont bien été exécutées, et la valeur attendue est bien retournée. Les fonctions définies par les paramètres de la fonction withCallingHandlers() sont appliquées avant chaque appel aux fonctions message() et warning() appelées au sein de la fonction my_msg() sans en modifier le comportement.

Mais alors, comment fait-on si on souhaite :

  • Gérer les messages comme ici
  • Modifier le message du warning
  • Retourner une valeur différente en cas de warning ?

Pour cela, il faut apprendre à jongler entre tryCatch() et withCallingHandlers() :

tryCatch(withCallingHandlers(
  my_msg(2), 
  message = function(c) {
    message("Nouveau message")
  }),
  warning = function(c) {
    warning("Ceci est mon nouveau warning, et j'ai pu modifier la valeur retournée", 
            call. = FALSE)
    paste0("Deux messages")
  }
)

Une gestion {purrr} des alertes

Le package {purrr} du tidyverse dispose également de ses propres fonctions permetant de gérer les alertes. Elles prennent en paramètre des fonctions, et au lieu de générer messages, warnings ou erreurs, renvoient un output :

  • safely(.f) : renvoie une liste contenant result (le résultat de la fonction .f, otherwise ou NULL par défaut si l’appel a généré une erreur) et error le message d’erreur. Reprenons la fonction my_fun() :
library(purrr)
my_fun_safe <- safely(my_fun, otherwise = "error value")
my_fun(TRUE)
my_fun_safe(TRUE)

  • possibly() : permet de renvoyer une valeur lors d’une erreur sans afficher ni retourner le message d’erreur :
my_fun_possible <- possibly(my_fun, otherwise = "error value")
my_fun_possible(TRUE)

  • quietly(.f) : les warnings et les messages ne sont pas affichés mais renvoyés dans le résultat : une liste contenant result le résultat de l’appel à la fonction .f si il n’y a pas eu d’erreur, messages, warnings et output.
my_fun_quiet <- quietly(my_fun)
my_fun(1)
my_fun_quiet(1)

Un package dédié à la gestion des alertes : {attempt}

Les fonctions du package {attempt}, inspiré par {purrr} et basé sur {rlang}, permettent d’aller plus loin dans la gestion des alertes.

# Installation du package depuis le CRAN : 
install.packages("attempt")
# Installation de la version dev : 
# install.packages("attempt", repo = "https://colinfay.me/ran")
library(attempt)

Création d’alertes

A l’instar des fonctions stop(), warning(), et message(), ou encore de abort(), warn() et inform() pour la version {rlang}, le package {attempt} dispose de d’un ensemble très complet fonctions pour générer des alertes, selon le contexte, et permettant d’alléger le code de tests if :

  • stop_if(), stop_if_all(), stop_if_any(), stop_if_none(), stop_if_not()
  • warning_if(), warning_if_all(), warning_if_any(), warning_if_none(), warning_if_not()
  • message_if(), message_if_all(), message_if_any(), message_if_none(), message_if_not()

Gestion des alertes

  • attempt() : fonctionne comme la fonction try() que nous avons vue plus haut, mais permet de modifier le message d’erreur :
my_log(-1)
attempt(my_log(-1), msg = "Non, x doit être positif")

  • try_catch(), de manière identique à tryCatch() (hors gestion des messages):
try_catch(expr,
          .e = function(c) {"ce que je dois faire en cas d'erreur"},
          .w = function(c) {"ce que je dois faire en cas de warning"},
          .f = expr2 # ce que je veux exécuter quoi qu'il arrive
)

Reprenons l’exemple précédent my_fun() :

lapply(list(1, "a", TRUE), function(val) {
  try_catch(my_fun(val), 
            .w = function(c) {
              message("x est une chaine de caracteres et j'ai pu modifier mon message")
              paste0("ma nouvelle valeur : ", val)
            },
            .e = function(c) {
              print(c)
              message("Avant j'avais une erreur mais je peux maintenant retourner ce que je veux")
              paste0("Valeur forcée : ", val)
            },
            .f = ~ print("tout s'est bien passé"))
})

  • try_catch_df() : renvoie un tibble avec l’appel, le message d’erreur, le message du warning le cas échéant et la valeur de l’expression évaluée ou “error”. Les valeurs seront toujours contenues dans une colonne de liste. Par exemple:
try_catch_df(my_log(-1))
## # A tibble: 1 x 4
##   call       error         warning value    
##   <chr>      <chr>         <lgl>   <list>   
## 1 my_log(-1) x must be > 0 NA      <chr [1]>
  • map_try_catch() et map_try_catch_df() permettent de mapper try_catch() et try_catch_df() sur une liste d’arguments.

Pour en savoir plus sur ce package et ses fonctions : https://github.com/ColinFay/attempt

En conclusion

Vous l’aurez compris, ces fonctions ne sont pas à utiliser à la légère car elle peuvent avoir une incidence importante dans votre code. Il faudra donc user de prudence !

Pour aller plus loin, créer vos propres alertes, et trouver d’autres exemples, je vous recommande d’aller lire le chapitre Conditions du livre Advanced R écrit par Hadley Wickham.


À propos de l'auteur


Commentaires


À lire également

Nos formations Certifiantes à R sont finançables à 100% via le CPF

Nos formations disponibles via moncompteformation.gouv.fr permettent de délivrer des Certificats de Qualifications Professionnelles (CQP) reconnus par l’état. 3 niveaux de certifications existent :

Contactez-nous pour en savoir plus.

Calendrier des formations