Table of Contents

Les exceptions en C++

Qu'est ce qu'est une exception ?

Si lors de l’exécution d’une fonction, un événement pouvant conduire à l’interruption de l’exécution se produit, cet événement est une exception.

Selon cette définition :

En effet, quand une exception se produit, il est possible de procéder à des opérations palliatives qui permettront au programme de continuer de fonctionner.

En fait, l'exception doit être vu comme un élément initiateur qui s'il n'est pas gérer conduira à l'échec de la fonction ou du programme. Cependant, une exception peut-être capturée (ie. détectée et traitée) et dans ce cas, la fonction ou le programme pourront éventuellement continuer à effectuer leur tâche.

Pourquoi introduire des le concept d'exceptions dans le langage ?

Avant l'apparition du concept des exceptions, la conception d'un programme “sûr” et “robuste” imposait que l'on vérifier avant d'exécuter une opération si les paramètres étaient corrects et après que l'opération se soit réalisée, si l'opération n'a pas connu d'erreur. Ainsi pour l'ouverture d'un fichier en lecture en C, nous effectuions les opérations suivantes :

  if(filename == NULL){
    perror ( "Empty file name" );
    exit ( EXIT_FAILURE );
  }
 
  FILE *fp = fopen (filename, mode );
 
  if ( fp == NULL ) {
    perror ( "Unable to open file" );
    exit ( EXIT_FAILURE );
  }

Nous voyons que nous mélangeons le code relatif aux opérations et le code relatif à la vérification des opérations. D'ailleurs au passage, il y a plus de code pour les vérifications que réaliser l'opération. En plus, est-ce que c'est bien exit qu'il fallait appeller si un échec se produit ? Cela dépend typiquement du contexte d'utilisation. Peut-être que cela est sans conséquence et que le programme peut continuer en essayant de lire un fichier de sauvegarde.

L'idée des exceptions, c'est de séparer le traitement des fautes de la survenance de la faute. Reprenons l'exemple précédent avec l'idée de faire une fonction générique de lecture d'un fichier texte :

readFile(file_name)
    si file_name est NULL
        => signale que le nom du fichier est NULL et arrête la fonction.
    appelle fopen pour ouvrir le fichier.
    si fopen n'a pas pu ouvrir le fichier
        => récupère le code d'erreur 
           signale l'erreur qui s'est produite.
    lit le contenu du fichier
    si une erreur se produit durant la lecture
        => arrête la lecture
           signale l'erreur qui s'est produite
    ferme le fichier
    si une erreur se produit durant la fermeture
           signale l'erreur qui s'est produite
    retourne le contenu du fichier.

Dans l'écriture de la fonction, nous nous concentrons sur deux aspects :

Quand une erreur se produit, nous indiquons seulement l'erreur qui s'est produite et nous arrêtons d'exécuter la fonction. Nous ne faisons pas d'autres traitements. C'est au code qui a appellé ce code de lecture qui devra s'en charger.

Faire ainsi permet de :

  1. simplifier l'écriture de code, en rendant le code beaucoup plus lisible
  2. séparer la partie exécution et traitement des erreurs.

Cependant, il faut mettre en place un mécanisme qui :

C'est ce mécanisme que l'on appelle support des exceptions.

La syntaxe des exceptions en C++

C++ ajoute le mot clé throw pour déclencher une exception. En fait à une exception est associée une valeur ou un objet quelconque.

  int divrem(int numerator, int denominator, int& remainder)
  {
      if(denominator == 0)
        throw 0;
      remainder = numerator % denominator;
      return numerator / denominator;
  }

Dans le code précédent, si le dénominateur est égal à 0, une exception sera levée et à cette exception sera associée un entier ayant la valeur 0.

Quand l'exception est levée, l'exécution du programme s'arrête tant que l'exécution n'est pas capturée. Si l'exécution n'est pas capturée, le programme plante, ie. il s'arrête et indique le type de l'objet associé à l'exécution.

Il est donc nécessaire d'introduire un mécanisme permettrant de capturer les exceptions :

  try
  {
      divrem(2, 0);
  }
  catch(...)
  {
      std::cout << "Error: Try to divide by zero" << std::endl;
  }

Le bloc try{}catch(…){} fonctionne comme suit :

Dans le cas précédent, la sortie du programme serait l'affichage du message :

Error: Try to divide by zero

Distinguer entre les exceptions

En fait, nous aimerons identifier la nature de l'erreur qui s'est produite. Pour ce faire, nous aimerions par exemple faire la différence entre une erreur d'entrée sortie si nous lisons à partir d'un flux et une erreur de calcul.

C++ proposer un mécanisme pour faire la différence entre les exceptions en permettant de ne capturer que les exceptions qui ont un certain type :

  try
  {
      divrem(2, 0);
  }
  catch(std::invalid_argument)
  {
      std::cout << "Error: Invalid argument when calling the function." << std::endl;
  }
  catch(...)
  {
      std::cout << "Error: Unknwon error when calling the function." << std::endl;
  }

exécutera le code :

  catch(std::invalid_argument)
  {
      std::cout << "Error: Invalid argument when calling the function." << std::endl;
  }

si une exception de type std::invalid_argument s'est produite. Si une autre exception s'est produite, il se rabattra sur la règle par défaut,

  catch(...)
  {
      std::cout << "Error: Unknwon error when calling the function." << std::endl;
  }

Nous voyons donc l'intérêt de définir des objets ayant un type indiquant la nature de exception et non pas d'associer à une expression une valeur entière ou autre, afin de pouvoir identifier la nature de l'exception et faciliter ainsi la mise en place de code pour traiter l'exception.

Récupérer des informations complémentaires

Nous modifions désormais la fonction divrem pour que celle-ci génère une exception std::invalid_argument si jamais denominator vaut zero :

#include<stdexcept>
 
int divrem(int numerator, int denominator, int& remainder)
{
    if(denominator == 0)
      throw std::invalid_argument();
    remainder = numerator % denominator;
    return numerator / denominator;
}

Lorsque nous exécutons le code suivant :

  try
  {
      divrem(2, 0);
  }
  catch(std::invalid_argument)
  {
      std::cout << "Error: Invalid argument when calling the function." << std::endl;
  }
  catch(...)
  {
      std::cout << "Error: Unknwon error when calling the function." << std::endl;
  }

le message suivant sera affiché :

Error: Invalid argument when calling the function.

Cependant, nous aimerions avoir un peu plus d'information sur l'erreur. Nous savons uniquement qu'une valeur passée en argument est incorrecte.

Pour ce faire, nous pouvons ajouter des informations lors de la création de l'objet ''std::invalid_argument''.

#include<stdexcept>
 
int divrem(int numerator, int denominator, int& remainder)
{
    if(denominator == 0)
      throw std::invalid_argument("denominator is zero");
    remainder = numerator % denominator;
    return numerator / denominator;
}
 
...
  try
  {
      divrem(2, 0);
  }
  catch(std::invalid_argument e)
  {
      std::cout << "Error: " << e.what()  << std::endl;
  }
  catch(...)
  {
      std::cout << "Error: Unknwon error when calling the function." << std::endl;
  }
...

Dans ce cas, nous capturons l'exception par la clause suivante :

  catch(std::invalid_argument e)
  {
      std::cout << "Error: " << e.what()  << std::endl;
  }

et e contient une copie de l'exception. Dés lors, il est possible d'accéder aux champs et méthodes de l'objet std::invalid_argument et notamment la méthode what() qui retourne le message d'erreur qui a été passé à l'objet lors de son initialisation.

C'est pour cela que lorsque l'on exécute le code, nous allons obtenir le message suivant :

Error: denominator is zero

Ce qui est beaucoup plus parlant que le message générique.

Relancer une exception

#include<iostream>
#include<sstream>
#include<string>
#include<stdexcept>
 
std::string read_file_content(std::string theFileName)
{
    if(theFileName.empty)
        throw std::invalid_argument("file name is empty");
    if(!std::filesystem.exists(theFileName))
        throw std::invalid_argument("file name does not exists");
 
    std::ifstream fileStream(filename, std::ios::in);
    if(fileStream.fail())
        throw std::invalid_argument("file name does not exists");
    ostringstream result;
    try
    {
        while(!file.eof())
        {
            char buffer[512];
            int numberOfCharsRead = 512;
            fileStream.read(buffer, numberOfCharsRead);
            if(fileStream.fail()) 
            {
                if(!fileStream.eof())
                    throw std::ios::failure("IO error when reading from file");
                numberOfCharsRead = fileStream.gcount();
            }
            result.write(buffer, numberOfCharsRead);
        }
    }
    catch(...)
    {
        fileStream.close();
        throw;
    }
    return result.str();
}

Ce code ouvre un fichier et lit son contenu. Il génère les exceptions suivantes :

Cependant, nous avons placé le bloc suivant :

    try
    {
        while(!file.eof())
        {
            ...
        }
    }
    catch(...)
    {
        fileStream.close();
        throw;
    }

qui va capturer si une erreur d'entrée/sortie va se produire. Si c'est le cas, l'erreur va être capturée, nous allons forcer la fermeture du fichier et nous allons relancer l'exception, c'est ce que fait l'instruction throw sans paramètre, elle relance l'exception qui vient d'être capturée pour indiquer l'erreur à la fonction appelante 1).

1)
Dans le cas présent, ce code est inutile, puisque le fichier est bien fermé au moment de la destruction du fichier, mais c'est simplement pour montrer comment capturer une exception pour effectuer un traitement local et la propager à l'appelant pour terminer le traitement