Cet article est l'occasion de nous intéresser aux exceptions en C++. Les adeptes du java ne seront pas trop perdus car habitués à leur utilisation. Nous verrons au travers d'une application exemple comment écrire du code utilisant les exceptions, comment les déclarer et les "envoyer".
Les exceptions en C++ servent à signaler des "circonstances exceptionnelles", soit, la plupart du temps, des erreurs. Il s'agit d'une anvancée considérable (nombreux sont ceux qui soutiendraient le contraire) dans la gestion d'erreurs. Les exceptions permettent, éventuellement assistées d'un système de gestion d'erreurs plus classique, de déclarer les problèmes possibles, de les traiter partiellement ou totalement dans la section de code déclenchant une erreur et de propager l'information d'erreur aux couches supérieures du programme. Leur mode de fonctionnement est particulièrement adapté aux programmes constitués de plusieurs bibliothèques, leur permettant de se communiquer les évènements exceptionnels entre elles pour une gestion collaboratives de ces évènements.
Nous verrons en première partie une présentation des principes liés aux exceptions, puis, nous illustrerons ces propos au fil d'un exemple de réécriture de la gestion des circonstances exceptionnelles d'un programme.
Comme d'habitude, je ne serais pas exhaustif et vous renvoie aux ouvrages cités en référence pour de plus amples informations sur le sujet.
try ... catch
Lorsqu'un code est susceptible de déclencher une exception, on l'exécute dans un bloc try
.
Ceci veut littéralement dire essayer et correspond bien à la réalité : on tente d'exécuter le code,
tout en sachant qu'il est possible qu'une exception survienne pendant l'exécution de ce code.
On prévoit le déclenchement d'une exception et on prend les mesures appropriées au sein d'un bloc
catch
faisant suite au bloc try
. On doit préciser à catch
quel type d'exception il doit tenter d'intercepter, en le lui passant en paramètre.
Un bloc de ce type suit donc le schéma suivant :
/* code normal */ try { /* instructions risquant de déclencher une exception */ } catch (type_exception& e) { /* mesures à prendre en cas de déclenchement d'une exception */ } /* code normal */
Si une des instructions du bloc try
déclenche une exception, le programme abandonne
ce bloc d'instructions et cherche à savoir si un bloc catch
existe pour traiter ce type
d'exception. Les instructions suivant celle ayant déclenché l'exception ne sont pas exécutées.
Chaque bloc catch
capture un type d'exception mais on peut mettre bout à bout
plusieurs blocs, un pour chaque type d'exception pouvant être survenir.
Dans les parenthèses suivant catch on trouve un type d'exception ou une référence ou bien encore
un pointeur sur exception, selon le type d'exception et sa méthode d'émission. On trouve parfois
la construction catche(...)
, qui signifie : capturer n'importe qu'elle exception.
Si une exception est déclenchée mais non interceptée par un bloc catch
,
le programme cesse de s'exécuter, vous gratifiant du message suivant : Aborted
.
Une méthode ou fonction susceptible de déclencher une excpetion doit l'annoncer dans son prototype via le mot clé throw. Le prototype d'un telle fonction ressemble alors à ceci :
type_retour nom_fonction(/* paramètres */) throw (type_exception);
Si la fonction peut déclencher plusieurs interruptions, il est possible d'en passer la liste à throw, en les séparant par des virgules.
Cette déclaration est optionnelle mais facilite grandement la compréhension des fichiers d'entête par les programmeurs devant les utiliser. Ils savent ainsi quelles exceptions ils devront prendre en compte lorsqu'ils utiliseront ces méthodes. Cela ne dispense pas de fournir une documentation de vos APIs mais en fait plutôt partie intégrante.
Dans une fonction, lorsque la nécessité de déclencher une exception se fait sentir,
le développeur utilise le mot clé throw
. Il faut fournir en paramètre un objet,
initialisé comme il se doit. Le code situé après cet appel n'est pas exécuté, il faut donc prendre
vos dispositions (concernant la libération de ressources par exemple) avant de déclencher une exception.
Une exception n'est ni plus ni moins qu'un objet, instance d'une classe tout à fait classique, si ce n'est qu'elle est utilisée pour signaler une situation exceptionnelle. Vous devez donc, pour déclencher une exception, définir une classe correspondant à cette exception, contenant, si besoin est, des méthodes et propriétés (publiques, protégées ou privées) pouvant être utiles au code interceptant cette exception.
En tant que classe normale, une classe d'exception peut hériter d'une ou plusieurs autres
classes. Ceci permet de regrouper les exceptions en "familles". Par exemple, on peut imaginer
deux exceptions nombre_trop_petit
et nombre_trop_grand
. On a besoin
de deux blocs catch
pour capturer ces deux exceptions. Si, par contre, elles héritent
toutes deux d'une exception nombre_exception
alors, un seul bloc catch
suffit à capturer ces deux exceptions. On peut parfois perdre en information en utilisant cette
technique, mais elle permet une gestion plus globale des exceptions tout en permettant d'affiner
cette gestion comme si les exceptions n'étaient pas liées entre elles.
Afin de pouvoir utiliser cette technique, les classes d'exceptions doivent utiliser un héritage public. Voici alors commetn on déclarerait ces tois classes d'exceptions :
class nombre_exception {}; class nombre_trop_petit : public nombre_exception {}; class nombre_trop_grand : public nombre_exception {};
Un bloc try ... catch
intéressé uniquement par des exceptions de type
nombre_trop_petit
sera codé ainsi :
try { /* instructions */ } catch (nombre_trop_petit& e) { /* instructions si nombre_trop_petit est déclenchée */ }
A l'inverse, si on veut calculer toutes les exceptions, on écrira plutôt :
try { /* instructions */ } catch (nombre_exception& e) { /* instructions si nombre_exception, nombre_trop_petit ou nombre_trop_grand est déclenchée */ }
Si un bloc catch
n'est pas en mesure de prendre toutes les dispositions nécessaires au traitement
d'une exception, il peut la faire suivre au "niveau supérieur" en utilisant le mot-clé throw
, seul,
sans arguments. La portion de code ayant appelé ce bout de code devra alors traiter cette exception et
éventuellemtn la faire suivre et ainsi de suite. Si tous les blocs catch
sont parcourus, le programme
s'arrête (Aborted
).
void f1() { try { /* instructions */ } catch(type_exception& e) { /* instructions */ throw; } } void f2() { try { /* instructions */ f1(); /* instructions */ } catch(type_exception& e) { /* instructions */ } }
Dans le code précédent, si une exception est déclenchée dans le bloc try
de f1
son bloc catch
prend les mesures appropriées et fais suivre (redéclenche) l'exception traitée.
C'est alors le bloc catch
de f2
qui prend le relais. Les instructions de f2
suivant l'appel de f1
ne sont pas exécutées, comme si l'exception n'avait pas été traitée. Le
fait que l'exception soit issue d'un redéclenchement est totalement transparent pour son traitement dans
f2
.
Nous allons partir d'un exemple écrit sans gestion d'exception, et appliqués divers changements permettant d'illustrer ce que nous venons de voir. Cet exemple représente un émulateur de mémoire réduite (1kilooctet) permettant la lecture et l'écriture d'informations. Les deux problèmes que nous avons à traiter sont les tentatives d'écriture et de lecture au delà de la mémoire physique que l'on simule.
zdevice.htypedef unsigned int zuint; typedef unsigned char zuchar; class zmemory { zuchar memdevice[1024]; public: zuint store(const zuchar * data, const zuint& dataaddr, const zuint& datalen); zuint read(zuchar * data, const zuint& dataaddr, const zuint& datalen); };zdevice.cpp
#include "zdevice.h" zuint zmemory::store(const zuchar* data, const zuint& dataaddr, const zuint& datalen) { zuint datawritten; if (dataaddr + datalen <= 1024) { memcpy (memdevice+dataaddr, data, datalen); return datalen; } else { if (dataaddr <= 1023) { datawritten = 1024 - dataaddr; memcpy (memdevice+dataaddr, data, datawritten); } else { datawritten = 0; } } return datawritten; } zuint zmemory::read(zuchar * data, const zuint& dataaddr, const zuint& datalen) { zuint dataread; if (dataaddr + datalen <= 1024) { memcpy (data, memdevice+dataaddr, datalen); return datalen; } else { if (dataaddr <= 1023) { dataread = 1024 - dataaddr; memcpy (data, memdevice+dataaddr, dataread); } else { dataread = 0; } } return dataread; }
La classe zmemory met à disposition deux méthodes, store
et read
, pour accéder à la mémoire simulée stockée dans un
membre privé, memdevice
. Ces méthodes prennent en paramètres un pointeur sur un buffer d'octets,
une adresse dans la mémoire et une taille de buffer. Par souci de facilité, on définit deux types entier non signé (zuint
)
et octet (zuchar
).
On tient compte de la possibilité d'erreur en renvoyant le nombre d'octets réellement stockés ou lus.
Jetons un coup d'oeil à une mauvaise utilisation de cette classe : main.cpp
#includeUtilisation :using namespace std; #include "zdevice.h" int main (void) { zmemory mem; int i=2000000000; zuint addr = 1021; cout << "Ecriture de i : " << i << " à l'adresse " << addr << endl; cout << "Taille de i : " << sizeof(i) << endl; zuint datawritten = mem.store((zuchar*)&i, addr, sizeof(i)); cout << datawritten << " octets réellement écrits" << endl; int j; cout << "Lecture de j à l'adresse " << addr << endl; cout << "Taille de j : " << sizeof(int) << endl; zuint dataread = mem.read((zuchar*)&j, addr, sizeof(j)); cout << "Valeur de j : " << j << endl; }
$ g++ -o zdevice zdevice.cpp main.cpp $ ./zdevice Ecriture de i : 2000000000 à l'adresse 1021 Taille de i : 4 3 octets réellement écrits Lecture de j à l'adresse 1021 Taille de j : 4 Valeur de j : 3511296
On tente ici une écriture puis une lecture de 4 octets à l'adresse 1021, ce qui nous fait rencontrer la bordure supérieure de notre mémoire virtuelle de 1024 octets (1021+4=1025).
Sans contrôle d'erreur, la valeur de i
que l'on croit retrouver en j
après
relecture s'avère être fausse, puisque tous les octets n'ont pu être ni écris, ni lus.
Passons maintenant à une implémentation avec exceptions de cet exemple. zdevice.h
typedef unsigned int zuint; typedef unsigned char zuchar; class zmem_exception { private: zuint good_bytes; public: zmem_exception(zuint gb) : good_bytes(gb) {}; zuint bytes_ok() { return good_bytes; }; }; class zmem_wr_exception : public zmem_exception { public: zmem_wr_exception(zuint gb) : zmem_exception(gb) {}; }; class zmem_rd_exception : public zmem_exception { public: zmem_rd_exception(zuint gb) : zmem_exception(gb) {}; }; class zmemory { zuchar memdevice[1024]; public: zuint store(const zuchar * data, const zuint& dataaddr, const zuint& datalen) throw(zmem_wr_exception); zuint read(zuchar * data, const zuint& dataaddr, const zuint& datalen) throw(zmem_rd_exception); };
On définit une exception générique zmem_exception
. On l'initialise avec un nombre
d'octets valides. Elle fournit également une méthode permettant à un gestionnaire d'exception de
connaître le nombre d'octets valides avant le déclenchement de l'exception.
zmem_wr_exception
et zmem_rd_exception
héritent de zmem_exception
et permettent d'utiliser des exceptions spécifiques selon que l'erreur intervient lors d'une écriture
ou d'une lecture.
On ajoute dans les prototypes des méthodes de la classe zmemory les clauses throw
identifiant
le type d'excception susceptible de survenir lors d'un appel à ces dernières.
#include "zdevice.h" zuint zmemory::store(const zuchar* data, const zuint& dataaddr, const zuint& datalen) throw(zmem_wr_exception) { if (dataaddr + datalen <= 1024) { memcpy (memdevice+dataaddr, data, datalen); return datalen; } else { if (dataaddr <= 1023) { memcpy (memdevice+dataaddr, data, 1024 - dataaddr); } throw zmem_wr_exception(1024 - dataaddr); } } zuint zmemory::read(zuchar * data, const zuint& dataaddr, const zuint& datalen) throw(zmem_rd_exception) { zuint dataread; if (dataaddr + datalen <= 1024) { memcpy (data, memdevice+dataaddr, datalen); return datalen; } else { if (dataaddr <= 1023) { dataread = 1024 - dataaddr; memcpy (data, memdevice+dataaddr, 1024 - dataaddr); } throw zmem_rd_exception(1024 - dataaddr); } }
L'implémentation des méthodes de zmemory
ne subit pas de grosse modification. On renvoit
le nombre d'octet lus ou écrit en cas de succès. En revanche, en cas de dépassement, une exception est
déclenchée via le mot clé throw
. Les exceptions sont initialisées avec le nombre d'octets
ayant pu être transférés dans un sens ou dans l'autre.
On peut à partir de ces fichiers illustrer les notions vues en début d'article.
Sans capture des exceptionsSi on garde le même fichier main.cpp
, on obtient la sortie suivante.
$ ./zdevice Ecriture de i : 2000000000 à l'adresse 1021 Taille de i : 4 Aborted
L'appel à store
se traduit par le déclenchement d'une exception
zmem_wr_exception
. Celle ci n'étant pas interceptée par un bloc catch
,
le programme se termine.
#include <iostream> using namespace std; #include "zdevice.h" int main (void) { zmemory mem; int i=34; zuint addr = 1021; cout << "Ecriture de i : " << i << " à l'adresse " << addr << endl; cout << "Taille de i : " << sizeof(int) << endl; try { mem.store((zuchar*)&i, addr, sizeof(int)); } catch (zmem_wr_exception& e) { cout << "L'opération n'a pas pu être menée à terme" << endl; cout << e.bytes_ok() << " octets réellement écrits" << endl; } int j; cout << "Lecture de j à l'adresse " << addr << endl; cout << "Taille de j : " << sizeof(int) << endl; try { mem.read((zuchar*)&j, addr, sizeof(int)); } catch (zmem_rd_exception& e) { cout << "L'opération n'a pas pu être menée à terme" << endl; cout << e.bytes_ok() << " octets réellement lus" << endl; } cout << "Valeur de j : " << j << endl; }
On entoure ici chaque ligne sensible dans un bloc try...catch
. On capture et traite
ainsi obligatoirement les exceptions déclenchées, ici, on se contente d'un message informatif.
On permet ici aux deux opérations de se poursuivre. Ce programme
donne la sortie suivante :
$ ./zdevice Ecriture de i : 34 à l'adresse 1021 Taille de i : 4 L'opération n'a pas pu être menée à terme 3 octets réellement écrits Lecture de j à l'adresse 1021 Taille de j : 4 L'opération n'a pas pu être menée à terme 3 octets réellement lus Valeur de j : 134217762
Si on veut empêcher la lecture d'avoir lieu en cas d'échec de l'écriture, on peut placer les deux intructions
dans un bloc try
et capturer les exceptions dans deux catch
distincts :
main_2.cpp
#include <iostream> using namespace std; #include "zdevice.h" int main (void) { zmemory mem; int i=34; int j; zuint addr = 1021; cout << "Ecriture de i : " << i << " à l'adresse " << addr << endl; cout << "Taille de i : " << sizeof(int) << endl; try { mem.store((zuchar*)&i, addr, sizeof(int)); cout << "Lecture de j à l'adresse " << addr << endl; cout << "Taille de j : " << sizeof(int) << endl; mem.read((zuchar*)&j, addr, sizeof(int)); } catch (zmem_wr_exception& e) { cout << "L'opération n'a pas pu être menée à terme" << endl; cout << e.bytes_ok() << " octets réellement écrits" << endl; } catch (zmem_rd_exception& e) { cout << "L'opération n'a pas pu être menée à terme" << endl; cout << e.bytes_ok() << " octets réellement lus" << endl; } cout << "Valeur de j : " << j << endl; }
Ce programme donne la sortie suivante :
$ ./zdevice Ecriture de i : 34 à l'adresse 1021 Taille de i : 4 L'opération n'a pas pu être menée à terme 3 octets réellement écrits Valeur de j : 0
On constate l'absence de tentative de lecture et que la valeur de j reste inchangée (en C++, un int est toujours initialisé à 0).
Si on se moque de savoir quelle exception empêche le déroulement de la fonction, on peut intercepter l'exception parente aux deux ci-dessus.
main_3.cpp#include <iostream> using namespace std; #include "zdevice.h" int main (void) { zmemory mem; int i=34; int j; zuint addr = 1021; cout << "Ecriture de i : " << i << " à l'adresse " << addr << endl; cout << "Taille de i : " << sizeof(int) << endl; try { mem.store((zuchar*)&i, addr, sizeof(int)); cout << "Lecture de j à l'adresse " << addr << endl; cout << "Taille de j : " << sizeof(int) << endl; mem.read((zuchar*)&j, addr, sizeof(int)); } catch (zmem_exception& e) { cout << "L'opération n'a pas pu être menée à terme" << endl; } cout << "Valeur de j : " << j << endl; }
$ ./zdevice Ecriture de i : 34 à l'adresse 1021 Taille de i : 4 L'opération n'a pas pu être menée à terme Valeur de j : 0
On perd cependant l'information sur l'étape ayant engendré l'erreur.
On peut encore étendre cette gestion en remplaçant
} catch (zmem_exception& e) {
par
} catch (...) {
On capture alors toutes les exceptions susceptibles d'être déclenchées. Ici, on obtient exactement la même sortie.
Enfin, on peut redéclencher l'exception. Dans notre cas, celà à pour effet de terminer le programme puisqu'aucune instance au dessus de nous ne traite l'exception.
Il vous suffit pour faire le test de modifier le corps du catch
ainsi :
cout << "L'opération n'a pas pu être menée à terme" << endl; throw;
L'affichage obtenu est alors le suivant :
$ ./zdevice Ecriture de i : 34 à l'adresse 1021 Taille de i : 4 L'opération n'a pas pu être menée à terme Aborted
J'espère que cet article vous aura donné envie d'utiliser les exceptions et de creuser plus avant les possibilités offertes par ce mécanisme. Comme toujours, vous retrouverez les codes sources de cet article en ligne, à l'url suivante : http://www.xgarreau.org/techelp/cg/cpp/sources/article_8/ On se retrouve bientôt pour de nouvelles aventures au pays de g++.
@+
Xavier Garreau - <xavier@xgarreau.org>
http://www.xgarreau.org/
Ingénieur de recherche PRIM'TIME TECHNOLOGY
http://www.prim-time.fr/
Membre fondateur du ROCHELUG
http://www.rochelug.org/
Précédent | Index | Suivant |
a+