La gestion des erreurs

Pour le moment, lorsque nos programmes rencontraient une erreur, ils se terminaient immédiatement. Dans un vrai programme, c’est quand même plutôt gênant. Dans ce chapitre, nous allons donc voir comment gérer les erreurs.

Les exceptions

Qu’est-ce qu’une exception

Pour commencer ce chapitre, il nous faut bien sûr commencer par voir ce qu’est une exception. En fait, il s’agit tout simplement d’un évènement qui interrompt le flux d’exécution normal du programme. Il s’agit très souvent d’une erreur qui survient dans le programme. En effet, quand une erreur a lieu, une exception est levée par le programme (et son flux d’exécution est interrompu). Un exemple nous permettra d’y voir plus clair.

a = gets
b = a + 2
print 'Addition faite.'

En exécutant ce programme, nous obtenons l’erreur « no implicit conversion of Integer into String (TypeError) » (nous avons oublié de convertir a en nombre). Ben c’est qu’une exception a été levée. Le flux normal d’exécution du programme a bien été interrompu (il était censé afficher « Addition faite » et il ne l’a pas fait, il s’est terminé directement).

Le but de ce chapitre est de nous apprendre à prendre en compte ces erreurs (pour par exemple afficher un vrai message d’erreur, réessayer, etc.) et donc à gérer les erreurs proprement.

Trace de l’erreur

Une exception vient avec une « trace ». Cette trace correspond au chemin du programme qui a mené à l’erreur. C’est un peu obscur dit ainsi, un exemple aidera à mieux comprendre.

def f(x)
  x + 2 
end

print f('a')

Ici, une exception est encore lancée et notre interpréteur nous affiche un résultat de ce type.

Traceback (most recent call last):
2: from file.rb:5:in `<main>'
1: from file.rb:2:in `f'
file.rb:2:in `+': no implicit conversion of Integer into String (TypeError)

C’est la trace de notre erreur, et elle se lit ainsi (de bas en haut) :

  • À la ligne 2 du fichier, dans la méthode +, il y a eu un problème de conversion de type.
  • La méthode + avait été appelée par la méthode f à la ligne 2.
  • La mééthode f avait été appelée dans <main> (le programme principal) à la ligne 5.

Cette trace permet de savoir quand et où une erreur a lieu dans un script Ruby.

Attraper une exception

Comme nous l’avons dit, quand une exception survint, elle interrompt le flux d’exécution de notre programme et l’interpréteur Ruby nous dit ensuite qu’une exception a interrompu notre programme. Cependant, il est possible d'attraper une exception. Cela permet d’empêcher sa propagation, de traiter l’erreur et d’éventuellement continuer le programme.

Pour attraper une exception, nous utilisons la syntaxe la syntaxe begin-rescue-end. Entre le begin et le rescue, nous mettons le code que nous voulons exécuter et qui va potentiellement lancer une exception. Puis, entre le rescue et le end, nous mettons le code à exécuter en cas d’exception. Voyons un exemple que nous allons décortiquer.

begin
  print "Entrez un nombre : "
  number = gets
  result = 1 / number
rescue
  puts 'Exception attrapée dans le programme.'
end
puts 'Fin du programme.'

Ici, nous demandons à l’utilisateur de rentrer un nombre. Nous récupérons sa saisie dans number et nous lui rajoutons 1. Si ce bout de code échoue, une exception sera levée, et elle est attrapée par le bout de code dans le rescue, c’est-à-dire l’affichage d’un message d’erreur.

Lorsque nous essayons ce programme, il y a bien sûr une exception qui est levée (nous ne convertissons pas la saisie de l’utilisateur en entier, donc nous essayons d’additionner un entier et une chaîne de caractères). Le message d’erreur est donc affiché.

Pourquoi le message « Fin du programme » est-il également affiché ?

Si une exception est attrapée, elle ne se propage pas et le programme continue son cours normalement (à moins que dans le bloc rescue on ferme le programme ou quelque chose de ce genre). Il est donc normal que le message indiquant la fin du programme soit affiché.

Rattraper les exceptions dans une méthode ou un bloc de code

Lorsque nous écrivons une méthode ou un bloc de code, lee début de ce bloc ou de la méthode nous donne un begin-end implicite. Ainsi, on peut y utiliser le mot-clé rescue directement. Il sera alors associé à toute la méthode ou à tout le bloc.

def divide(x, y)
  return x / y
  rescue
    return 0
end

Nous essayons de retourner x / y, si une exception est lancée, on la rattrape et on retourne 0.

Si l’on veut avoir une gestion plus fine, nous devons cette fois retourner au begin-rescue-end. En effet, avec ce que nous venons de voir, tout ce qui est du rescue au end de la méthode n’est exécuté qu’en cas d’exception, et toutes les exceptions lancées entre le def et le rescue sont rattrapées.

def divide(x, y)
  result = x / y
  rescue
    result = 0
  puts "#{x} / #{y} = #{result}"
end

Ici, si aucune exception n’est lancée, l’affichage n’aura pas lieu. Pour obtenir le comportement souhaité, nous modifions le code de la manière suivante.

def divide(x, y)
  begin
    result = x / y
  rescue
    result = 0
  end
  puts "#{x} / #{y} = #{result}"
end

Nous essayons de faire des méthodes courtes et qui n’ont qu’une seule fonction. Par exemple, notre méthode divide se contentrait de renvoyer le résultat de la division (ce serait donc notre première version). Ainsi, il arrive souvent qu’on puisse utiliser le rescue directement dans nos méthodes.

Mais quelle est donc cette exception ?

Bon, on sait qu’on a une exception, mais on n’est pas très avancé. Nous aimerions bien avoir des informations sur l’exception qu’on vient d’attraper. Pour cela, nous utilisons l’opérateur >= avec rescue. Il nous permet de capturer l’exception qui a été attrapé.

begin
  print "Entrez un nombre : "
  number = gets
  result = 1 / number
rescue => e
  puts "Exception #{e.class} attrapée : #{e.message}."
end
puts 'Fin du programme.'

Ici, en exécutant le programme, nous obtenons le résultat suivant.

Exception TypeError attrapée : String can't be coerced into Fixnum.
Fin du programme.

Ici nous apprenons que (comme tout le reste) les exceptions sont des objets (nous aurions pu nous y attendre). L’opérateur >= nous permet de récupérer une exception (ici de la classe TypeError) et nous utilisons sa méthode message pour obtenir le message associé à l’exception.

Essayons le même programme, mais en rajoutant la conversion en entier.

begin
  print "Entrez un nombre : "
  number = gets.to_i
  result = 1 / number
rescue => e
  puts "Exception #{e.class} attrapée : #{e.message}."
end
puts 'Fin du programme.'

Cette fois, on peut causer plusieurs erreurs. Par exemple, en ne rentrant pas un entier, ou en entrant zéro (ce qui causera une division par 0). Cette fois, on obtient une exception différente.

Exception ZeroDivisionError attrapée : divided by 0.

On a donc plusieurs classes d’exceptions.

Notons de plus que les exceptions ont une méthode backtrace qui nous donne accès à la trace dont nous avons parlé plus haut.

def f(x)
  1 / x
end

begin
  f(0)
rescue => e
  puts e.backtrace
end

Gérer les exceptions

La classe des exceptions

Nous avons déjà rencontré deux exceptions différentes, une de la classe TypeError, et l’autre de la classe ZeroDivisionError. En Ruby, les noms des classes des exceptions sont plutôt explicites. En fait, toutes les exceptions descendent de la classe Exception. Voici un code tiré de cet article de Synbioz qui nous montre les différentes classes existantes.

def subclasses_tree(klass, level = 0)
  puts level.zero? ? klass.to_s : "#{' ' * (4 * (level - 1))}├── #{klass}"
  descendants_of(klass).select { |k| k.superclass == klass }.each do |k|
    subclasses_tree(k, level + 1)
  end
end

def descendants_of(klass)
  ObjectSpace.each_object(Class).select { |k| k < klass }
end

subclasses_tree(Exception)

On peut alors observer la longue liste d’exceptions de Ruby. Notons par exemple l’existence de ArgumentError (lancée par exemple lorsqu’une méthode est appelée avec le mauvais nombre d’arguments) ou encore de NameError (lancée par exemple lorsqu’un identifiant est utilisée et que l’interpréteur ne le connaît pas).

Notons que l’arbre d’héritage est plutôt bien fait. Par exemple, NoMethodError est une classe fille de NameError. En effet, NameError est lancée lorsqu’un identifiant inconnu est utilisé, et NoMethodError lorsqu’une méthode inconnue est utilisée (et cette méthode a bien un identifiant). Pour voir cela, testons ces deux codes.

put 'On affiche ceci' # => NoMethodError, put est inconnue.

Ici, on sait que put est censée être une méthode puisqu’on a un argument, l’exception NoMethodError est lancée.

put # => NameError, put est inconnue

Ici, put pourrait être une méthode, mais pourrait aussi juste être une variable normale, ce serait donc une erreur de lancer une exception de la classe NoMethodError.

Rattraper des exceptions particulières

Il y a plusieurs types d’exceptions, et nous avons la possibilité de rattraper une exception en particulier. Pour cela, il nous suffit de préciser à rescue la classe de l’exception à rattraper.

def divide(x, y)
  return x / y
  rescue ZeroDivisionError
    puts 'Division par 0 !'
    return 0
end

Bien entendu, nous pouvons toujours capturer l’exception rattrapée dans une variable.

def divide(x, y)
  return x / y
  rescue ZeroDivisionError => e
    puts "Division par 0 : #{e.message} !"
    return 0
end
Rattraper plusieurs types d’exceptions…

Dans les deux codes précédents, seules les exceptions de la classe ZeroDivisionError seront rattrapées. Néanmoins, nous voudrons peut-être rattraper plusieurs types d’exceptions. Par exemple, nous avons plus haut obtenu une exception TypeError lorsque nous essayions de diviser 1 par une chaîne de caractère. Cette exception peut arriver, rattrapons là.

def divide(x, y)
  return x / y
  rescue ZeroDivisionError, TypeError => e
    puts "Impossible de calculer #{x} / #{y} : #{e.message} !"
    return 0
end

divide(1, 0)
divide(1, 'x')

De manière générale, on rattrape les exceptions E1, E2, E3, et plus encore, en utilisant le code rescue E1, E2, E3 et en ajoutant => variable pour capturer l’exception si besoin est.

… Avec des traitements différents

Quand nous voulons rattraper plusieurs types d’exceptions, il peut arriver que l’on veuille avoir des traitements différents suivant de l’exception rattrapée. Pour cela, il suffit d’utiliser plusieurs fois le mot-cle rescue. On va ainsi créer un bloc de code à exécuter pour chaque rescue. Par exemple, affichons un message différent si la division échoue à cause d’une erreur de type ou d’une division par zéro.

def divide(x, y)
  return x / y
  rescue ZeroDivisionError => e
    puts 'Pas de division par zéro !'
    return 0
  rescue TypeError => e
    puts "Erreur de type : #{e.message}"
end

divide(1, 0)
divide(1, 'x')

Cette fois, nous obtenons des messages différents lors des deux appels à la méthode.

L’infini en Ruby

En fait, en Ruby, nous avons une constante INFINITY dans la classe Float de Ruby. Une division par zéro avec des flottants (1.0 / 0 par exemple) renvoie cette constante. On pourrait fait notre méthode la renvoyer en cas de division par zéro (ou même essayer de faire x / y.to_f).

def divide(x, y)
  return x / y
  rescue ZeroDivisionError => e
    return Float::INFINITY
  rescue TypeError => e
    puts "Erreur de type : #{e.message}"
    return 0
end
Bonne gestion des exceptions

Avec tout ce que nous avons vu, nous pouvons légitimement nous demander comment bien gérer les exceptions. Quand faut-il les rattraper ? Comment faut-il les rattraper ? Ce sont à ces questions et à d’autres encore que nous répondrons ici.

Une des premières choses à retenir (et des plus importantes) est de ne pas utiliser les exceptions pour le flux de contrôle. Comme son nom l’indique, une exception est censée être lancée lorsqu’une situation exceptionnelle, qu’on n’attendait pas arrive**. Par exemple, l’impossibilité d’ouvrir un fichier ou de se connecter à un réseau.

Si on prend l’exemple de la division par 0 que nous utilisons depuis le début, elle ne devrait pas être gérée avec une exception si c’est quelque chose qui peut arriver (par exemple si c’est l’utilisateur qui rentre les données), si on veut afficher un message d’erreur si cela arrive, c’est un if qu’il nous faut utiliser.

n == 0 ? puts 'Impossible de diviser par 0 !' else puts n / d

Bien sûr, si n est fourni et ne devrait pas pouvoir valoir 0 (par exemple, si on essaie de récupérer la date du jour et qu’on tombe sur 0), alors là, une exception n’est pas une mauvaise chose.

Les exceptions du système

Certaines exceptions de Ruby sont dédiés à son bon fonctionnement et il est déconseillé de les rattraper. Nous pouvons comprendre assez aisément pourquoi ce n’est pas une bonne idée de rattraper une exception SystemExit par exemple (elle est déclenchée lorsque l’utilisateur décide de fermer le programme). Un autre exemple est celui de LoadError qui est lancée quand le chargement d’un fichier (avec require) échoue.

begin
  require 'bad_file'
  main
rescue Exception => e
  puts 'Exception, on quitte le programme.'
end

Dans cet exemple (adapté du même article de Synbioz), on ne sait pas si le problème est causé par le require ou par le main ; le problème de chargement est masqué alors que c’est une erreur qu’on préférerait avoir.

C’est pourquoi nous n’attraperons que les exceptions qui descendent de StandardError.

Du moins général au plus général

De plus, s’il y a plusieurs exceptions à rattraper, nous les rattraperont du plus général au moins général. Ce conseil se comprend bien : si le rescue d’une exception est placée avant celui d’une autre exception plus spécifique, le code du second bloc ensure ne sera jamais exécuté, puisque les exceptions de ce type seront rattrapées par le bloc ensure plus général.

begin
  puts 1 / 0
rescue StandardError
  puts 'Une erreur standard'
rescue ZeroDivisionError
  puts 'Division par 0 !'
end

Ici, il nous faut donc placer le rescue du ZeroDivisioError avant celui du StandardError.

La clause ensure

Le mot-cle ensure nous permet de nous assurer qu’un traitement est effectué même si une exception a lieu. Par exemple, il permet de s’assurer qu’un fichier est bien fermé même après son ouverture. On place le code à eexécuter dans tous les cas entree le rescue et le end. On a ainsi la structure begin-rescue-ensure-end. Ici, nous allons l’utiliser pour afficher un message de fin dans tous les cas. Le rescue n’est pas obligatoire (nous pouvons parfaitement ne pas vouloir rattraper l’exception, mais quand même faire quelque chose si elle survient.

def main
  loop do
    str = gets.chomp
    break if str == 'quit'
    n = str.to_i
    puts "1 / #{n} = #{1 / n}"
  end
end

begin
  main
ensure
  puts 'Fin du programme !'
end

puts 'Aucune exception, bravo !'

Ici, si on rentre une saisie invalide (zéro ou encore une chaîne différente de exit), une exception ZeroDivisionError est lancée et n’est pas rattrapée, mais le code du ensure est quand même exécuté, contrairement au code qui suit en dehors du bloc.

Quand on dit que ce code est toujours exécuté, c’est vraiment toujours. On pourrait tout à fait rattraper l’exception et faire un return si elle a lieux que le message serait quand même affiché (mais la trace de l’erreur ne serait plus affichée).

begin
  main
rescue StandardError => e
  return -1
ensure
  puts 'Fin du programme !'
end

puts 'Aucune exception, bravo !'

De l'autre côté du miroir

Lever des exceptions

Ruby nous laisse la possibilité de lever nous même des exceptions. Pour cela, nous utilisons le mot-clé raise avec le nom de l’exception à lever.

def f(x)
  raise ArgumentError if x == 0
  1 / x 
end

f(10)
f(0) # ArgumentError

Nous pouvons donner à raise un second argument qui correspond au message de l’exception levée.

def f(x)
  raise ArgumentError, 'Impossible de diviser par 0 !' if x == 0
  1 / x 
end

Notons de plus que le premier argument peut être omis auquel cas c’est une exception de la classe RuntimeError qui sera lancée. On peut alors juste écrire raise 'Une erreur RuntimeError' si on veut lancer une exception RuntimeError.

begin
  raise 'Error'
rescue StandardError => e
  puts e.class
  puts e.message
end

Ici, l’exception lancée est bien une RuntimeError et son message est bien « Error ».

Créer nos propres exceptions

Nous pouvons lancer nous même des exceptions, et bien entendu, nous pouvons aussi créer nos propres exceptions. Il nous suffit de créer une classe classique qui hérite de la classe Exception (ou d’une de ses descendantes). Nous pouvons alors la lancer et la rattraper comme n’importe quel autre type d’exception.

class NewError < Exception
end

raise NewError

Mais pourquoi créer ses propres exceptions ?

Créer ses exceptions offre plusieurs avantages. Premièrement, cela permet de donner plus de sémantique à notre code. Par exemple, si nous créons une exception FileNotFoundError, si une méthode lance une exception FileNotFoundError, on a plus d’informations que si c’était, disons une exception RuntimeError.

De plus, nous obtenons une certaine granularité au niveau du rattrapage d’exception puisque nous pouvons rattraper l’exception en utilisant son nom, et en particulier, nous pouvons avoir un traitement particulier si cette nouvelle exception est attrapée.

Créons une méthode qui prend un paramètre x et lance une exception si x / 2 est supérieur à 2.

def new_method(x)
  raise NewError, "#{x} / 2 > 2" if x / 2 > 2
  x
end

Nous pouvons alors rattraper spécifiquement l’exception NewError quand on appelle cette méthode.

begin
  new_method(6) # => Lance exception NewError
rescue NewError => e
  puts "Exception : #{e.message}"
end

Et dans le même temps, si c’est un autre type d’exception qui est lancée, l’exception ne sera pas rattrapée.

begin
  new_method('6') # => Lance exception NoMethodError
rescue NewError => e
  puts "Exception : #{e.message}"
end
Bonne gestion des exceptions

Après avoir vu comment bien rattraper les exceptions, il nous faut voir les bonnes pratiques quant au lancement et à la création des exceptions.

Quand créer des exceptions

Notons que le nombre d’exceptions standard est plutôt grand et que leurs noms sont suffisamment bien choisis pour couvrir la plupart des erreurs que l’on pourrait vouloir gérer. Dans la plupart des cas, elles seront donc suffisantes et nous allons favoriser l’usage d’exceptions standard.

Néanmoins, il ne faut pas hésiter à créer ses propres exceptions quand aucune des exceptions standards ne semble assez expressive pour l’erreur que l’on veut indiquer.

De plus, la plupart des temps, nous créerons nos exceptions en tant que classe-fille de StandardError plutôt que juste en tant que classe-fille de Exception. Nous donnons ainsi plus de sens à l’exception créée (c’est une exception standard) et nous permettons à celle-ci d’être rattrapée lorsque nous rattrapons les exceptions standards (rappelons que c’est une mauvaise pratique de rattraper toutes les exceptions).

Exceptions et espace de noms

Une bonne idée quand on crée des exceptions est de les avoir dans un espace de noms qui indique bien à quoi l’exception se rapporte. Ainsi, on évite les conflits de nommage d’exceptions. De plus, on peut ainsi spécifier plus précisément à quoi se rapporte une exception.

Ainsi, on ne laisserait l’espace de nom global que pour les exceptions standards. On pourrait par exemple avoir une exception NameError dans un module de gestion d’utilisateurs et ce sans avoir de conflits avec l’exception standard NameError.

Relancer une exception rattrapée

Un comportement que nous voudrons parfois avoir est le suivant : quand une certaine exception est levée, nous l’attrapons et la traitons, mais ne souhaitons pas qu’elle soit « oubliée » (en clair, nous voulons savoir que l’exception a été levée).

La solution à ce problème est de relancer l’exception dans le bloc ensure. Pour ce faire, il nous suffit de relancer l’exception. Et Ruby nous offre la possibilité de le faire simplement, juste avec le mot-clé raise. On peut alors écrire ce programme.

def main
  loop do
    str = gets.chomp
    break if str == 'quit'
    n = str.to_i
    begin
      puts "1 / #{n} = #{1 / n}"
    rescue
      puts 'Votre saisie est invalide !'
      raise
    end
  end
end

begin
  main
rescue => e
  puts "Il y a une exception : e.message."
else
  puts 'Aucune exception, bravo !'
ensure
  puts 'Fin du programme !'
end

Exercices

Nous n’allons pas faire d’exercices spécifiques aux exceptions. Mais comme depuis quelques chapitres, nous pouvons nous lancer dans l’implémentation de petits projets. Bien sûr, à partir de maintenant, nous pouvons gérer les exceptions dans nos programmes (en fait, nous pouvons même reprendre certains de nos anciens programmes).


Ça y est, nous ne sommes plus les exceptions des Rubyistes, nous savons nous aussi gérer les erreurs. Pour plus de bonne pratiques liées aux exceptions, nous pourront lire ce guide de bonnes pratiques.

  • Les exceptions servent à gérer des erreurs imprévues (comme leur nom l’indique, ce sont des exceptions).
  • Nous rattrapons les exceptions avec le mot-clé rescue et il vaut mieux préciser la classe de l’exception que l’on veut rattraper.
  • Il est déconseillé de rattraper des exceptions qui ne descendent pas de StandardError.
  • Le mot-clé ensure nous permet de nous assurer qu’un bout de code sera exécuté même si une exception vient interrompre le flux d’exécution du programme.