• Aucun résultat trouvé

Quelques compléments au sujet des pointeurs

Dans le document Mieux programmer c++ (Page 184-189)

if( this != &other ) {

// ...

} }

J’espère que vous avez trouvé l’erreur du premier coup1.

Quelques compléments au sujet des pointeurs

Voici pour finir deux exemples de résultats inattendus pouvant se produire lors de comparaisons de pointeurs :

Comparer deux pointeurs pointant vers des chaînes de caractère littérales peut donner des résultats inattendus : en particulier, si vous comparez deux pointeurs vers deux variables chaînes littérales distinctes ayant la même valeur, il est possi-ble que ces deux pointeurs contiennent la même adresse. En effet, à des fins d’optimisation, la norme C++ autorise explicitement les compilateurs à ne pas stocker systématiquement chaque nouvelle chaîne littérale dans un espace mémoire séparé.

La comparaison de valeurs de pointeurs à l’aide des opérateurs <, <=, > et >= pro-duit en général un résultat indéterminé (sauf dans certains cas particuliers comme la comparaison de pointeurs vers des objets stockés dans un même tableau). Cette limitation peut être contournée par l’emploi du modèle de fonction less<> et de ses cousins, qui permettent d’établir une relation d’ordre entre des pointeurs. Ces fonctions sont utilisées, entre autres, lorsque l’on crée une table de correspondance utilisant un clé de type pointeur – par exemple map<T*,U> (qui est en fait, après résolution du paramètre par défaut : map<T*,U,less<T*>>).

1. Vérifier si on n’est pas en train d’effectuer une auto-affectation n’a aucun sens dans le cas d’un constructeur : l’objet « otherª ne peut pas être égal à l’objet qu’on est en train de construire, pour la bonne raison que ce dernier n’existe pas encore ! Un de mes amis, Jim Hyslop, m’a fait remarquer que l’exemple suivant (techniquement illégal) utilisant un opérateur new avec « placement » (adresse d’allocation forcée) donnerait un sens, s’il était légal, à ce test dans le constructeur :

T t ;

new(&t) T(t) ; // Donnerait un sens au test si c’est légal

Autre exemple dans lequel ce test serait utile : « T t = t ; », instruction acceptée par le compilateur mais qui provoquera immanquablement une erreur à l’exécution.

Comme vous le savez, la classe string de la bibliothèque standard C++ n’implé-mente pas de conversion automatique vers le type const char* mais fournit, à la place, une fonction membre c_str() renvoyant un const char* :

string s1("hello"), s2("world");

strcmp( s1, s2 ); // 1 (Erreur) strcmp( s1.c_str(), s2.c_str() ) // 2 (OK)

Dans cet exemple, le premier appel à strcmp provoque une erreur de compilation car il n’existe pas de conversion de string vers const char*. Le second appel se compile correctement, mais est plus lourd à écrire; on est dès lors tenté de déplorer que la syntaxe du premier appel soit interdite...

À votre avis, pour quelle(s) raison(s) la classe string n’implémente-t-elle pas de conversion vers const char ?

Il est toujours dangereux d’implémenter des conversions implicites – qu’il s’agisse d’opérateurs de conversion ou de constructeurs à un argument (sauf s’ils sont déclarés explicit)1. En effet :

Les conversions implicites peuvent être à l’origine de surprises lors de la résolu-tion de noms.

Les conversions implicites peuvent rendre possible la compilation d’un code pour-tant « incorrect ».

Si la classe string implémentait une conversion automatique vers const char*, cette conversion risquerait d’être appelée de manière implicite par le compilateur sans que l’utilisateur ne s’en rende systématiquement compte, pouvant ainsi causer ainsi toutes sortes de problèmes parfois difficiles à repérer, car ne provoquant en général pas d’erreurs de compilation. De nombreux exemples pourraient être cités, en voici un :

string s1, s2, s3;

s1 = s2 - s3; // Faute de frappe... ‘-’ au lieu de ‘+’

P

B N

° 39. C

ONVERSIONS AUTOMATIQUES

D

IFFICULTÉ

: 4

Les conversions automatiques d’un type à un autre sont extrêmement pratiques. Elles peuvent également être extrêmement dangereuses, comme nous allons le voir dans ce problème.

S

OLUTION

1. Nous n’abordons ici que les problèmes courants liés aux conversions implicites. Il y a d’autres raisons qui justifient l’absence de conversion de stringYHUVconst char*. À ce sujet, voir Koenig A. and Moo B. Ruminations on C++ (Addison Wesley Longman, 1997), pages 290 à 292 ; Stroustrup Bjarne The Design and Evolution of C++ (Addiso Wesley, 1994), page 83.

Si la classe string implémentait une conversion automatique vers const char, le code ci-dessus se compilerait correctement mais, à l’exécution, s2 et s3 seraient convertis vers des const char* et la soustraction des deux pointeurs résultants serait affectée à s1 !

Examinez le code suivant :

void f() {

T t(1);

T& rt = t;

//--- #1: on manipule t et rt --- t.~T();

new (&t) T(2);

//--- #2 : on manipule t et rt --- } // t est détruit automatiquement

Le bloc de code #2 va-t-il s’exécuter correctement ?

Le code proposé est légal et conforme à la norme C++ ; néanmoins, la fonction dans son ensemble n’est pas saine – elle peut poser des problèmes en présence d’exceptions – et utilise un style de programmation qu’il vaut mieux éviter.

Nous opérons ici une destruction explicite suivi d’une allocation « placée » (allo-cation à une adresse mémoire déterminée) : la norme C++ spécifie clairement qu’une référence (en l’occurrence rt) ne doit pas être altérée par une opération de ce type (bien entendu, on fait ici l’hypothèse que l’opérateur & n’a pas été redéfini pour la classe T et renvoie bien l’adresse de l’objet).

En revanche, la fonction f() peut se comporter de manière incorrecte en présence d’exceptions : en effet, si une exception se produit lors de la « reconstruction » de t

new (&t) T(2) »), alors la fonction va se terminer et un appel au destructeur T::~T

se produira, ce qui posera problème car la zone mémoire de t ne contiendra alors aucun objet construit. Autrement dit, en présence d’exception, t risque d’être construit Recommandation

Évitez d’implémenter des opérateurs de conversion. Déclarez les constructeurs ‘explicit

P

B N

° 40. D

URÉE DE VIE DES OBJETS

(1

re PARTIE

) D

IFFICULTÉ

: 5

Allocation, construction, destruction, désallocation... À quel moment un objet est-il vrai-ment utilisable ?

S

OLUTION

une fois mais détruit deux fois, ce qui provoquera à coup sûr une erreur à l’exécution.

Indépendamment de ces questions relatives aux exceptions, il n’est pas recom-mandé de prendre l’habitude d’avoir recours à cette technique de destruction explicite / allocation placée. Si elle n’est pas dangereuse utilisée depuis du code client de T, elle peut en revanche poser problème depuis certaines fonctions membres :

// Avez-vous vu le danger ? //

void T::DestroyAndReconstruct( int i ) {

this->~T();

new (this) T(i);

}

Ce code est dangereux ! Pour preuve, examinez le code suivant :

class U : public T { /* ... */ };

void f() {

/*AAA*/ t(1);

/*BBB*/& rt = t;

//--- #1: on manipule t et rt --- t.DestroyAndReconstruct(2);

//--- #2: on manipule t et rt --- } // t est détruit automatiquement

Si « /*AAA*/ » vaut « T », le code du bloc #2 s’exécutera correctement, que « /

*BBB*/ » soit « T » ou une classe de base de « T ».

En revanche, si « /*AAA*/ » vaut « U », il se produira à coup sûr une erreur à l’exé-cution, et ce, quelle que soit la valeur de « /*BBB*/ » : en effet, l’appel à

DestroyAndReconstruct()remplacera l’objet t de type U par un objet plus petit, de type T. La bonne exécution de la suite dépend de la partie de U utilisée par le code du bloc #2 : si ce code appelle des fonctions implémentées dans U et pas dans T, une erreur se produira.

En conclusion, même si elle fonctionne dans certains cas, cette technique dange-reuse n’est pas recommandable.

Recommandation

Assurez-vous que votre code se comporte correctement en présence d’exceptions. En par-ticulier, organisez votre code de manière à désallouer correctement les objets et à laisser les don-nées dans un état cohérent, même en présence d’exceptions.

Recommandation

Ne poussez pas le langage dans ses derniers retranchements. Les techniques les plus sim-ples sont souvent les meilleures.

Examinez le code suivant :

T& T::operator=( const T& other ) {

if( this != &other ) {

this->~T();

new (this) T(other);

}

return *this;

}

1. Quel est l’intérêt d’implémenter l’opérateur d’affectation de cette manière ? Ce code présente-t-il des défauts ?

2. En faisant abstraction des défauts de codage, peut-on considérer que la technique utilisée ici est sans danger ? Sinon, quelle(s) autre(s) technique(s) pourrai(en)t per-mettre d’obtenir un résultat équivalent ?

Note : Voir aussi le problème n° 40.

La technique de destruction explicite / réallocation placée utilisée dans cet exem-ple est parfaitement légale et conforme à la norme C++ (où elle est même citée en exemple, voir plus loin la discussion sur ce sujet). Néanmoins, elle peut être à l’ori-gine d’un grand nombre de problèmes, comme nous allons le montrer ici1. D’une manière générale, il est préférable de ne pas utiliser cette technique de programma-tion, d’autant plus qu’il existe un moyen plus sûr d’aboutir au même résultat.

L’intérêt majeur de cette technique est le fait que l’opérateur d’affectation est implémenté en fonction du constructeur de copie, assurant ainsi une identité de com-portement entre ces deux fonctions. Ceci permet de ne pas à avoir répéter inutilement deux fois le même code et élimine les risques de désynchronisation lors des évolutions de la classe – par exemple, plus de risque d’oublier de mettre à jour une des deux fonctions lorsqu’on ajoute une variable membre à la classe.

P

B N

° 41. D

URÉE DE VIE DES OBJETS

(2

e PARTIE

) D

IFFICULTÉ

: 6

Ce problème étudie plus en détail la technique de destruction explicite / réallocation placée déjà entrevue au cours du problème précédent. Parfois utile, cette pratique présente néanmoins des dangers certains.

S

OLUTION

1. Nous ne traiterons pas ici le cas trivial de la redéfinition de l’opérateur & (il est évident que cette technique ne fonctionne pas si l’opérateur & renvoie autre chose que

« thisª ; voir le problème n° 38 à ce sujet).

Cette technique s’avère également utile – voire indispensable – dans un cas parti-culier : si la classe T a une classe de base virtuelle comportant des variables membres, l’opération de destruction / réallocation assure que ces variables membres seront cor-rectement copiées1. Néanmoins, ce n’est qu’un maigre argument car, d’une part une classe de base virtuelle ne devrait pas contenir de variables membres (voir, à ce sujet, Meyers98, Meyers99 et Barton94) et d’autre part le fait que T comporte une classe de base virtuelle indique très probablement qu’elle est elle-même destinée à servir de classe de base, ce qui va poser problème (comme nous allons le voir dans la section suivante).

Si l’utilisation de cette technique présente quelques rares avantages, elle induit malheureusement beaucoup de problèmes, que nous allons détailler maintenant.

Dans le document Mieux programmer c++ (Page 184-189)