Musée des horreurs – Conversions explicites de types

Quelques raccourcis :

Les conversions explicites de types (transtypages, casts), surtout si elles sont mal comprises, mènent à des horreurs parfois gênantes...

Entrée 00 – Conversion explicite de type sur un lvalue

Situation prise en exemple : nous devons rédiger une fonction prenant en paramètre un tableau de caractères à partir duquel certaines opérations doivent être réalisées. Pour réduire ces opérations à leur plus simple expression (simple pour notre démonstration, du moins), nous présumerons que notre fonction cherche à remplacer le contenu dudit tableau par une chaîne de caractères arbitraire (disons "Coucou").

Présentation de l'entrée 00 sous étude
#include <iostream>
void f (char *);
int main()
{
   using namespace std;
   char tab[] = "Allo";
   f(tab);
   cout << tab << endl;
}
void f(char *p)
{
   // à déterminer
}

Aller à l'élément i d'un tableau tab de type T à une dimension signifie additionner i fois la taille d'un T à l'adresse tab et de prendre les sizeof(T) bytes qui s'y trouvent.

C'est ce qui explique la convention C et C++ de démarrer les indices de tableaux à zéro, le premier élément étant placé à 0 bytes à partir du début du tableau.

Une horreur autre que celle dont nous voulons parler ici, mais qu'il faut mentionner : un tableau est, en C comme en C++, un pointeur sur son premier élément. Le tableau est une structure de données très simple et très efficace, pour laquelle accéder à un élément à l'aide de son index est aussi complexe qu'effectuer une addition.

Un tableau brut en C ou en C++ ne connaît rien de sa propre taille, et est trop primitif pour pouvoir gérer son propre dimensionnement ou valider tout débordement potentiel. Il ne contient rien d'autre que ses éléments; muni de l'adresse de départ du tableau, du type de ses éléments et de l'indice auquel on cherche à accéder, un compilateur C ou C++ peut aller à la bonne adresse sans savoir s'il est justifié d'y aller.

Un tableau en C ou en C++ n'est pas un objet, et ne possède pas de méthodes bien à lui. On ne peut lui affecter de valeurs à l'aide de l'opérateur =, et on ne peut comparer deux tableaux entre eux avec == ou !=, du moins pas avec plus d'intelligence que si on tentait de réaliser une simple comparaison de pointeurs.

Une horreur primitive, disons-le ainsi, serait donc de tenter de modifier le contenu du tableau en lui affectant une nouvelle valeur :

Horreur 00.0a
#include <iostream>
void f(char *);
int main()
{
   using namespace std;
   char tab[] = "Allo";
   f(tab);
   cout << tab << endl;
}
void f(char *p)
{
   p = "Coucou";
}

Ceci cherche à modifier un pointeur (Texte est un pointeur sur le premier élément du tableau tab tel que déclaré dans main()), et n'a rien à voir avec la gestion du contenu pointé. Ce code ne génère pas d'erreurs, mais est foncièrement incorrect en ce qu'il n'effectue pas du tout le travail attendu.

Le code de Horreur 00.0a est trompeur en ceci qu'il ne génèrera pas d'erreurs à la compilation. Il est légal, bien qu'inutile. On peut illustrer le problème avec plus de force en le prenant d'un point de vue académique et en se repliant en totalité sur main(), oubliant l'idée de fonction appelée :

Horreur 00.0b
#include <iostream>
void f(char *);
int main()
{
   using namespace std;
   char tab[] = "Allo";
   tab = "Coucou";
   cout << tab << endl;
}

Un lvalue est un nom qui peut être utilisé à gauche d'une affectation (le « l » initial de lvalue signifie left). Le cas type de nom qui ne peut être un lvalue est une constante.

Le concept de lvalue est particulièrement utile dans la rédaction de processeurs de langages comme le sont les compilateurs.

Présenté ainsi, un compilateur C ou C++ devrait refuser, carrément, de compiler le programme. En effet, un tableau est un pointeur non modifiable sur son premier élément. En jargon typique, on dira qu'un tableau C ou C++ n'est pas un lvalue.

Il faut comprendre ici la différence entre le tableau (un contenant, qui offre un pointeur non modifiable sur son premier élément) et ses éléments (du contenu, vers lesquels mène le tableau, et qui peuvent en général être modifiés).

Il est possible de modifier le contenu d'un tableau, un élément à la fois. On peut aussi être tenté de passer par des raccourcis, ce qui peut être une option raisonnable... si c'est bien fait.

L'horreur annoncée : l'horreur dont nous ferons mention ici est une technique bien connue de plusieurs programmeurs inexpérimentés. On pourrait nommer cette technique Technique de l'autruche, car elle consiste à chercher à faire disparaître les symptômes du problème (les messages d'erreur) plutôt que de comprendre et de chercher à éliminer les causes du problème.

Le programme suivant compile, et donne un résultat (pas celui escompté), et correspond à une application de la technique de l'autruche au cas présenté à l'entrée 00 :

Horreur 00.1a
#include <string>
#include <iostream>
int main ()
{
   using namespace std;
   char tab[] = "Allo";
   (string)tab = "Coucou";
   cout << tab << endl;
}

Essayez de prédire le résultat de l'exécution de ce programme s'il vous semble raisonnable. L'écriture utilisant une conversion explicite de types brute du langage C occulte un peu la problématique ici (utilisez des conversions explicites de types ISO autant que possible; elles sont plus claires et plus contraignantes, deux qualités lorsqu'on parle de conversions explicites de types). Une réécriture sous la forme suivante (donnant un résultat identique à l'exécution) clarifierait un peu le problème.

L'emploi d'une syntaxe d'appel de constructeur donne un indice fort sur la nature de l'opération qui sera réalisée ici par le compilateur: une instance de std::string sera construite à partir de tab (en prenant le const char* qu'est tab en paramètre), résultant en une std::string dont la valeur est une copie du contenu de tab, et l'opérateur = utilisé sera celui qui modifiera le texte contenu dans cette variable temporaire. Le tableau tab ne sera pas modifié le moins du monde!

À ceci près qu'on ne peut garantir la même portée à l'instance de std::string créée à partir de tab, l'exemple 00.1b ci-dessous donne à peu de choses près les mêmes résultats et devrait éclaircir encore plus le propos :

Horreur 00.1b
#include <string>
#include <iostream>
int main()
{
   using namespace std;
   char tab[] = "Allo";
   string temporaire{ tab };
   temporaire = "Coucou";
   cout << tab << endl;
}

On n'aurait pas été tenté de prétendre que 00.1b modifierait tab , n'est-ce pas? Et pourtant, 00.1a et 00.1b sont pratiquement (pour nos fins) identiques, au nom d'une variable temporaire près.

L'erreur derrière l'horreur : les conversions explicites de types servent à faire passer un nom comme étant d'un type autre que son type réel. Pour y arriver, surtout dans un langage orienté objet, il est fréquent qu'un compilateur ait recours à la génération de variables temporaires, utilisant entre autres pour ceci la mécanique de la construction et appelant les constructeurs appropriés à la conversion devant être réalisée.

Une règle simple vient immédiatement à notre secours: on ne devrait jamais utiliser une conversion explicite de types pour un lvalue. Dans une opération comprenant un lvalue, le lvalue en question est généralement le résultat désiré. C'est une donnée de son type qu'on désire obtenir; ce n'est donc pas son type qu'on cherche à déguiser!

Le bon usage : il n'y a pas de solutions simples ici. En particulier, le tableau original (tab) est trop petit pour contenir la chaîne "Coucou", qui comprend sept caractères (incluant le délimiteur '\0' à la fin) car il a été créé pour contenir cinq caractères seulement. La tentative est flouée à la base.

Règle générale, privilégiez l'emploi d'objets du début à la fin, en particulier des objets standards comme des instances de std::string qui sont à la fois très rapides d'utilisation et qui offrent des opérations pour la plupart sécuritaires.

Si vous n'avez pas accès à des objets, ou si vous devez osciller entre objets et tableaux bruts, assurez-vous d'avoir des tableaux de taille suffisante lorsqu'un d'entre eux doit servir de destination pour des affectations.

Enfin, la copie de données d'un tableau à l'autre doit être réalisée à l'aide d'une répétitive qui copie les éléments de l'un vers l'autre, un à un. Il n'y a pas de magie derrière la mécanique :

Entrée 00a – Usage en bonne et due forme
#include <cstring>
#include <cassert>
#include <iostream>
int main()
{
   using namespace std;
   const int CAPACITE_SUFFISANTE = 32; // arbitraire
   char tab[CAPACITE_SUFFISANTE] = "Allo";
   char *src = "Coucou";
   assert(strlen(src) < CAPACITE_SUFFISANTE);
   for (int i = 0; src[i]; i++)
      tab[i] = src[i];
   //
   // si on inclut <algorithm>, on peut aussi faire
   //    std::copy(src, src + strlen(src), tab);
   //
   cout << tab << endl;
}

La macro assert() valide une expression et fait en sorte de faire planter le programme si l'expression ne s'avère pas. Dans cet exemple, elle permet d'éviter un débordement de capacité dans tab et allège le code de la répétitive qui réalise la copie. La fonction std::strlen() retourne le nombre de caractères dans une chaîne ASCIIZ (délimitée à la fin par un zéro), délimiteur exclus.


Valid XHTML 1.0 Transitional

CSS Valide !