Opérateurs de conversion C++ ISO

Quelques raccourcis :

Une conversion explicite de types, ou un transtypage (en anglais : un Cast ou un Typecast, selon la tradition) est un mal nécessaire. Le genre de chose dont nous souhaiterions tous nous passer, qui brise la logique d'un programme et tend à le fragiliser, et qu'on voudrait, dans un monde idéal, en venir à faire disparaître complètement de nos programmes.

Cette conception est un souhait, un idéal, mais ne doit pas être prise en un sens dogmatique. Que ce soit pour des raisons pragmatiques (adaptation à une bibliothèque dont la philosophie diffère de la vôtre), techniques (manipulations de très bas niveau, près du matériel) ou conceptuelles (résoudre par programmation une ambiguité résultant d'un design complexe et d'un requis spécial), il arrive que des conversions explicites de types se glissent dans le portrait.

Notre capacité de faire (éventuellement) disparaître les conversions explicites de types de nos programmes dépend de notre capacité de les y repérer une fois celles-ci introduites. Notre capacité de nous assurer que la conversion appliquée fasse exactement ce que nous souhaitons qu'elle fasse dépend de la précision avec laquelle nous pourrons exprimer nos intentions.

Les conversions explicites de types ISO s'inscrivent dans cette optique :

Ce que fait un transtypage (une conversion explicite de types)?

Règle générale, la conversion n'est pas vraiment une conversion, au sens où l'objet converti ne change pas. Un conversion explicite de types peut en fait représenter plusieurs opérations distinctes, selon le contexte et les types impliqués :

Il est important, surtout avec un outil aussi riche et expressif que C++, de pouvoir énoncer clairement laquelle des options doit être choisie. Dans les cas de hiérarchies complexes d'objets, ne pas pouvoir dicter correctement son intention à un compilateur résulterait en des programmes dangereux ou carrément inopérants.

La tradition C (adoptée par Java et C#)

La conversion explicite de types du langage C est une conversion brutale et incontrôlable. C'est une prise de responsabilité de l'informaticien(ne) face à une action contre nature (en fait, foncièrement dénaturante) sur un objet.

La conversion traditionnelle prend la forme du type de destination (donc du type dans lequel une expression sera convertie) placé entre parenthèses et précédant l'expression à convertir. L'exemple ci-dessous montre comment il est possible, selon la tradition, de signaler à un compilateur qu'il doit traiter source comme étant du type de dest.

Dest dest;
Source source;
dest = (Dest) source;// conversion explicite de source au type de dest

C++ admet aussi une notation rejoignant celle des constructeurs pour ces conversions. L'invocation d'un constructeur pour créer une variable temporaire est raisonnable; le problème est que ce n'est peut-être pas là l'intention de la programmeuse ou du programmeur (par exemple, si le programme cherche à traiter un void* en int* dans une fonction du système d'exploitation, l'idée n'est pas tant de créer une variable temporaire que de traiter une adresse typée comme une adresse d'un autre type). La syntaxe ressemble à celle utilisée avec Modula.

Dest dest;
Source source;
dest = Dest(source); // conversion explicite de source au type de dest avec signature de constructeur

Un problème?

Particulièrement en POO, il est complexe – pour le compilateur – de comprendre ce que veut faire la programmeuse ou le programmeur, surtout lorsque l'expression de conversion explicite de types est prise hors contexte.

Prenez l'exemple à droite (tiré de Design & Evolution of C++, p. 325). Que veut-on faire exactement, ici? Utiliser px sans le présumer const (pour appeler une de ses méthode non const)? Accéder directement aux membres d'un ancêtre de px? Les deux?

const X* px = new X;
// ...
pv = (Y*) px; // piache!

Les opérateurs de conversion explicite de type ISO

Le langage C++ a introduit, depuis 1998 environ (arrivée du standard ISO), quatre nouveaux opérateurs de conversion explicite de types.

Ces opérateurs sont listés dans la table ci-dessous. Dans chaque cas, la notation requise pour les utiliser est op<T>(e) op est l'opérateur de conversion de type, T est le type dans lequel convertir (donc le type de destination) et e est l'expression devant être convertie (la source).

Tout transtypage est un mensonge que fait le programmeur au système de types, et doit être réalisé avec prudence. Comme pour tous les mensonges, il y en a qui sont bénins – ce que les anglais nomment les Little White Lies – et il y en a qui sont vilains.

Opérateur Rôle (en bref)
static_cast<T>(e)

Voir cette section pour des détails. Fait au moment de la compilation, ou du moins aussi tôt que possible (car pour une conversion d'un nombre entier à un nombre à virgule flottante, il faut quand même travailler un peu à l'exécution), et relativement sécuritaire. Par défaut, c'est probablement celui que vous voudrez privilégier.

On l'utilise :

  • Pour clarifier des conversions autrement implicites
  • Quand une conversion est naturelle (float vers int, Enfant vers Parent)
  • Pour lever une ambiguïté. Par exemple, un enfant a deux parents, qui ont chacun un parent du même type, et on veut traiter l'enfant comme son ancêtre, donc comme l'un de ses grands-parents
  • Quand une conversion parent à enfant, normalement risquée, est telle que le programmeur sait qu'elle est correcte (et assume l'erreur s'il y a lieu). Prudence!
dynamic_cast<T>(e)

Voir cette section pour des détails. Fait au moment de l'exécution, sécuritaire, mais demande une certaine prudence méthodologique. Les abus mènent à des techniques antagonistes à l'approche OO.

On l'utilise pour des conversions entre pointeurs ou références quand les types source et destination de la conversion sont apparentés, donc qu'ils ont en commun un parent, un enfant, une soeur, etc. et quand la classe source a au moins une méthode polymorphique. Le coût de cette conversion est indéterministe a priori, donc elle est en général proscrite dans un système temps réel (quoique...) mais elle est préférable à une vérification « manuelle » qui chercherait à faire la même chose.

reinterpret_cast<T>(e)

Voir cette section pour des détails. Produit une valeur temporaire, qui devrait être reconvertie pour être utilisée proprement. Malsain, mais c'est parfois ce dont on a besoin. N'a de sens que sur les pointeurs, et ses résultats ne sont pas portables.

On l'utilise quand on manipule des abstractions de très bas niveau, souvent quand on utilise des services du système d'exploitation ou quand on travaille près du matériel. Il importe de manipuler cette conversion avec prudence.

const_cast<T>(e)

Voir cette section pour des détails. Se débarrasse ou ajoute temporairement d'une qualification const ou volatile.

On l'utilise souvent quand on interopère avec un outil qui n'est (tristement) pas const-correct, mais aussi quand on sait des choses que le compilateur ne sait pas. La prudence est de mise.

La syntaxe « étrange » : pourquoi?

En principe, une conversion explicite de types n'est jamais souhaitable. L'intention d'une programmeurs ou d'un programmeur devrait être, dans la mesure du possible, en ressortir à des solutions plus naturelles.

L'emploi de la notation traditionnelle de C pour exprimer la conversion de e au type T (et qu'ont adopté Java et C#), qui est (T)e (ou T(e)), complique la recherche dans le code des occurrences de conversions explicites de types – avec cette syntaxe, il est très ardu de chercher une conversion explicite de type à l'aide d'un éditeur de texte sans se frapper sans arrêt à des appels de sous-programmes ou à des opérations d'affectation de priorité sur des opérations usuelles).

L'emploi de noms d'opérateurs plus longs et plus explicites (des noms réservés!) facilite la recherche et l'identification des opérations de conversion explicite de type dans le code. Le dépistage pour fins de débogage ou de nettoyage du code en devient une tâche beaucoup plus raisonnable.

L'emploi de la syntaxe des templates, elle, vise à décourager l'usage de conversions explicites de types dans le code, là où elles pourraient être évitées. C'est une syntaxe volontairement lourde et légèrement pénible à utiliser, qui rend évident le tour de passe-passe qu'est, en fait, la conversion.

Il est même possible, en C++, de déterminer de nouvelles opérations de conversion explicite de type en utilisant cette syntaxe, étendant ainsi le domaine du possible. Pour un exemple parmi plusieurs (un peu sophistiqué, mais bon), voir ceci.

L'opérateur static_cast

L'opérateur static_cast permet des conversions qui seraient légales à la compilation (p. ex. : de int à float). Pour ces cas, utiliser un opérateur de conversion explicite de types est redondant.

Cet opérateur permet aussi des conversions du type Base*←(Dérivée*)e qui sont implicitement sécuritaires mais peuvent être nécessaires en situation d'héritage multiple.

class B { /* ... */ };
class D0 : public B { /* ... */ };
class D1 : public B { /* ... */ };
class D : public D0, public D1 { /* ... */ };

void f(D *pD) {
   // B *pB = pD; // illégal car ambigü: il y a deux B dans un D!
   B* pB = static_cast<D1*>(pD); // Ok, ambiguïté levée (aussi Ok si on passe par un D0*)
}

L'opérateur static_cast permet aussi des conversions assez sécuritaires du type Dérivée*←(Base*)e pouvant être validées au moment de la compilation.

Exemple (Design & Evolution of C++, p. 329)
class B { /* ... */ };
class D : public B { /* ... */ };
void f (B* pB, D* pD) {
   D* pD2 = static_cast<D*>(pB); // peut être risqué, selon le contexte. Était « (D*)pB »
   B* pB2 = static_cast<B*>(pD); // sécuritaire, implicite
}

Pour certaines conversions malsaines entre types non apparentés, l'opérateur static_cast sera en mesure de générer une erreur de compilation, permettant de dépister plus d'erreurs à la compilation (ce qui est toujours préférable à du débogage une fois le produit livré au client).

Une conversion par l'opérateur static_cast génère du code très efficace en terme de temps d'exécution car elle est réalisée statiquement, donc à la compilation. Cela dit, il est possible de lui jouer des tours.

Exemple (Design & Evolution of C++, p. 330)
class B { /* ... */ };
class D : public B { };
void f (B* pb) {
   D* pd1 = static_cast<D*>(pb); // OK si pb est un D*, fort dangereux sinon
}

En bref

Un static_cast, lorsque possible, est la meilleure option. Le résultat peut être obtenu à la compilation, et est typiquement soit un changement de perspective pour le compilateur, soit une addition d’une valeur constante sur une adresse.

L'opérateur dynamic_cast

L'un des objectifs de base de C++ est de maximiser le travail faisable (et effectivement fait) à la compilation pour qu'il reste un minimum de travail à faire lorsque le programme s'exécutera.

Sert à produire des conversions sécuritaires du type Dérivée*←(Base*)e et pouvant être validées au moment de l'exécution, donc qui sont plus sécuritaires (mais plus lentes, à cause des vérifications qui doivent être faites) que celles réalisées avec static_cast.

L'expression dest = dynamic_cast<Dest>(source); doit se lire comme suit: si source peut être considéré comme un Dest, alors déposer ce Dest dans dest, sinon retourner 0 s'il s'agit d'une tentative de conversion de pointeurs ou lever une exception s'il s'agit plutôt d'une tentative de conversion de références.

Il est à noter qu'on ne vérifie pas ainsi si source est un Dest, mais bien si source est au moins un Dest. Malgré cette restriction, il est clair que dynamic_cast est un bris d'encapsulation (tout comme l'est static_cast lorsqu'on l'applique sur des objets) et devrait être utilisé avec parcimonie.

Restriction technique importante:  dynamic_cast repose sur de l'information supplémentaire sur les classes (le Run Time Type Information, ou RTTI), information que le compilateur peut générer sur demande mais ne générera pas par défaut du fait qu'elle grossit un peu les programmes.

Cette information, lorsqu'elle est incluse dans un programme, est logée dans la table de méthodes virtuelles (la vtbl) des classes. Cela signifie que l'opérateur dynamic_cast ne peut être appliqué que sur des types pour lesquels on trouve au moins une méthode virtuelle.

Exemple (Design & Evolution of C++, p. 330)
class B { /* ... */ };
class D : public B { };
void f(B* pb) {
   D* pd1 = static_cast<D*>(pb);  // (0)
   D* pd2 = dynamic_cast<D*>(pb); // (1)
}

Si vous passez l'adresse d'un dérivé de B qui n'est pas un D à la procédure f(), le static_cast l 'acceptera (boum!) alors que le dynamic_cast, qui vérifiera à l'exécution si pb est vraiment un D*, retournera 0 (au sens de pointeur nul).

Il faut donc valider, une fois un dynamic_cast appliqué à un pointeur, si le pointeur résultant de cette opération de conversion diffère bel et bien de 0.

L'opérateur dynamic_cast permet aussi de convertir des références apparentées, en levant une exception de type std::bad_cast (voir le fichier d'en-tête <typeinfo>) si la conversion est illégale.

Notez que dynamic_cast permet, en situation d'héritage multiple, de réaliser des conversions complexes entre classes soeurs ou cousines.

class B { /* ... */ };
class D0 : public B { /* ... */ };
class D1 : public B { /* ... */ };
class D : public D0, public D1 { /* ... */ };
int main() {
   D0 *pD0 = new D; // légal: un D est un D0 sans ambiguité
   D1 *pD1 = dynamic_cast<D1*>(pD0); // légal: pD0 pointe vers un D qui est
                                     // aussi un D1... dynamic_cast<> navigue
                                     // la hiérarchie du haut vers le bas puis
                                     // du bas vers le haut!
   // ...
}

Notez que dynamic_cast respecte les qualifications d'accès. On ne peut transtyper d'un type S vers un type D si la relation entre ces types n'est pas accessible a priori. Par exemple, dans le cas suivant...

class X {
   // ...
public:
   virtual ~X() = default;
};
class Y {
   // ...
};
class D0 : public X, protected Y {
   // ...
};
class D1 : public X, public Y {
   // ...
};
void f(X *p) {
   Y *q = dynamic_cast<Y*>(p);
   // ...
}
int main() {
   D0 d0;
   D1 d1;
   f(&d0);
   f(&d1);
}

... la raison pour laquelle le transtypage d'un X* en un Y* par f(), une fonction globale, échouera dans le cas où p pointe en fait un D0, est que pour un D0, le parent Y est qualifié protected, donc cette relation n'est connue que de D0, des enfants de D0 et des amis de D0. À moins que f() ne soit friend dans D0, f() n'a pas accès à ce savoir et le transtypage échouera, dans le respect des qualificatifs d'accès.

Par contre, dans le cas où p pointe vers un D1, la conversion sera un succès.

Enfin, s'il y a ambiguïté dans une conversion par voie de dynamic_cast, donc si plus d'un résultat s'avère possible, le transtypage échouera. Par exemple :

class B {
   // ...
public:
   virtual ~B() = default;
};
struct D0 : B {};
struct D1 : B {};
struct D : D0, D1 {};
int main() {
   D d;
   try {
      B & b = dynamic_cast<B &>(d); // oups, deux possibilités!
   } catch(...) {
      // on passera ici
   }
}

Bien qu'il ne soit en général pas possible de réaliser un dynamic_cast de manière suffisamment prévisible pour s'en servir dans un système temps réel, des recherches montrent qu'il serait possible d'y arriver. Pour en savoir plus, voir http://www.stroustrup.com/fast_dynamic_casting.pdf.

Sémantique d'un dynamic_cast

Quel sens devrait-on donner à un dynamic_cast? Tout dépend s'il est appliqué sur un pointeur ou sur une référence.

Un dynamic_cast sur un pointeur est une question :

  • est-ce que le pointeur source, celui sur lequel s'applique le transtypage, est en fait du type destination, celui vers lequel s'applique le transtypage?

Le caractère vérifiable explicitement du fruit du transtypage illustre cette réalité. Si ce transtypage est une question, alors il faut en valider la réponse avant de procéder, et avoir un plan « B » dans le cas où la réponse est négative.

class X {
   // ...
public:
   virtual ~X() = default;
};
class Y : public X {
   // ...
};
void f(X *p) {
   Y *q = dynamic_cast<Y*>(p);
   if (q) {
      // utiliser q
   } else {
      // bon, *p n'était pas un Y
   }
}

Un dynamic_cast sur une référence est une affirmation :

  • j'affirme que la référence source, celle sur laquelle s'applique le transtypage, est en fait du type destination, celle vers laquelle s'applique le transtypage

Dans ce cas, le code doit être écrit pour tenir compte du cas où l'affirmation serait erronée. Le cas « normal », dans le bloc try, est celui où la programmeuse ou le programmeur a vu juste, mais il existe un cas « atypique », représenté par le bloc catch, où elle/ il était dans l'erreur.

class X {
   // ...
public:
   virtual ~X() = default;
};
class Y : public X {
   // ...
};
void f(X &p) {
   try {
      Y &q = dynamic_cast<Y&>(p);
      // utiliser q
   } catch(bad_cast&) {
      // bon, p n'était pas un Y
   }
}

Il demeure qu'en pratique, un dynamic_cast dénote habituellement une faute de design. Cherchez à retravailler la structure de vos classes de manière à ne pas avoir à provoquer de tels bris d'encapsulation.

Notez qu'il est possible de restreindre la portée du pointeur obtenu par un dynamic_cast à l'alternative qui s’en servira en le déclarant à même l'alternative. On peut remplacer ceci :

// ...
Y *q = dynamic_cast(p);
if (q) {
   // utiliser q
}
// ... ici, q existe encore, mais est-ce pertinent?

...on peut remplacer ceci :

// ...
if (Y *q = dynamic_cast(p), q) {
   // utiliser q
}
// ... ici, q n'existe plus...

Ceci évite parfois de laisser traîner des variables dont la durée de vie utile est limitée à une zone clairement définie du programme.

En bref

Un dynamic_cast intervient lorsque le programme tente d’utiliser un objet polymorphique d’une manière brisant l’encapsulation; en théorie, cela ne devrait jamais se produire, mais en pratique, de tels cas existent.

L'opérateur reinterpret_cast

Sert à produire des conversions de pointeurs (et seulement de pointeurs) un peu moins gentilles et sans la moindre intelligence comme celles d'une donnée du type char* vers le type int* ou entre deux pointeurs de classes qui ne sont pas reliées par une relation de hiérarchie. Ce sont là des conversions qui sont intrinsèquement malsaines, mais parfois nécessaires.

Par conversion sans intelligence, on entend ici que le compilateur se ferme les yeux et croit aveuglément que la programmeuse ou le programmeur dit la vérité en exprimant que le pointeur source est du type du pointeur de destination. Le nom le dit : ce transtypage réinterprète les bits de l'entité à convertir, sans plus.

On utilise cet opérateur pour des détails très ponctuels et reliés à l'implantation locale. Les résultats ne sont jamais garantis comme étant portables. Le côté aveugle de cette conversion en fait le plus proche cousin des conversions classiques du langage C.

Le problème de fond ici, évidemment, est que l'ordre des bytes dans un entier varie selon les plateformes. En gros, tout usage de reinterpret_cast est éminemment non-portable.

//
// un unsigned int, c'est un peu comme un tableau de sizeof(int) bytes... non?
//
unsigned int conversion_imprudente(char (&arr)[4]) {
   return reinterpret_cast<unsigned int*>(arr+0);
}

L'avantage d'un reinterpret_cast sur son illustre ancêtre (T)e est qu'il est plus visible, et plus facile à isoler.

À vos risques et périls. Sachez reconnaître les moments où cette conversion est nécessaire, et sachez surtout l'éviter le plus possible...

Et void*?

La conversion vers un void* et à partir d'un void* est mieux réalisée par un static_cast que par un reinterpret_cast. En effet, tout pointeur est en fait une adresse, donc il n'y a rien d'anormal à passer d'adresse brute à adresse typée (ou l'inverse).

En bref

Un reinterpret_cast signifie seulement « change ton point de vue sur le type à cette adresse ». Utilisez-le avec prudence et parcimonie.

L'opérateur const_cast

Sert à retirer temporairement – et visiblement – une qualification const ou volatile. Pour procéder à un const_cast, il faut que le type de destination soit précisément le type de la source à la qualification const (ou volatile) près.

Cet opérateur peut aussi ajouter la qualification const ou volatile mais est moins utilisé en ce sens du fait que cet ajout tend à être fait de manière implicite.

Exemple (Design & Evolution of C++, p. 332)
//
// supposons une fonction comme celle-ci. Le second paramètre, source, est sans danger mais
// n'est malheureusement pas qualifié « const », et on souhaite l'utiliser malgré tout
// car elle s'exécute très rapidement...
//
extern "C" char* strcpy (char*, char*);
//
// prenons soin de l'l'enrober proprement, cependant (question d'hygiène)
// 
inline const char* strcpy(char* dest, const char* source) {
   // appel à la fonction rapide, en cachette. Aussi vite que possible, sans blagues.
   return strcpy(dest, const_cast<char*>(source));
}

Pour comprendre la qualification extern, voir cet article. Ça permet, par exemple, de déclarer une instance d'une classe comme étant const, mais d'utiliser certains de ses membres (de façon ponctuelle, et au risque des programmeuses et des programmeurs, comme dans tout cas de conversion explicite de type) comme n'étant pas const.

Une panacée?

L'insertion de nouveaux opérateurs de conversion explicites de types ne résout pas tous les problèmes et ne blinde pas les programmes contre toute bêtise. C++ permet encore de faire des trucs pas très gentils et plutôt pervers, mais c'est une partie du prix à payer pour avoir un outil aussi polyvalent et puissant.

Exemple (Design & Evolution of C++, p. 333)
const char cc = 'a'; // constante de type char
const char* pcc = &cc; // légal
const char **ppcc = &pcc;  // légal
void* pv = ppcc; // légal; eh oui, on peut transtyper implicitement
                 // n'importe quel type de pointeur en void*... mais
                 // on vient de perdre notre const!
char **ppc = (char**)pv; // ouch!
void f() {
   **ppc = 'x'; // BOUM!
}

La prudence reste donc de mise.

En bref

Un const_cast signifie que les qualifications de sécurité en vigueur peuvent être changées sans risque. Le concept-même est préoccupant, mais l’opération est pragmatique. Visez à accompagner ces opérations de commentaires.

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !