Aller au contenu

ELCa11 - BE #4 — Gestion d’erreurs avec exceptions et héritage

Cours associé : Cours 4 — Gestion d’erreurs et héritage (voir le polycopié, section Cours 4).


Objectifs

  • Lever et attraper des exceptions en C++ (throw, try, catch).
  • Définir une classe d’exception personnalisée, héritant de std::exception.
  • Mettre en œuvre l’héritage entre nos propres classes, avec méthode virtual redéfinie dans les classes filles.

Prérequis

  • BE #2 et BE #3 : classe DamierDyn complète avec opérateurs.

Principe

Le BE est en trois parties :

  1. Partie 1 — Lever et attraper une exception standard (std::out_of_range).
  2. Partie 2 — Définir une classe d’exception personnalisée plus expressive.
  3. Partie 3 — Mettre en place un héritage entre nos propres classes pour modéliser des damiers de jeux (dames, échecs).

À côté de cet énoncé, vous trouverez les codes des trois exemples vus en cours : Exceptions1.zip (premier throw/catch), Exceptions2.zip (exception sur un vecteur) et Exceptions3.zip (classe d’exception personnalisée). Ils illustrent les concepts de ce BE.


Partie 1 — Lever une exception standard

Notions à maîtriser

  • Les mots-clés throw, try, catch ;
  • la hiérarchie des exceptions standard du C++ ;
  • la classe std::out_of_range et ses parents.

Mini-encart pédagogique : la hiérarchie des exceptions standard

La bibliothèque standard fournit une arborescence de classes d’exceptions (en-tête <stdexcept>), toutes héritant de std::exception :

std::exception
├── std::runtime_error          (erreurs détectées à l'exécution)
│   ├── std::range_error
│   ├── std::overflow_error
│   └── std::underflow_error
└── std::logic_error            (erreurs imputables au programmeur)
    ├── std::invalid_argument
    ├── std::domain_error
    ├── std::length_error
    └── std::out_of_range       ← celle qu'on utilise ici

Toutes ces classes possèdent une méthode const char* what() const noexcept; qui renvoie un message d’erreur descriptif. On les construit avec un std::string : throw std::out_of_range("message");.

L’intérêt de la hiérarchie : on peut attraper toutes les exceptions standards d’un coup avec catch (const std::exception& e), ou bien attraper finement un type particulier avec catch (const std::out_of_range& e).

Contexte

Vous allez créer une nouvelle classe DamierExc, dérivée de DamierDyn par recopie (et pas par héritage — l’héritage viendra en partie 3). Cette duplication peut sembler artificielle, mais elle nous permet de faire évoluer librement DamierExc (ajout de la borne en partie 2, héritage en partie 3) sans modifier DamierDyn.

Travail à réaliser

  1. Créez un nouveau projet Qt Creator nommé damierexc.
  2. Recopiez le code de DamierDyn du BE #3 vers de nouveaux fichiers damierexc.h et damierexc.cpp. Renommez la classe (et toutes ses méthodes) en DamierExc.
  3. Modifiez la méthode Set(int x, int y, int val) : si (x, y) est en dehors du damier, levez une exception std::out_of_range :

    #include <stdexcept>
    // ...
    if (x < 0 || x >= L || y < 0 || y >= C) {
        throw std::out_of_range("Accès incorrect à une case du Damier");
    }
    
  4. Modifiez aussi Get(int x, int y) (introduit au BE #2) pour lever la même exception en cas d’accès hors damier — plus besoin de retourner 0 par convention !

Programme de test

Dans main(), attrapez l’exception :

#include "damierexc.h"
#include <iostream>

int main() {
    DamierExc D(3, 5);

    try {
        D.Set(2, -1, 8);     // -1 hors bornes : lève std::out_of_range
    } catch (const std::out_of_range& e) {
        std::cerr << "Exception attrapée : " << e.what() << std::endl;
    }

    std::cout << "Le programme continue." << std::endl;
    return 0;
}

Vérifiez que le programme :

  • affiche bien le message d’erreur sur cerr ;
  • continue son exécution après le catch (contrairement à std::exit(1) du BE #3, qui terminait brutalement).

Points à noter

  • throw X lève une exception ; le flot de contrôle remonte la pile d’appels jusqu’à un catch correspondant.
  • En remontant, les destructeurs des objets locaux sont appelés (mécanisme appelé stack unwinding) : pas de fuite de mémoire, c’est le grand avantage par rapport à std::exit.
  • catch (const std::out_of_range& e) : on attrape par référence constante, idiome standard.

Questions à se poser

  • Que se passe-t-il si une exception est levée mais qu’aucun catch ne la rattrape ? (réponse : std::terminate() est appelé, le programme s’arrête).
  • Si on remplace catch (const std::out_of_range& e) par catch (const std::exception& e), le code fonctionne-t-il toujours ? Pourquoi ?
  • Pourquoi attraper par référence (const std::exception& e) plutôt que par valeur (std::exception e) ?

Partie 2 — Définir sa propre classe d’exception

Notions à maîtriser

  • Héritage de std::exception ;
  • redéfinition de la méthode virtuelle what() ;
  • mot-clé noexcept ;
  • macros prédéfinies __FILE__ et __func__.

Contexte

std::out_of_range est pratique mais limité : son seul “argument” est un message texte. On veut une exception plus riche, qui transporte du contexte structuré (valeur rejetée, borne, lieu de l’erreur).

Par ailleurs, on veut imposer une borne supérieure sur les valeurs stockables dans le damier. Par exemple, avec une borne de 4, les valeurs admissibles dans chaque case seront dans [0, 4].

Travail à réaliser

1. Adapter DamierExc pour gérer une borne
  • Ajoutez un attribut int Borne; à la classe.
  • Modifiez le constructeur pour recevoir la borne en argument supplémentaire :

    DamierExc(int l, int c, int borne, int val = 0);
    
  • Modifiez Set, Init et operator+=(int c) pour vérifier que la valeur stockée reste dans [0, Borne].

  • Pour operator+=(const DamierExc&) et operator+, l’addition est autorisée mais la borne du résultat devient la somme des deux bornes.
2. Créer la classe ExceptionDamier

Créez deux nouveaux fichiers exceptiondamier.h et exceptiondamier.cpp. La classe hérite de std::exception :

#include <exception>
#include <string>

class ExceptionDamier : public std::exception {
public:
    ExceptionDamier(int borne, int valeur,
                    const std::string& fichier,
                    const std::string& fonction) noexcept;

    const char* what() const noexcept override;

private:
    int         Borne;
    int         Valeur;
    std::string Fichier;
    std::string Fonction;
    std::string Message;     // construit dans le constructeur, retourné par what()
};
3. Lever ExceptionDamier

Lors d’une tentative de stockage hors bornes, lancez :

throw ExceptionDamier(Borne, val, __FILE__, __func__);
  • __FILE__ est une macro qui renvoie le nom du fichier source courant.
  • __func__ est une constante prédéfinie (C++11) qui renvoie le nom de la fonction courante.

L’appel à what() doit retourner un message du type :

Borne : 4
Valeur rejetée : 10
Fichier : ../damierexc/damierexc.cpp
Fonction : Set

Programme de test

#include "damierexc.h"
#include "exceptiondamier.h"
#include <iostream>

int main() {
    DamierExc D(3, 5, 4);     // damier (3,5) avec borne 4
    D.Set(1, 2, 3);            // OK

    try {
        D.Init(6);             // 6 > 4 : lève ExceptionDamier
    } catch (const ExceptionDamier& e) {
        std::cerr << e.what() << std::endl;
    }
    return 0;
}

Points à noter

  • noexcept : promet au compilateur que la fonction ne lèvera aucune exception. Le constructeur d’une exception, et sa méthode what(), doivent eux-mêmes être noexcept — on ne veut pas qu’ils lèvent une exception lors de la levée d’une exception !
  • override : indique au compilateur qu’on redéfinit une méthode virtuelle de la classe parente. Si la signature ne correspond pas exactement, le compilateur émet une erreur — utile pour éviter les fautes de frappe silencieuses.
  • L’héritage class ExceptionDamier : public std::exception permet à un catch (const std::exception& e) d’attraper aussi nos ExceptionDamier — c’est le principe du polymorphisme (vu plus en détail en partie 3).

Aparténoexcept vs throw(). La syntaxe void f() throw(); (vue dans certains tutoriels anciens) avait le même rôle que noexcept, mais elle est dépréciée depuis C++11 et supprimée depuis C++17. Toujours utiliser noexcept aujourd’hui.

Questions à se poser

  • Pourquoi Message (et pas la concaténation à chaque appel) est-il un attribut de la classe ? (indice : la durée de vie du const char* retourné par what())
  • Que se passe-t-il si on oublie le override dans la déclaration de what() ?
  • Pourquoi what() ne peut-elle pas être déclarée noexcept(false) ?

Partie 3 — Héritage et polymorphisme

Notions à maîtriser

  • Héritage public ;
  • visibilité protected (intermédiaire entre private et public) ;
  • méthode virtuelle (virtual) ;
  • redéfinition dans les classes filles avec override.

Contexte

Notre DamierExc est suffisamment générale pour servir de base à plusieurs damiers de jeux : dames, échecs, go… Plutôt que de dupliquer son code dans DamierDame, DamierEchec et DamierGo, on va hériter de DamierExc. Chaque jeu apportera juste les spécificités qui lui sont propres : ses dimensions, sa borne, et la disposition initiale des pièces.

Conventions de représentation — chaque jeu a sa propre table.

Dames (10 × 10), borne 4 :

Valeur Signification
0 case vide
1 pion noir
2 pion blanc
3 dame promue noire
4 dame promue blanche

Échecs (8 × 8), borne 12 :

Valeur Pièce blanche Valeur Pièce noire
1 pion 7 pion
2 tour 8 tour
3 cavalier 9 cavalier
4 fou 10 fou
5 dame 11 dame
6 roi 12 roi

(0 = case vide.)

Go (19 × 19), borne 2 :

Valeur Signification
0 intersection vide
1 pierre noire
2 pierre blanche

Travail à réaliser

1. Adapter DamierExc pour l’héritage
  • Passez les attributs L, C, Borne, T de private: à protected:, pour que les classes filles y aient accès.
  • Ajoutez une méthode virtuelle pour l’initialisation des pièces :

    virtual void InitJeu();    // par défaut, ne fait rien (ou laisse le damier vide)
    
2. Classe DamierDame (10 × 10)

Dans des fichiers damierdame.h / damierdame.cpp, créez :

class DamierDame : public DamierExc {
public:
    DamierDame();
    void InitJeu() override;
};

Le constructeur appelle celui de la classe parente avec les bonnes dimensions et la bonne borne, via la liste d’initialisation :

DamierDame::DamierDame() : DamierExc(10, 10, 4, 0) {
    InitJeu();
}

Important — l’appel au constructeur parent doit se faire dans la liste d’initialisation (le : DamierExc(...) avant le {). Il n’y a aucune autre syntaxe pour le faire. Si vous omettez cet appel et que la classe parente n’a pas de constructeur sans argument (c’est le cas ici : DamierExc(int, int, int, int)), le code ne compilera pas. La liste d’initialisation a été introduite au BE #2 (partie 2, étape 1) — relisez si besoin.

InitJeu() place les pions de départ : 4 rangées de pions noirs en haut (valeur 1) sur les cases sombres, 4 rangées de pions blancs en bas (valeur 2) sur les cases sombres, 2 rangées vides au milieu. Les dames promues (3 / 4) n’apparaissent qu’en cours de partie.

3. Classe DamierEchec (8 × 8)

Constructeur : DamierEchec() : DamierExc(8, 8, 12, 0) { InitJeu(); }.

InitJeu() place les pièces en position de départ — voir le tableau de représentation des pièces ci-dessus. Pour la rangée 0 (noire), l’ordre traditionnel est : tour (8), cavalier (9), fou (10), dame (11), roi (12), fou (10), cavalier (9), tour (8). La rangée 1 est entièrement composée de pions noirs (7). Les rangées 6 (pions blancs, 1) et 7 (pièces blanches, ordre symétrique : 2, 3, 4, 5, 6, 4, 3, 2) sont disposées de manière analogue. Les rangées 2 à 5 sont vides.

4. (Bonus) Classe DamierGo (19 × 19)

Constructeur : DamierGo() : DamierExc(19, 19, 2, 0) { InitJeu(); }. Au Go, le plateau de départ est vide ; InitJeu() ne fait rien (ou on peut s’en passer si l’initialisation à 0 du parent suffit).

Programme de test

#include "damierdame.h"
#include "damierechec.h"
#include <iostream>

int main() {
    DamierDame  Dames;
    DamierEchec Echecs;

    std::cout << "Damier de dames :"  << std::endl << Dames  << std::endl;
    std::cout << "Damier d'échecs :"  << std::endl << Echecs << std::endl;
    return 0;
}

Encart — Pour aller plus loin : classe abstraite

Dans notre conception, DamierExc reste concrète : on peut écrire DamierExc D(3, 5, 4); et l’instancier directement. C’est un choix qui maintient la cohérence avec la Partie 2.

Une conception plus pure aurait été d’introduire une classe abstraite DamierJeu, intermédiaire entre DamierExc et DamierDame/DamierEchec/DamierGo. On y déclarerait InitJeu() comme virtuelle pure :

virtual void InitJeu() = 0;     // pas de corps : virtuelle pure

Une classe contenant au moins une méthode virtuelle pure est dite abstraite : on ne peut pas l’instancier. Toute classe fille concrète doit fournir une implémentation des méthodes virtuelles pures.

Cette architecture force chaque jeu à définir son InitJeu() (le compilateur le vérifie). C’est une bonne pratique pour des hiérarchies riches, mais on n’ira pas jusque-là dans ce TD.

Points à noter

  • public DamierExc : héritage public — la classe fille hérite de l’interface publique du parent. C’est l’héritage qu’on utilise 99 % du temps.
  • protected: : les attributs sont accessibles depuis la classe fille, mais pas depuis l’extérieur. Plus permissif que private:, plus restrictif que public:.
  • virtual sur InitJeu() dans la classe parente : autorise la redéfinition dans les classes filles.
  • override dans la classe fille : indique qu’on redéfinit une méthode virtuelle. Permet au compilateur de vérifier la cohérence des signatures.
  • Le constructeur d’une classe fille doit toujours commencer par appeler un constructeur du parent (explicitement avec : DamierExc(...) ou implicitement si le parent a un constructeur sans argument).

Questions à se poser

  • Pourquoi passer T, L, C en protected: plutôt que de les laisser en private: ? Quelles seraient les conséquences ?
  • Que se passe-t-il si on instancie un DamierDame mais qu’on appelle InitJeu() via un pointeur sur DamierExc (DamierExc* p = new DamierDame; p->InitJeu();) ? Quelle version est appelée, et pourquoi le mot-clé virtual est-il essentiel ici ?
  • Pourrait-on avoir une méthode InitJeu() non virtuelle ? Que se passerait-il alors ?