19 oct. 2007

Exceptions exceptionnelles

Où l'auteur parle de ses déboires et ses succès avec la gestion des exceptions, sans oublier d'aborder des sujets plus controversés - les exceptions sont elles exceptionnelles, et leur utilisation dans un programme est-elle judicieuse ou dangereuse ?

"L'exception confirme la règle". J'entends encore l'instituteur lancer ce bon mot de sens commun sur un ton goguenard (ou sadique, allez savoir) pendant un cours de grammaire, histoire d'embrouiller ses chers élèves. Bien évidemment, notre métier étant ce qu'il est, on lui préfère la plus sage maxime "l'exception infirme la règle".

Parce que c'est bien connu, les exceptions sont "exceptionnelles" : elles ne doivent être utilisée que dans le cadre d'un traitement exceptionnel - plus de mémoire, un fichier nécessaire manquant, etc. Dans les autres cas, il parait qu'il faut leur préférer d'autres modes de remontée d'erreur, parce que traiter des exceptions, c'est lourd - au moins dans certains langages orientés objet. Bien. Je n'écrirais pas cet article si j'étais entièrement d'accord avec cette théorie. Pour ma part, je pense que les exceptions peuvent très bien être utilisée pour gérer des cas d'erreurs courants et que leur lourdeur est toute relative.

Les questions qu'on se pose

Il est étonnant de voir que l'utilisation idiomatique des exceptions est différente d'un langage à l'autre. En C++, on considère que les exceptions ne doivent être utilisées que pour gérer les cas exceptionnels (quid de l'utilisation qui en est faite dans la librairie standard ?). Dans d'autres langages, leur utilisation est plus libre - j'ai même vu des cas où les exceptions étaient utilisées pour stopper une récursion. D'où vient la réticence des programmeurs C++ ?

Premièrement, écrire du code robuste aux exceptions est complexe. La plupart du temps, le programmeur se contente d'écrire un code neutre aux exceptions (c'est à dire qu'il n'utilise ni le mot clef throw, ni les mots clefs try/catch. Il est alors à la merci d'une librairie quelconque qui elle peut générer des exceptions. L'exemple le plus important reste la librairie standard du C++ et en particulier les allocateurs globaux: new, dans sa forme la plus basique, peut générer une exception std::bad_alloc. D'autres exceptions peuvent être aussi générées (std::vector::at() peut générer une exception std::out_of_range, etc).

Si les techniques qui permettent d'écrire du code robuste aux exceptions sortent du cadre de cet article, rien ne m'interdit de vous en donner le principe de base: l'état du programme à la sortie d'un bloc catch est sensée être exactement l'entrée du bloc try correspondant. Le but de cette règle est de s'assurer que malgré l'exception générée, nous avons encore le contrôle de l'état du programme. C'est particulièrement important lorsqu'une ressource particulière a été allouée auparavant. On notera que l'idiome RAII déjà discuté sur ce blog[1] simplifie énormément l'écriture de code robuste aux exceptions.

Si vous souhaitez en apprendre davantage à propos des subtilités liées aux exceptions, n'hésitez pas à visiter le site web gotw.ca d'Herb Sutter, et notamment la section Guru of the Week qui contient plusieurs articles sur l'écriture de code robuste aux exceptions. Son livre Exceptional C++ contient aussi une grosse partie sur ce sujet.

Deuxièmement, un code source gérant les exceptions est difficile à lire : à moins que des commentaires redondants ne soient ajoutés dans le code source, on ne peut pas savoir si une expression génère une exception juste en l'examinant. C'est encore pire si on utilise les exceptions pour effectuer un contrôle du flux : dans ce cas, le flux n'est plus linéaire et on se retrouve avec un problème connu...

Troisièmement, les exceptions sont lourdes - dans certains langages - et pénalisent le programme. Or mon application a besoin d'être la plus rapide possible, il serait donc de mauvais goût de l'alourdir pour gérer des problèmes que je sais gérer autrement. Je dois le dire tout de suite: je ne suis pas entièrement d'accord avec ce point de vue.

Le modèle C++

Supposons par exemple que l'opérateur new en C++ ne génère pas d'exceptions mais qu'il se contente de renvoyer une pointeur NULL lorsqu'il n'y a plus de mémoire. D'un coup, chaque allocation effectuée dans mon programme doit maintenant tester la valeur retournée et renvoyer une erreur ou effectuer un autre traitement dans le cas ou le pointeur retourné est invalide. A chaque allocation j'ai maintenant 2 (voire plus) lignes de codes non triviales à écrire - ce qui signifie plus de possibilités d'ajouter des problèmes au logiciel, et l'exécution systématique d'un test après chaque allocation. En quoi est-ce plus léger que d'utiliser une version de new qui génère une exception ?

Toutefois, il reste vrai que le modèle C++ impose un certain nombre de contraintes qui font que même si vous ne générer pas une exception dans un bloc qui peut les intercepter, le code machine généré sera moins optimal que si vous utilisez une autre méthode - par exemple, un code de retour signalant une erreur. Soit. Mais alors considérons le cas (courant) d'une fonction utilitaire qui retourne un code d'erreur que seul la partie haut niveau de l'application (par exemple, l'IHM ou le modèle métier de l'application) peut interpréter. Dans ce cas, il faut remonter le code d'erreur - parfois sur plusieurs niveaux - pour pouvoir au final le traiter.

Soit une fonction qui effectue le rendu d'un noeud d'un scene graph[2] dans une application. Le scene graph étant un arbre, il faut le traverser pour pouvoir effectuer le rendu de chaque nœud (les nœuds peuvent être spécialisés à loisir).

error::code sg::node::render(rendering_context *context)
{
   for (int i=0; i<sons.size(); ++i)
   {
      error::code errcode = sonsi->render(context);
      if (errcode != error::code::ok)
      {
        return errcode;
      }
   }
   return attached->render(context);
}

Si une erreur survient lors du rendu d'un nœud situé loin de la racine, il me faut arrêter la récursion pour remonter l'information jusqu'à la fonction appelante. En terme pénalité de temps d'exécution du au dépilement, je ne vois pas en quoi on diffère ici de la génération d'une exception. Puisque l'erreur ne peut venir que de l'appel à attached->render(...), pourquoi ne pas simplifier le code et faire en sorte que cette méthode génère une exception ?

void sg::node::render(rendering_context *context)
{
   std::for_each(sons.begin(), sons.end(), std::mem_fun(&renderable::render));
   attached->render(context);
}

Bien évidemment, un nouveau problème se pose dans ce cas: le code n'est plus explicite, ce qui signifie que la lecture du code est moins aisée. En C++ on peut utiliser la spécification throw() - mais elle est malheureusement mal implémentée dans l'un des compilateur leader du marché, j'ai nommé Microsoft C/C++ Compiler (y compris dans ses dernières versions).

void sg::node::render(rendering_context *context)
  throw (std::exception, renderable::exception)
{
   std::for_each(sons.begin(), sons.end(), std::mem_fun(&renderable::render));
   attached->render(context);
}

Un début de réponse?

Les exceptions en C++ (et dans d'autres langages) sont des outils difficiles à utiliser - mais lorsqu'elles sont bien utilisés, leurs bénéfices sont multiples :

  • simplification du code écrit
  • gestion aisée des erreurs
  • possibilité de complexifier à loisir la gestion des erreurs (par exemple en cascadant les exceptions).

Mal utilisées, elles introduisent un certain nombre de problèmes :

  • lourdeur du code machine généré par le compilateur
  • rends la compréhension du programme plus complexe en modifiant le contrôle de flux.

Le problème principal n'est pas que les exceptions sont intrinsèquement mauvaises, mais plutôt qu'il est difficile de les utiliser correctement. Parmi les utilisations à proscrire : la gestion du contrôle de flux (notamment pour sortir d'une fonction récursive ou d'une boucle).

Est-ce à dire qu'il faut réserver la génération d'exceptions au traitement des erreurs ? A mon sens, c'est mieux ainsi. Mais existe-t-il une classe d'erreurs pour laquelle je ne dois pas utiliser d'exceptions ? Je ne le pense pas. Cela reviendrait à dire qu'il existe des erreurs qui sont plus exceptionnelles que d'autres, et je ne le crois pas. Est-ce que je dois traiter les erreurs d'allocation mémoire et les erreurs pendant la validation des données utilisateur de manière différente ? Je n'en vois pas l'utilité.

Si on associe à chaque erreur une gravité et une fréquence d'apparition, que signifie alors "erreur exceptionnelle" ? Cela couvre-t-il les erreurs de faible gravité mais qui ne sont pas censées arriver où les erreurs particulièrement graves qui sont quasiment systématiques ? Vouloir restreindre l'utilisation des exceptions à un type particulier d'erreur est, selon moi, un non sens.

Pour autant, cela ne signifie pas que chaque erreur doit être accompagné de la génération d'une exception. Au final, l'utilisation correcte des exceptions relève d'une alchimie complexe ou se mêle bon sens, connaissance du modèle de compilation (si besoin) du langage utilisé et expérience.

Mais je vous l'accorde: trouver une programme ou une librairie qui utilise correctement les exceptions est assez... exceptionnel[3].

Notes

[1] mais mal aimé... :)

[2] cf Wikipedia: article "Scene Graph"

[3] désolé. Pourtant, j'ai pris des cours d'humour quand j'étais plus jeune, mais forcément, moins on pratique, plus c'est difficile.

Ajouter un commentaire

Les commentaires peuvent être formatés en utilisant une syntaxe wiki simplifiée.

Fil des commentaires de ce billet