Licence CC BY-NC

Erreur, erreur, erreur…

Dernière mise à jour :

Depuis le début de ce cours, nous avons été épargnés par les erreurs. Nous avons certes appris à sécuriser un peu les entrées, mais c’est tout. Ça ne sera pas toujours le cas. Il serait de bon ton que nos programmes soient résistants aux erreurs, afin de les rendre fiables et robustes.

Le but de ce chapitre va être d’introduire la gestion des erreurs ainsi que la réflexion à mener lors de l’écriture de son code.

L'utilisateur est un idiot

Derrière ce titre un poil provoquant se cache une vérité informatique universelle : peu importe que ce soit consciemment ou non, il arrivera forcément que l’utilisateur rentre une information incomplète, fausse, ou totalement différente de ce qu’on attendait. Que ce soit parce qu’il veut s’amuser avec votre programme ou bien parce qu’il a mal compris une consigne, a oublié un caractère ou s’est trompé de ligne, votre programme doit être capable de gérer ça.

Vérifier les entrées veut bien sûr dire s’assurer qu’elles soient du bon type, mais aussi contrôler que la donnée est cohérente et valide. Ainsi, vous ne pouvez pas dire que vous êtes nés un 38 avril, car un mois a 31 jours au maximum. Si la donnée n’est pas correcte, même si elle est du bon type, il faut demander à l’utilisateur de la ressaisir. Cela se fait relativement facilement.

#include <iostream>

int main()
{
    int jour { 0 };
    std::cout << "Donne un jour entre 1 et 31 : ";
    
    while (!(std::cin >> jour) || jour < 0 || jour > 31)
    {
        std::cout << "Entrée invalide. Recommence." << std::endl;
        std::cin.clear();
        std::cin.ignore(255, '\n');
    }

    std::cout << "Jour donné : " << jour << std::endl;
    return 0;
}

Tant que l’entrée met std::cin dans un état invalide ou qu’elle n’est pas dans la plage de valeurs autorisées, on demande à l’utilisateur de saisir une nouvelle donnée. Vous reconnaitrez que ce code est tiré de notre fonction entree_securisee. Je l’ai extraite pour pouvoir ajouter des conditions supplémentaires. Nous verrons dans le prochain chapitre une façon d’améliorer ça.

Pour l’instant, retenez qu’il faut vérifier qu’une donnée est cohérente en plus de s’assurer qu’elle est du bon type.

À une condition… ou plusieurs

Écrire une fonction peut être plus ou moins simple, en fonction de ce qu’elle fait, mais beaucoup de développeurs oublient une étape essentielle consistant à réfléchir au(x) contrat(s) que définit une fonction. Un contrat est un accord entre deux parties, ici celui qui utilise la fonction et celui qui la code.

  • La fonction mathématique std::sqrt, qui calcule la racine carrée d’un nombre, attend qu’on lui donne un réel nul ou positif. Si ce contrat est respecté, elle s’engage à retourner un réel supérieur ou égal à zéro et que celui-ci au carré soit égal à la valeur donnée en argument. Le contrat inhérent à la fonction std::sqrt se résume donc ainsi : « donne-moi un entier positif, et je te renverrai sa racine carrée ». Ça paraît évident, mais c’est important de le garder en tête.
  • Quand on récupère un élément d’un tableau avec [], il faut fournir un index valide, compris entre 0 et la taille du tableau moins un. Si on fait ça, on a la garantie d’obtenir un élément valide.
  • Si l’on a une chaîne de caractères ayant au moins un caractère, on respecte le contrat de pop_back et on peut retirer en toute sécurité le dernier caractère.

On voit, dans chacun de ces exemples, que la fonction attend qu’on respecte une ou plusieurs conditions, que l’on nomme les préconditions. Si celles-ci sont respectées, la fonction s’engage en retour à respecter sa part du marché, qu’on appelle les postconditions. Les passionnés de mathématiques feront un parallèle avec les domaines de définitions des fonctions.

Le but de réfléchir à ces conditions est de produire du code plus robuste, plus fiable et plus facilement testable, pour toujours tendre vers une plus grande qualité de notre code. En effet, en prenant quelques minutes pour penser à ce qu’on veut que la fonction fasse ou ne fasse pas, on sait plus précisément quand elle fonctionne et quand il y a un bug.

Contrats assurés par le compilateur

Le typage

Un exemple simple de contrat, que nous avons déjà vu sans même le savoir, s’exprime avec le typage. Si une fonction s’attend à recevoir un entier, on ne peut pas passer un std::vector. Le compilateur s’assure qu’on passe en arguments des types compatibles avec ce qui est attendu.

Avec const

Un autre type de contrat qu’on a déjà vu implique l’utilisation de const. Ainsi, une fonction qui attend une référence sur une chaîne de caractères constante garantie que celle-ci restera intacte, sans aucune modification, à la fin de la fonction. Cette postcondition est vérifiée par le compilateur, puisqu’il est impossible de modifier un objet déclaré comme étant const.

Vérifier nous-mêmes

Le compilateur fait une partie du travail certes, mais la plus grosse part nous revient. Pour vérifier nos contrats, nous avons dans notre besace plusieurs outils que nous allons voir.

Le développeur est un idiot

Vous souvenez vous du chapitre sur les tableaux ? Quand nous avons vu l’accès aux éléments avec les crochets, je vous ai dit de vérifier que l’indice que vous utilisez est bien valide, sous peine de comportements indéterminés. Si vous ne le faites pas, alors c’est de votre faute si le programme plante, car vous n’avez pas respectés une précondition. C’est ce qu’on appelle une erreur de programmation, ou bug.

Dans le cas où ce genre de problème arrive, rien ne sert de continuer, mieux vaut arrêter le programme et corriger le code. Il existe justement un outil qui va nous y aider : les assertions.

Fichier à inclure

Pour utiliser les assertions, il faut inclure le fichier d’en-tête <cassert>.

Une assertion fonctionne très simplement. Il s’agit d’évaluer une condition quelconque. Si la condition est vraie, le programme continue normalement. Par contre, si elle se révèle être fausse, le programme s’arrête brutalement. Voyez par vous-mêmes ce qu’il se passe avec le code suivant.

#include <cassert>

int main()
{
    // Va parfaitement fonctionner et passer à la suite.
    assert(1 == 1);
    // Va faire planter le programme.
    assert(1 == 2);
    return 0;
}
Assertion failed: 1 == 2, file d:\documents\visual studio 2017\projects\zdscpp\zdscpp\main.cpp, line 8

Le programme plante et nous indique quel fichier, quelle ligne et quelle condition exactement lui ont posé problème. On peut même ajouter une chaîne de caractères pour rendre le message d’erreur plus clair. Cela est possible car, pour le compilateur, une chaîne de caractères est toujours évaluée comme étant true.

#include <cassert>

int main()
{
    // Va parfaitement fonctionner et passer à la suite.
    assert(1 == 1 && "1 doit toujours être égal à 1.");
    // Va faire planter le programme.
    assert(1 == 2 && "Oulà, 1 n'est pas égal à 2.");
    return 0;
}
Assertion failed: 1 == 2 && "Oulà, 1 n'est pas égal à 2.", file d:\documents\visual studio 2017\projects\zdscpp\zdscpp\main.cpp, line 8

Pour donner un exemple concret, on va utiliser assert pour vérifier les préconditions de l’accès à un élément d’un tableau. Ce faisant, nous éviterons les comportements indéterminés qui surviennent si nous violons le contrat de la fonction.

#include <cassert>
#include <iostream>
#include <vector>

int main()
{
    std::vector<int> const tableau { -4, 8, 452, -9 };
    int const index { 2 };

    assert(index >= 0 && "L'index ne doit pas être négatif.");
    assert(index < std::size(tableau) && "L'index ne doit pas être plus grand que la taille du tableau.");
    std::cout << "Voici l'élément " << index << " : " << tableau[index] << std::endl;

    return 0;
}

Si, lors d’un moment d’inattention par exemple, nous avons utilisé un indice trop grand, alors le plantage provoqué par assert nous le rappellera et nous poussera ainsi à corriger le code. La documentation est, pour cela, une aide très précieuse, car elle indique comment réagit une fonction en cas d’arguments invalides. Dans la suite du cours, nous apprendrons à utiliser ces informations.

Mais je ne veux pas que mon programme plante. Pourquoi utiliser assert ?

En effet, plus un programme est résistant aux erreurs et mieux c’est. Cependant, assert s’utilise non pas pour les erreurs de l’utilisateur mais bel et bien pour celles du programmeur. Quand le développeur est distrait et écrit du code qui résulte en comportements indéterminés (comme un dépassement d’indice pour un tableau), tout peut arriver et justement, ce n’est pas ce qu’on attend du programme. Cela n’a donc pas de sens de vouloir continuer l’exécution.

Par contre, les erreurs de l’utilisateur, par exemple rentrer une chaîne de caractères à la place d’un entier, ne sont absolument pas à traiter avec assert. On ne veut pas que notre programme plante brutalement parce que l’utilisateur s’est trompé d’un caractère.

Préconditions et assertions

Pour nos propres fonctions, le principe sera le même. Nous allons utiliser les assertions pour vérifier que les paramètres reçus respectent bien les contrats que nous avons préalablement définis.

Prenons un exemple bête avec une fonction chargée de renvoyer le résultat de la division de deux réels.

#include <cassert>
#include <iostream>

double division(double numerateur, double denominateur)
{
    assert(denominateur != 0.0 && "Le dénominateur ne peut pas valoir zéro.");
    return numerateur / denominateur;
}

int main()
{
    std::cout << "Numérateur : ";
    double numerateur { 0.0 };
    std::cin >> numerateur;

    double denominateur { 0.0 };
    // La responsabilité de vérifier que le contrat est respecté appartient au code appellant.
    do
    {
        std::cout << "Dénominateur : ";
        std::cin >> denominateur;

    } while (denominateur == 0.0);

    std::cout << "Résultat : " << division(numerateur, denominateur) << std::endl;
    return 0;
}

La fonction division pose ici une précondition, qui est que le dénominateur soit non nul. Si elle est respectée, alors elle s’engage, en post-condition, à retourner le résultat de la division du numérateur par le dénominateur.

Au contraire, si le contrat est violé, le programme va s’interrompre sur le champ, car une division par zéro est mathématiquement impossible. Si le programme continuait, tout et n’importe quoi pourrait arriver.

Les tests unitaires à notre aide

Pourquoi tester ?

C’est une bonne question. Pourquoi ne pas se contenter de lancer le programme et de voir soi-même avec quelques valeurs si tout va bien ? Parce que cette méthode est mauvaise. Déjà, nous n’allons tester que quelques valeurs, en général des valeurs qui sont correctes, sans forcément penser aux valeurs problématiques. De plus, ces tests sont consommateurs en temps, puisqu’il faut les faire à la main.

À l’opposé, en écrivant des tests nous-mêmes, nous bénéficions de plusieurs avantages.

  • Nos tests sont reproductibles, on peut les lancer à l’infini et vérifier autant que l’on veut. Ils sont déjà écrits, pas de perte de temps donc.
  • Conséquence du point précédent, on peut les lancer au fur et à mesure que l’on développe pour vérifier l’absence de régressions, c’est-à-dire des problèmes qui apparaissent suite à des modifications, comme une fonction qui ne renvoie plus un résultat correct alors qu’auparavant, c’était le cas.
  • Comme on ne perd pas de temps manuellement, on peut écrire plus de tests, qui permettent donc de détecter plus facilement et rapidement d’éventuels problèmes. Ceux-ci étant détectés plus tôt, ils sont plus faciles à corriger et le code voit sa qualité progresser.

Un test unitaire, qu’est-ce que c’est ?

Un test unitaire, abrégé TU est un morceau de code dont le seul but est de tester un autre morceau de code, afin de vérifier si celui-ci respecte ses contrats. Il est dit unitaire car il teste ce morceau de code en toute indépendance du reste du programme. Pour cela, les morceaux de code qui seront testés unitairement doivent être relativement courts et facilement isolable. Voici des exemples d’identifiants explicites qui vont vous aider à voir ce à quoi peut ressembler un test unitaire.

  • test_sinus_0_retourne_0
  • test_cosinus_pi_retourne_moins1
  • test_parenthesage_expression_simple_retourne_true

Notez que les noms des fonctions sont un peu long mais très clairs. Cela est important, car quand certains tests échouent, s’ils ont un nom clair, qui définit sans ambiguïté ce que chaque test vérifie, il est plus facile de corriger ce qui ne va pas. Ainsi, si la fonction test_cosinus_zero échoue, vous savez où regarder. À l’inverse, un nom trop vague, trop générique, comme test_cosinus, ne nous donnerait aucune indication sur ce qui n’est pas correct.

Et si ce genre de test est nommé unitaire, c’est qu’il en existe d’autres types, que nous ne verrons pas dans le cadre de ce cours, mais dont vous entendrez parler au fur et à mesure que vous continuerez en programmation.

  • Les tests d’intégration, qui vérifient que différents modules s’intègrent bien entre eux. On peut tester, par exemple, que le module de payement s’intègre bien au reste de l’application déjà développé.
  • Les tests fonctionnels, qui vérifient que le produit correspond bien à ce qu’on a demandé. Des tests fonctionnels sur une calculatrice vérifieront que celle-ci offre bien les fonctionnalités demandées (addition, logarithme, etc) par rapport à ce qui avait été défini et prévu (si l’on avait demandé le calcul de cosinus, il faut que la calculatrice l’implémente).
  • Les tests d’UI (interface utilisateur, de l’anglais user interface), qui vérifient, dans le cas d’une application graphique, que les boutons sont bien au bon endroit, les raccourcis, les menus et sous-menus, etc.
  • Et encore de nombreux autres que je passerai sous silence.

Écrire des tests unitaires

Il existe de nombreuses bibliothèques pour écrire des tests unitaires en C++, certaines basiques, d’autres vraiment complètes, en bref, pour tous les goûts. Dans notre cas, nous n’avons pas besoin d’une solution aussi poussée. Nous allons écrire nos tests très simplement : quelques fonctions que nous lancerons dans le main pour vérifier que tout va bien.

Et pour illustrer cette section, nous allons tester un type que nous connaissons bien, std::vector. Bien entendu, il a déjà été testé bien plus complètement et profondément par les concepteurs de la bibliothèque standard, mais ce n’est pas grave.

Par exemple, pour tester que la fonction push_back fonctionne correctement, on va vérifier qu’elle augmente bien le nombre d’éléments du tableau de un, et que l’élément ajouté se trouve bien à la fin du tableau. On obtient un code qui ressemble à ça.

#include <cassert>
#include <iostream>
#include <vector>

void test_push_back_taille_augmentee()
{
    std::vector<int> tableau { 1, 2, 3 };
    assert(std::size(tableau) == 3 && "La taille doit être de 3 avant l'insertion.");

    tableau.push_back(-78);
    assert(std::size(tableau) == 4 && "La taille doit être de 4 après l'insertion.");
}

void test_push_back_element_bonne_position()
{
    std::vector<int> tableau { 1, 2, 3, 4};
    int const element { 1 };

    tableau.push_back(element);

    assert(tableau[std::size(tableau) - 1] == element && "Le dernier élément doit être 1.");
}

int main()
{
    test_push_back_taille_augmentee();
    test_push_back_element_bonne_position();

    return 0;
}

Si on lance ce code, on voit que rien ne s’affiche et c’est normal. Comme le std::vector fournit dans la bibliothèque standard est de qualité, on est assuré qu’il se comportera bien comme il doit. Cependant, si nous avions dû écrire nous-mêmes std::vector, alors ces tests nous auraient assurés de la qualité de notre code.

Vous pouvez vous entraîner en écrivant les tests unitaires des différents exemples et exercices des chapitres passés.

Les tests unitaires et les postconditions

Rappelez-vous, nous avons défini les postconditions comme étant les contrats que doit respecter une fonction si ses préconditions le sont. Nous pouvons, grâce aux tests unitaires, vérifier que la fonction fait bien son travail en lui fournissant des paramètres corrects et en vérifiant que les résultats sont valides et correspondent à ce qui est attendu.

[T.P] Gérer les erreurs d'entrée — Partie IV

Un autre genre de mauvaise pratique consiste à écrire des littéraux en dur dans le code. Un exemple, que j’ai moi-même introduit dans ce cours (honte à moi !), se trouve dans les précédents T.P sur la gestion des entrées.

std::cin.ignore(255, '\n');

On décide d’ignorer jusqu’à 255 caractères, mais que se passe-t-il si l’utilisateur décide d’écrire un roman en guise d’entrée ? Nous ne supprimons pas tous les caractères et notre fonction échoue à remplir sa mission : c’est un bug, une erreur de notre part. Afin de le corriger, il faudrait vider le bon nombre de caractères. Comment faire ?

Dans le fichier d’en-tête <limits>, il existe un type std::numeric_limits qui permet, pour chaque type, comme int, double et autres, d’en obtenir quelques propriétés, telles la valeur maximale que peut stocker ce type, ou bien la valeur minimale. Comme ces valeurs peuvent varier en fonction de la plateforme et du compilateur, utiliser std::numeric_limits à la place de constantes écrites en dur est un gage d’une meilleure robustesse et donc de qualité.

std::numeric_limits</* type en question */>::/*fonction à appeller */
#include <iostream>
#include <limits>

int main()
{
    std::cout << "Valeur minimale d'un int : " << std::numeric_limits<int>::min() << std::endl;
    std::cout << "Valeur maximale d'un int : " << std::numeric_limits<int>::max() << std::endl;

    std::cout << "Valeur minimale d'un double : " << std::numeric_limits<double>::min() << std::endl;
    std::cout << "Valeur maximale d'un double : " << std::numeric_limits<double>::max() << std::endl;

    return 0;
}

Pour résoudre notre problème soulevé en introduction à cette section, il faut juste savoir combien de caractères peut stocker au maximum std::cin. Un int, par exemple, pourrait être trop petit et ne pas suffire, ou être trop grand et donc nous créer des problèmes. Un type spécifique, std::streamsize, est justement là pour ça.

En se basant sur ces informations, vous avez toutes les clés pour rendre notre fonction de gestion des entrées plus robuste. :)

Correction T.P Partie IV
#include <iostream>
#include <limits>

void entree_securisee(int & variable)
{
    while (!(std::cin >> variable))
    {
        std::cout << "Entrée invalide. Recommence." << std::endl;
        std::cin.clear();
        std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
    }
}

void entree_securisee(double & variable)
{
    while (!(std::cin >> variable))
    {
        std::cout << "Entrée invalide. Recommence." << std::endl;
        std::cin.clear();
        std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
    }
}

int main()
{
    int annee { 0 };
    std::cout << "Donne-moi une année (essaye de taper un long texte, je ne broncherai pas) : ";
    entree_securisee(annee);

    return 0;
}

Notez que le code est toujours dupliqué. Nous verrons progressivement comment améliorer ce problème dans les chapitres qui suivent.

L'exception à la règle

La problématique

Au fur et à mesure que vous coderez des programmes de plus en plus complexes, vous verrez qu’il existe plein de cas d’erreurs extérieures. Parfois, un fichier sera manquant ou supprimé pendant la lecture. D’autres fois, il n’y aura plus assez de mémoire pour stocker un gros tableau. Ce sont des situations qui n’arrivent que rarement, mais que le programme doit être capable de gérer.

Prenons un exemple très simple, avec l’ouverture et la lecture d’un fichier. Ce programme récupère un tableau de chaînes de caractères, lues depuis un fichier quelconque.

#include <fstream>
#include <iostream>
#include <string>
#include <vector>

std::vector<std::string> lire_fichier(std::string const & nom_fichier)
{
    std::vector<std::string> lignes {};
    std::string ligne { "" };
    std::ifstream fichier { nom_fichier };

    while (std::getline(fichier, ligne))
    {
        lignes.push_back(ligne);
    }

    return lignes;
}

int main()
{
    std::string nom_fichier { "" };
    std::cout << "Donnez un nom de fichier : ";
    std::cin >> nom_fichier;

    auto lignes = lire_fichier(nom_fichier);
    std::cout << "Voici le contenu du fichier :" << std::endl;
    for (auto const & ligne : lignes)
    {
        std::cout << ligne << std::endl;
    }

    return 0;
}

Si nous ne pouvons pas ouvrir le fichier parce que celui-ci n’existe pas, par exemple, alors il faut trouver un moyen de le signaler à l’utilisateur.

On peut modifier la fonction pour qu’elle retourne un booléen, et le tableau sera un argument transmis par référence. C’est la façon de faire en C, mais elle comporte un gros défaut : on peut très bien ignorer la valeur de retour de la fonction et par là, ne pas détecter l’erreur potentielle. Cette solution n’est donc pas bonne.

On peut aussi afficher un message et demander de saisir un nouveau nom. C’est une solution parmi d’autres, mais elle implique de rajouter des responsabilités à notre fonction, puisque en plus de lire un fichier, elle doit maintenant gérer des entrées. De plus, si nous voulons changer nos entrées / sorties de la console vers un fichier, ou même un programme graphique, nous sommes coincés.

La solution consiste à utiliser un nouveau concept que je vais introduire, les exceptions.

C’est quoi une exception ?

C’est un mécanisme qui permet à un morceau de code, comme une fonction, de signaler à un autre morceau de code que quelque chose d’exceptionnel s’est passé. On dit du premier morceau qu’il lance / lève l’exception (en anglais « throw ») et que le second la rattrape (en anglais « catch »).

Contrairement aux assertions qui vont signaler la présence d’erreurs internes, de bugs à corriger, les exceptions s’utilisent pour les erreurs externes, erreurs qui peuvent arriver même si votre programme est parfaitement codé. Ne les voyez donc pas comme deux méthodes opposées, mais, bien au contraire, complémentaires.

Lancement des exceptions dans 3, 2, 1…

Tout d’abord et comme pour beaucoup de choses en C++, il faut inclure un fichier d’en-tête, qui se nomme ici <stdexcept>. Ensuite, le reste est simple. On utilise le mot-clef throw suivi du type de l’exception qu’on veut lancer. Car, de même qu’il existe des types pour stocker des caractères, des entiers, des réels et autres, il existe différents types d’exception pour signaler une erreur de taille, une erreur de mémoire, une erreur d’argument, etc.

Celle que nous allons utiliser s’appelle std::runtime_error et représente n’importe quelle erreur à l’exécution. Elle attend une chaîne de caractères, le message d’erreur.

#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>

std::vector<std::string> lire_fichier(std::string const & nom_fichier)
{
    std::vector<std::string> lignes {};
    std::string ligne { "" };

    std::ifstream fichier { nom_fichier };
    if (!fichier)
    {
        // Si le fichier ne s'ouvre pas, alors on lance une exception pour le signaler.
        throw std::runtime_error("Fichier impossible à ouvrir.");
    }

    while (std::getline(fichier, ligne))
    {
        lignes.push_back(ligne);
    }

    return lignes;
}

int main()
{
    std::string nom_fichier { "" };
    std::cout << "Donnez un nom de fichier : ";
    std::cin >> nom_fichier;

    auto lignes = lire_fichier(nom_fichier);
    std::cout << "Voici le contenu du fichier :" << std::endl;
    for (auto const & ligne : lignes)
    {
        std::cout << ligne << std::endl;
    }

    return 0;
}
Tester l’ouverture d’un flux

Pour tester si notre fichier a bien été ouvert, il suffit d’un simple if.

Lancez le programme et regardez-le … planter lamentablement. Bouahaha, je suis cruel. :diable:

Que s’est-il passé ? Lors du déroulement du programme, dès qu’on tombe sur une ligne throw, alors l’exécution de la fonction où l’on était s’arrête immédiatement (on dit que l’exception « suspend l’exécution ») et on revient à la fonction appelante. Si celle-ci n’est pas en mesure de gérer l’exception, alors on revient à la précédente encore. Quand enfin on arrive à la fonction main et que celle-ci ne sait pas comment gérer l’exception elle non plus, la norme C++ prévoit de tuer tout simplement le programme.

Attends que je t’attrape !

Il nous faut maintenant apprendre à rattraper (on dit aussi « catcher ») l’exception, afin de pouvoir la traiter. Pour cela, on utilise la syntaxe try catch (try étant l’anglais pour « essaye »).

Concrètement, si un code est situé dans un bloc try et que ce code lève une exception, l’exécution du programme s’arrête à cette ligne et on part directement dans le bloc catch pour traiter la-dite exception. Quant à ce dernier, il faut lui préciser, en plus du mot-clef catch, le type d’exception à rattraper, par référence constante. Une fois une exception attrapée, on peut afficher le message qu’elle contient en appelant la fonction what.

#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>

std::vector<std::string> lire_fichier(std::string const & nom_fichier)
{
    std::vector<std::string> lignes {};
    std::string ligne { "" };

    std::ifstream fichier { nom_fichier };
    if (!fichier)
    {
        throw std::runtime_error("Fichier impossible à ouvrir.");
    }

    while (std::getline(fichier, ligne))
    {
        lignes.push_back(ligne);
    }

    return lignes;
}

int main()
{
    std::string nom_fichier { "" };
    std::cout << "Donnez un nom de fichier : ";
    std::cin >> nom_fichier;

    try
    {
        // Dans le try, on est assuré que toute exception levée
        // pourra être traitée dans le bloc catch situé après.

        auto lignes = lire_fichier(nom_fichier);
        std::cout << "Voici le contenu du fichier :" << std::endl;
        for (auto const & ligne : lignes)
        {
            std::cout << ligne << std::endl;
        }
    }
    // Notez qu'une exception s'attrape par référence constante.
    catch (std::runtime_error const & exception)
    {
        // On affiche la cause de l'exception.
        std::cout << "Erreur : " << exception.what() << std::endl;
    }

    return 0;
}
L’exécution s’arrête lors d’une exception

Je l’ai écrit plus haut, mais je le remets bien lisiblement ici : dès qu’un appel à une fonction, dans le try, lève une exception, le code suivant n’est pas exécuté et on file directement dans le catch. C’est pour ça que le message "Voici le contenu du fichier :" n’est pas affiché.

Voilà, notre programme est maintenant capable de rattraper et de gérer cette situation exceptionnelle, bien qu’on se contente ici de simplement afficher la cause de l’erreur. On peut facilement imaginer un programme qui redemande de saisir un nom de fichier valide tant qu’on rencontre une erreur. Essayez donc de le faire.

#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>

std::vector<std::string> lire_fichier(std::string const & nom_fichier)
{
    std::vector<std::string> lignes {};
    std::string ligne { "" };

    std::ifstream fichier { nom_fichier };
    // Pour tester si un fichier est ouvert et valide, un simple if suffit.
    if (!fichier)
    {
        throw std::runtime_error("Fichier impossible à ouvrir.");
    }

    while (std::getline(fichier, ligne))
    {
        lignes.push_back(ligne);
    }

    return lignes;
}

std::string demander_nom_fichier()
{
    std::string nom_fichier { "" };
    std::cout << "Donnez un nom de fichier : ";
    std::cin >> nom_fichier;
    return nom_fichier;
}

int main()
{
    std::string nom_fichier { demander_nom_fichier() };
    bool fichier_valide { true };

    do
    {
        try
        {
            auto lignes = lire_fichier(nom_fichier);
            std::cout << "Voici le contenu du fichier :" << std::endl;
            for (auto const & ligne : lignes)
            {
                std::cout << ligne << std::endl;
            }

            // Si on atteint cette ligne, c'est qu'aucune exception n'a été levée et donc que le fichier est OK.
            fichier_valide = true;
        }
        catch (std::runtime_error const & exception)
        {
            std::cout << "Erreur : " << exception.what() << std::endl;
            // Si on atteint cette ligne, c'est qu'une exception a été levée car le nom n'est pas bon.
            fichier_valide = false;
            // Donc on redemande le nom.
            nom_fichier = demander_nom_fichier();
        }

    } while (!fichier_valide);

    return 0;
}

🎜 Attrapez-les tous !

Le catch qui suit le try, dans notre exemple, permet d’attraper des exceptions de type std::runtime_error. Mais certaines fonctions peuvent lancer plusieurs types d’exceptions. Il suffit cependant d’enchaîner les catch pour attraper autant d’exceptions différentes qu’on le souhaite.

Prenons l’exemple d’une fonction vue précédemment, permettant de convertir une chaîne de caractère en entier, std::stoi. Cette fonction peut lever deux types d’exception.

  • Soit std::invalid_argument si la chaîne de caractère est invalide et ne peut pas être convertie.
  • Soit std::out_of_range si le nombre à convertir est trop grand.
#include <iostream>
#include <stdexcept>
#include <string>

int main()
{
    try
    {
        // Essayez avec un nombre très très grand ou bien avec des lettres pour observer le comportement de std::stoi.
        int entier { std::stoi("1000000000000000000000000000000000000000000000000000000") };
        std::cout << "Entier : " << entier << std::endl;
    }
    catch (std::invalid_argument const & exception)
    {
        std::cout << "Argument invalide : " << exception.what() << std::endl;
    }
    catch (std::out_of_range const & exception)
    {
        std::cout << "Chaîne trop longue : " << exception.what() << std::endl;
    }

    return 0;
}
Ordre des catch

L’ordre dans lequel vous mettez les catch n’a, ici, pas d’importance. En effet, si le catch est du même type que l’exception lancée, on rentre dedans, sinon on le saute.

Lancez le programme, modifiez la valeur donnée en argument à std::stoi et voyez par vous-mêmes comment on passe dans le catch attrapant l’exception lancée.

Un type pour les gouverner tous et dans le catch les lier

Il arrive, dans certains cas, qu’on ne veuille pas traiter différemment les exceptions qu’on attrape. Cela signifie t-il qu’on doit écrire autant de catch qu’il n’y a d’exceptions potentiellement lançables ? Non, on peut utiliser une exception générique.

Il existe un type, du nom de std::exception, qui décrit une exception générique, une erreur quelconque. Nous reviendrons plus tard sur les propriétés qui rendent ce résultat possible, mais, en attendant, retenez qu’on peut attraper n’importe quelle exception avec.

#include <iostream>
#include <stdexcept>
#include <string>

int main()
{
    try
    {
        // Essayez avec un nombre très très grand ou bien avec des lettres pour observer le comportement de std::stoi.
        int entier { std::stoi("1000000000000000000000000000000000000000000000000000000") };
        std::cout << "Entier : " << entier << std::endl;
    }
    catch (std::exception const & exception)
    {
        std::cout << "Une erreur est survenue : " << exception.what() << std::endl;
    }

    return 0;
}

Son utilisation n’est absolument pas contraire à l’utilisation de type d’exception précis. On peut ainsi décider d’attraper certaines exceptions bien précises pour faire un traitement particulier, et attraper toutes les autres grâce à std::exception et suivre le même processus pour toutes celles-ci. Il n’y a qu’une chose à garder en tête : le catch générique doit toujours se trouver en dernier.

#include <iostream>
#include <stdexcept>
#include <string>

int main()
{
    try
    {
        int entier { std::stoi("+a") };
        std::cout << "Entier : " << entier << std::endl;
    }
    catch (std::invalid_argument const & exception)
    {
        // Message particulier affiché seulement dans le cas d'une exception std::invalid_argument.
        std::cout << "Argument invalide : " << exception.what() << std::endl;
    }
    catch (std::exception const & exception)
    {
        // Message générique pour tous les autres types d'exception possibles.
        std::cout << "Erreur : " << exception.what() << std::endl;
    }

    return 0;
}
catch générique toujours en dernier

Je répète, le catch générique doit toujours être en dernier. En effet, les exceptions sont filtrées dans l’ordre de déclaration des catch. Comme toutes les exceptions sont des versions plus spécifiques de std::exception, elles seront toutes attrapées par le catch générique et jamais par les éventuels autres catch.


En résumé

  • Les entrées de l’utilisateur peuvent poser problème, c’est pour ça que nous les vérifions avec des conditions.
  • Créer une fonction demande une réflexion sur ses préconditions et postconditions.
  • Les assertions sont là pour les erreurs de programmation, les fautes du programmeur. On s’en sert pour les préconditions, notamment.
  • Les tests unitaires présentent de nombreux avantages, notamment celui de vérifier les postconditions de nos fonctions, et donc leur bon fonctionnement.
  • Les valeurs écrites en dur sont une mauvaise pratique et peuvent même créer des problèmes.
  • Les exceptions nous permettent de signaler des erreurs externes, des situations sur lesquelles le programme n’a pas prise.
  • Une exception se lève avec le mot-clé throw.
  • Une exception se rattrape dans un bloc catch, qui suit immédiatement un bloc try.
  • On peut avoir autant de bloc catch qu’on veut, un pour chaque type d’exception que l’on veut attraper.
  • On peut attraper toutes les exceptions de manière générique en catchant std::exception.