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_cast, dynamic_cast, const_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 deconst_cast
reinterpret_cast
reinterpret_cast
suivi deconst_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
- Tenter de convertir une struct en une autre, en C (onlinegdb.com)
- Conversion de pointeurs en C (onlinegdb.com)
- Conversion de const en C (onlinegdb.com)
Notes
- Calembour volontaire.
- Si vous connaissez une situation où c’est effectivement utile, n’hésitez pas à le partager en commentaires.
- 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. - Je n’expliquerai pas en détail pourquoi ça affiche
0
au lieu de1515
pour la valeur detutu
: sachez juste que comme on réinterprète les données en mémoire, tenter de lire unFoo
comme si c’était unBar
ne fonctionne pas bien. - 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.