3 comportements intéressants à propos des conversions en C++

Article original : 3 interesting behaviors of C++ casts | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Cet article est une petite compilation1 de comportement étranges en C++ qui ne sont pas assez conséquents pour mériter un article à part entière.

Utiliser static_cast pour convertir un objet en lui-même peut appeler le constructeur de copie

Quand vous utilisez static_cast, par défaut (c’est-à-dire sans option d’optimisation) cela va appeler le constructeur de conversion de la classe dans laquelle vous essayez de convertir votre objet (si existant).

Par exemple, dans ce code:

class Foo;
class Bar;
 
int main()
{
    Bar bar;
    static_cast<Foo>(bar);
}

L’expression surlignée appellera le constructeur suivant (s’il existe) : Foo(const Bar&).

Jusque là, aucun problème, et il y a une bonne chance pour que vous sachiez déjà cela.

Mais savez-vous ce qui se passe si vous essayez de convertir un objet en lui-même ?

Prenons le code suivant :

struct Foo
{
    Foo(): vi(0), vf(0) {};
    Foo(const Foo & other): vi(other.vi), vf(other.vf) {};
    long vi;
    double vf;
};
 
int main()
{
    Foo foo1, foo2, foo3;
    foo2 = foo1;    
    foo3 = static_cast<Foo>(foo1);
 
    return 0;
}

Et étudions l’assembleur des lignes surlignées :

Ligne 12

mov     rax, QWORD PTR [rbp-32]
mov     rdx, QWORD PTR [rbp-24]
mov     QWORD PTR [rbp-48], rax
mov     QWORD PTR [rbp-40], rdx

Ligne 13

lea     rdx, [rbp-32]
lea     rax, [rbp-16]
mov     rsi, rdx
mov     rdi, rax
call    Foo::Foo(Foo const&) [complete object constructor]
mov     rax, QWORD PTR [rbp-16]
mov     rdx, QWORD PTR [rbp-8]
mov     QWORD PTR [rbp-64], rax
mov     QWORD PTR [rbp-56], rdx

On peut voir que lorsque l’objet foo1 est converti, ça appelle le constructeur de copie de Foo, comme si le constructeur de copie était un « constructeur de conversion du type dans lui-même ».

(Fait avec GCC 11.2 x86-64, Compiler Explorer (godbolt.org))

Bien sûr, ce comportement disparaît dès qu’on active les options d’optimisation.

C’est quelque chose de typiquement inutile à connaître2 et qu’on ne croise vraiment pas souvent dans la vraie vie (je l’ai déjà croisé une fois, mais c’était un malheureux accident).

static_cast peut appeler plusieurs constructeurs de conversion

Parlons de constructeurs de conversion, ils peuvent être transitifs lors de l’utilisation d’un static_cast.

Mettons les classes suivantes :

struct Foo
{  Foo() {};  };
 
struct Bar
{  Bar(const Foo & other) {};  };
 
struct FooBar
{  FooBar(const Bar & other) {};  };
 
struct BarFoo
{  BarFoo(const FooBar & other) {};  };

Nous avons quatre types : Foo, Bar, FooBar et BarFoo. Les constructeurs de conversion nous disent qu’on peut convertir un Foo en Bar, un Bar en FooBar et un FooBar en BarFoo.

Si on essaie d’exécuter le code suivant :

int main()
{
    Foo foo;
    BarFoo barfoo = foo;
    return 0;
}

Il y a une erreur de compilation à la ligne 4 : conversion from 'Foo' to non-scalar type 'BarFoo' requested.

Cependant, si on utilise un static_cast pour faire de foo un FooBar :

int main()
{
    Foo foo;
    BarFoo barfoo = static_cast<FooBar>(foo);
    return 0;
}

Le programme compile.

Si on regarde à l’assembleur qui est généré par la ligne 4 :

lea     rdx, [rbp-3]
lea     rax, [rbp-1]
mov     rsi, rdx
mov     rdi, rax
call    Bar::Bar(Foo const&) [complete object constructor]
lea     rdx, [rbp-1]
lea     rax, [rbp-2]
mov     rsi, rdx
mov     rdi, rax
call    FooBar::FooBar(Bar const&) [complete object constructor]
lea     rdx, [rbp-2]
lea     rax, [rbp-4]
mov     rsi, rdx
mov     rdi, rax
call    BarFoo::BarFoo(FooBar const&) [complete object constructor]

Il n’y a pas moins de 3 conversions générée dans une unique expression.

(Fait avec GCC 11.2 x86-64, Compiler Explorer (godbolt.org))

Un instant !

Vous pourriez vous demander pourquoi je n’ai pas directement appliqué le static_cast pour faire de foo un BarFoo, et que j’en ai seulement fait un FooBar.

Si on essaie de compiler le code suivant :

int main()
{
    Foo foo;
    BarFoo barfoo = static_cast<BarFoo>(foo);
    return 0;
}

On obtient une erreur de compilation :

<source>:16:44: error: no matching function for call to 'BarFoo::BarFoo(Foo&)'

En fait, static_cast n’est pas transitif

Voici ce qui se passe en réalité :

L’expression static_cast<FooBar>(foo) essaie d’appeler le constructeur suivant : FooBar(const Foo&). Or, il n’existe pas, le seul constructeur de conversion qui existe pour FooBar est FooBar(const Bar&). Mais comme Bar possède un constructeur de conversion depuis un Foo, le compilateur convertit implicitement foo en Bar pour pouvoir appeler FooBar(const Bar&).

Ensuite, on essaie d’assigner le FooBar résultant à un BarFoo. Ou, plus précisément, on construit un BarFoo à partir d’un FooBar, ce qui appelle le constructeur BarFoo(const FooBar&).

C’est pour cela qu’il y a une erreur de compilation quand on essaie de convertir directement un foo en BarFoo.

La réalité est que static_cast n’est pas transitif.

Que faire avec cette information ?

Les conversions implicites peuvent apparaître n’importe où. Puisque que static_cast est, selon la pragmatique3, un « appel de fonction » (dans le sens qu’il prend un argument et renvoie une valeur) il donne au compilateur l’opportunité de faire des conversions implicites.

Le comportement des conversions style-C

Ce qu’on appelle « conversions style-C » est l’usage de la syntaxe de conversion C, en C++.

Utiliser des conversions style-C est une mauvaise pratique répandue en C++. Ça aurait dû être un point de l’article Une liste de mauvaises pratiques couramment rencontrées dans le développement de logiciels industriels.

Beaucoup de développeurs C++ ne comprennent pas les comportements spécifiques que les conversions style-C effectuent.

Comment les conversions fonctionnent en C

Si mes souvenirs sont bons, les conversions en C s’utilisent de trois manières différentes.

Premièrement, elles peuvent servir à convertir un type scalaire en un autre

int toto = 42;
printf("%f\n", (double)toto);

Mais ça ne peut que convertir les scalaires. Si on essaye de convertir une struct :

#include <stdio.h>
 
typedef struct Foo
{
    int toto;
    long tata;
} Foo;
 
typedef struct Bar
{
    long toto;
    double tata;
} Bar;
 
 
int main()
{
    Foo foo;
    foo.toto = 42;
    foo.tata = 666;
     
    Bar bar = (Bar)foo;
     
    printf("%l %d", bar.toto, bar.tata);
 
    return 0;
}

On obtient un message d’erreur :

main.c:22:5: error: conversion to non-scalar type requested
   22 |     Bar bar = (Bar)foo;
      | 

(Source : GDB online Debugger | Code, Compile, Run, Debug online C, C++ (onlinegdb.com))

Deuxièmement, on peut utiliser une conversion en C pour réinterpréter un pointeur en un d’un autre type :

#include <stdio.h>
 
typedef struct Foo
{
    int toto;
    long tata;
    int tutu;
} Foo;
 
typedef struct Bar
{
    long toto;
    int tata;
    int tutu;
} Bar;
 
 
int main()
{
    Foo foo;
    foo.toto = 42;
    foo.tata = 666;
    foo.tutu = 1515;
     
    Bar* bar = (Bar*)&foo;
     
    printf("%ld %d %d", bar->toto, bar->tata, bar->tutu);
 
    return 0;
}

Ce code donne la sortie suivante4 :

42 666 0

(Source : GDB online Debugger | Code, Compile, Run, Debug online C, C++ (onlinegdb.com))

Et troisièmement, les conversions en C peuvent permettre d’ajouter ou d’enlever le qualificatif const :

#include <stdio.h>
 
int main()
{
    const int toto = 1;
    int * tata = (int*)(&toto);
    *tata = 42;
     
    printf("%d", toto);
 
    return 0;
}

Ce qui affiche 42.

(Source : GDB online Debugger | Code, Compile, Run, Debug online C, C++ (onlinegdb.com))

Cela marche aussi sur les struct.

On a fait le tour du fonctionnement des conversions en C5.

Comment cela se passe-t-il en C++ ?

Le C++ a ses propres opérateur de conversion (principalement static_castdynamic_castconst_cast, and reinterpret_cast, mais aussi un certain nombre d’autres conversions comme *_pointer_cast, etc.)

Mais le C++ a (originellement) été pensé pour être retro-compatible avec le C. Il fallait donc un moyen d’implémenter des conversions style-C pour qu’elles fonctionnent de manière similaire aux conversions en C, tout cela avec les nouvelles mécaniques de conversion.

Cela fait qu’en C++, quand vous faites une conversion style-C, le compilateur essaie les cinq conversions suivantes, dans l’ordre, s’arrêtant à la première qui fonctionne :

  • const_cast
  • static_cast
  • static_cast suivi de const_cast
  • reinterpret_cast
  • reinterpret_cast suivi de const_cast

Plus de détail sur la page suivante : Explicit type conversion – cppreference.com.

En quoi est-ce mauvais ?

La plupart des développeur C++ s’accordent sur le fait que l’usage des conversions style-C est une très mauvaise pratique. En voici les raisons : ce que le compilateur va faire n’est pas explicite. La conversion style-C va souvent compiler, même s’il y a une erreur, et taire cette erreur. Quand on veut convertir un objet, on veut effectuer spécifique une seule de ces cinq conversions, il est donc bien mieux de la mentionner explicitement plutôt que de laisser le compilateur la déduire. Ainsi, s’il y a un problème, il y a bien plus de chances que le compilateur le détecte et le signale à la compilation. Objectivement, il n’y a aucun avantage à utiliser une conversion style-C.

Voici un argumentaire plus fourni à l’encontre des conversions style-C : Coding Standards, C++ FAQ (isocpp.org).

Conclusion

Les conversions sont des opérations délicates. Elles peuvent être coûteuses (plus qu’on ne le pense parce qu’elles font de la place pour des conversions implicites) et encore aujourd’hui, il y a beaucoup de gens qui utilisent les conversions style-C sans savoir à quel point elles sont mauvaises.

C’est fastidieux, mais il faut apprendre comment les conversions fonctionnent et les spécificités de chacune.

Merci de votre attention et à la prochaine !

Article original : 3 interesting behaviors of C++ casts | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addenda

Liens vers les compilateurs en ligne

Utiliser static_cast pour convertir un objet en lui-même peut appeler le constructeur de copie

static_cast peut appeler plusieurs constructeurs de conversion

Le comportement des conversions style-C

Notes

  1. Calembour volontaire.
  2. Si vous connaissez une situation où c’est effectivement utile, n’hésitez pas à le partager en commentaires.
  3. En linguistique, la pragmatique est l’étude du contexte (complémentaire de la sémantique, l’étude du sens, et nombre d’autres champs d’étude). En terme de langage de programmation, on peut interpréter ça comme la manière pour une fonctionnalité d’interagir avec son entourage dans un contexte donné. Dans notre exemple, static_cast n’est pas un appel de fonction d’un point de vue sémantique, mais agit comme tel auprès de son environnement direct (comme c’est expliqué). La réalité technique est entre les deux : pour les POD ce n’est pas un appel de fonction, mais pour les classes qui définissent un constructeur de copie ça l’est.
  4. Je n’expliquerai pas en détail pourquoi ça affiche 0 au lieu de 1515 pour la valeur de tutu : sachez juste que comme on réinterprète les données en mémoire, tenter de lire un Foo comme si c’était un Bar ne fonctionne pas bien.
  5. Je ne suis pas aussi à l’aise en C qu’en C++. J’ai peut-être oublié un autre usage aux conversions en C. Si c’est le cas, corrigez-moi en commentaires.

Laisser un commentaire