Exceptions (g++ - partie 8)

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".

Introduction

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.

blocs 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.

Déclenchement

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.

Héritage

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 */
}

Redéclenchement

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.

Exemples

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.h
typedef 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

#include 
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;
}
 
Utilisation :
$ 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.

zdevice.cpp
#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 exceptions

Si 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.

Avec capture des exceptions
#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/

Références :


Précédent Index Suivant

a+

Auteur : Xavier GARREAU
Modifié le 10.09.2004