a = b = c, une conséquence singulière de l’associativité d’opérateurs

Article original : a = b = c, a strange consequence of operator associativity | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Cas d’étude

Si vous codez en C++ régulièrement, vous avez sans doute déjà rencontré la syntaxe suivante :

class Foo;
Foo * make_Foo();
 
int main()
{
 
    Foo * my_foo;
    if (my_foo = make_Foo())
    {
        // ... On fait de trucs avec le pointeur my_foo
    }
 
    return 0;
}

D’un point de vue sémantique, c’est équivalent à la chose suivante :

class Foo;
Foo * make_Foo();
 
int main()
{
 
    Foo * my_foo = make_Foo();
    if (my_foo)
    {
        // ... On fait de trucs avec le pointeur my_foo
    }
 
    return 0;
}

C’est le sujet d’aujourd’hui : l’assignation au sein d’une expression.

Comment ça marche ?

Quelle est la valeur d’une telle expression ?

Pour le savoir, il suffit de lancer le code suivant :

int main()
{
    int foo = 2;
    std::cout << (foo = 3) << std::endl;
    return 0;
}

La sortie standard nous affiche 3.

On peut donc en déduire que l’expression d’assignation est évaluée comme la variable assignée, après qu’elle le soit1.

Une typo catastrophique

Mettons qu’on a trois variables, a, b et c. Nous voulons que la valeur de a soit true si et seulement si b et c sont égales.

Pour cela, on va écrire ceci :

bool a, b, c;
// ...
a = b == c;

Mais, on n’est pas très loin d’une petite typographie qui peut tout changer :

bool a, b, c;
// ...
a = b = c;

Ce code va compiler et ne vous donnera pas le résultat attendu. Pourquoi ?

L’expression a = b = c sont deux opérations d’assignation au sein d’une même expression. Selon la Table de Précédence des Opérateurs en C++, l’associativité de = est de droite à gauche. Donc l’expression a = b = c est équivalente à a = (b = c).

Puisque (b = c) est évaluée (comme vu précédemment) comme la variable b après assignation, écrire a = b = c; est équivalent à écrire b = c; a = b;.

Si ensuite vous utilisez a en tant que booléen, il sera évalué à true si et seulement si c vaut également true.

Conclusion à propos de a = b = c

Il peut y avoir des cas où cette syntaxe (celle avec les deux = en une seule expression) peut être utile, mais la plupart du temps je la trouve absconse et déroutante.

Aujourd’hui, il n’y a pas de manière efficace de prévenir la typographie (les parenthèses ne vous sauveront pas dans ce cas). Tout ce qu’on peut faire, c’est ouvrir les yeux et utiliser des constantes autant que possible (oui, si b est const, alors la typo est attrapée à la compilation)2.

L’opération d’assignation renvoie une lvalue

Reprenons le a = b = c de tout à l’heure et ajoutons des parenthèses autour de a = b :

int main()
{
    int a = 1, b = 2, c = 3;
 
    (a = b) = c;
 
    std::cout << a << b << c << std::endl;
    return 0;
}

Ça compile et affiche la sortie suivante : 323.

Cela signifie qu’on a assigné à a la valeur de b, puis la valeur de c. L’expression a = b est bien une lvalue.

void foo(int&);
 
int main()
{
    int a = 1, b = 2;
 
    foo(a = b); // Compile parce que `a = b` est une lvalue
    foo(3); // Ne compile pas parce que `3` est une rvalue
 
    return 0;
}

Plus spécifiquement, l’opération d’assignation renvoie une référence vers la variable concernée.

Opération d’assignation pour les types personnalisés

Vous aurez peut-être déjà remarqué que l’opérateur operator= peut, d’après le standard, renvoyer n’importe quel type (je vous renvoie à la section Canonical implementations de operator overloading – cppreference.com pour plus de détails3).

Vous pouvez bien entendu renvoyer une référence vers l’objet assigné :

struct Foo
{
    Foo& operator=(const Foo&) { return *this; }
};
 
int main()
{
    Foo a, b, c;
    a = b = c;
    return 0;
}

Vous pouvez aussi renvoyer une valeur plutôt qu’une référence :

struct Foo
{
    Foo operator=(const Foo&) { return *this; }
};
 
int main()
{
    Foo a, b, c;
    a = b = c; // Fonctionne aussi, mais fait une copie
    return 0;
}

Puisque le résultat est copié, l’assignation b = c devient une rvalue. Du coup, si vous essayez de prendre une référence de cette expression, vous avez une erreur de compilation :

struct Foo
{
    Foo operator=(const Foo& other) 
    { 
        val = other.val; 
        return *this; 
    }
    int val;
};
 
int main()
{
    Foo b = {1}, c = {2};
    Foo & a = b = c; // Ne compile pas parce qu'ici, (b = c) est une rvalue
    return 0;
}

Ce code compilerait si l’operator= renvoyait un Foo& plutôt qu’un Foo.

Vous pouvez également ne rien renvoyer du tout (en utilisant void comme valeur de retour). Dans ce cas, a = b = c ne compile plus du tout :

struct Foo
{
    void operator=(const Foo&) {  }
};
 
int main()
{
    Foo a, b, c;
    a = b = c; // Ne compile pas parce que (b = c) ne renvoie rien
    return 0;
}

Ça peut être une bonne manière de prévenir la syntaxe a = b = c4.

À propos des déclarations

Il y a des cas spécifiques où vous pouvez écrire des déclarations au sein d’une autre instruction (un peu comme les assignations qu’on a vu au début).

Vous pouvez utiliser cette syntaxe spécifique dans la plupart des instructions de contrôle de flux (comme if, while, switch et, bien entendu, for) et dans les appels de fonction.

Par exemple, le tout premier exemple de cet article peut également être écrit comme suit :

class Foo;
Foo * make_Foo();
 
int main()
{
 
    if (Foo * my_foo = make_Foo())
    {
        // ...  On fait de trucs avec le pointeur my_foo
    }
 
    return 0;
}

Cependant, la déclaration elle-même n’est ni une lvalue, ni une rvalue.

Vous ne pouvez pas écrire ceci :

int main()
{
    int a = 1, c = 3;
    a = (int b = c); // Ne compile pas
 
    return 0;
}

Ni cela :

int main()
{
    int b = 2, c = 3;
    (int a = b) = c; // Ne compile pas
 
    return 0;
}

Les instructions où il est possible de faire des déclarations sont indiquées comme de « init-statements » dans le standard, comme ceci :

Conclusion générale

Les syntaxes comme a = b = c et if (a = b) sont intentionnelles et bien définies dans le standard. Cependant, elles sont étrangères à beaucoup de développeurs et développeuses et sont si rarement utilisées qu’elles peuvent être déroutantes.

Des bugs peuvent survenir à cause du fait que le symbole = ressemble beaucoup au digramme ==, soyez donc attentif à ça. Si vous voulez l’éviter avec vos types personnalisés, vous pouvez déclarer l’operator= pour qu’il renvoie void et qu’ainsi la syntaxe a = b = c devienne invalide. Mais ce n’est pas possible avec les types fondamentaux, et c’est une contrainte pas forcément bienvenue.

Merci de votre attention et à la prochaine fois!

Article original : a = b = c, a strange consequence of operator associativity | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Addendum

Notes

  1. En réalité, c’est évalué comme une référence à la variable et pas comme la valeur de la variable. Ce sera démontré plus loin dans l’article.
  1. Il est possible d’activer des warnings spécifiques pour prévenir certains cas (par exemple, -Wparentheses peut être utilisé sous GCC pour éviter l’assignation au sein d’un contrôle de flux), mais ça ne couvre pas tous les cas (typiquement, a = b = c n’a pas de warning associé) et parfois vous ne voudrez pas les activer, en fonction de votre affinité avec cette syntaxe.
  1. Le site cppreference.com dit que « par exemple, les opérateurs d’assignation renvoie par référence pour rendre possible la syntaxe a = b = c = d, parce que les opérateurs fondamentaux le permettent. ». Cependant, je n’ai trouvé aucune mention de cette intention spécifique dans la quatrième édition de The C++ Programming Language de Bjarne Stroustrup. Je suspecte que c’est une interprétation libre.
  1. Vous pouvez, comme vous vous en serez peut-être douté, renvoyer n’importe quel type, si vous avez des besoins spécifiques. Le prototype int operator=(const Foo&); (membre de la classe Foo) est valide. Ça peut être pratique, si par exemple vous voulez renvoyer un code d’erreur.
  1. Il y a une différence en terme de pragmatique sur la durée de vie des variables (qui n’est pas le sujet d’aujourd’hui), car dans l’exemple, la variable my_foo ne vit que le temps du bloc if, alors que dans les exemples du début, elle ne vit à travers toute l’étendue du main. Mais puisque c’est techniquement la même chose dans cet exemple particulier (parce qu’il n’y a rien après le bloc if), je n’ai jugé nécessaire de m’étendre sur la question.

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.

Une raison de plus pour ne pas utiliser printf (ou écrire du code C en général)

Article original : Yet another reason to not use printf (or write C code in general) | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Cet article est né du tweet suivant, par Joe Groff @jckarter:

De manière assez évidente, le tweet est une blague, mais discutons un peu de ce qui se passe dans ce code.

Et donc, que se passe-t-il ?

Juste pour être 100% claire, l’expression double(2101253) ne calcule pas le double de la valeur 2101253, c’est une conversion (de style C) d’un entier vers un double.

En l’écrivant différemment, on obtient :

#include <cstdio>
 
int main() {
    printf("%d\n", 666);
    printf("%d\n", double(42));
}

En compilant sous x86_64 gcc 11.2, on a le résultat suivant :

666
4202506

On peut donc voir que la valeur 4202506 n’a rien à voir avec le 666 ou le 42.

D’ailleurs, si on lance le même code sous x86_64 clang 12.0.1, on obtient un résultat différent :

666
4202514

Vous pouvez voir les résultats exécutés ici : [https://godbolt.org/z/c6Me7a5ee].

Vous l’avez peut-être déjà deviné, mais cela vient de la ligne 5, où on affiche un double comme s’il s’agissait d’un int. Mais ce n’est pas à proprement parler une erreur de conversion (votre machine sait très bien convertir un flottant en entier, si ce n’était que ça il n’y aurait pas de soucis), mais d’un tout autre problème.

La vérité

Si on veut comprendre comment tout cela fonctionne, il faut se plonger dans le code assembleur correspondant au code (https://godbolt.org/z/5YKEdj73r) :

.LC0:
        .string "%d\n"
main:
        push    rbp
        mov     rbp, rsp
        mov     esi, 666
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 0
        call    printf
        mov     rax, QWORD PTR .LC1[rip]
        movq    xmm0, rax
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 1
        call    printf
        mov     eax, 0
        pop     rbp
        ret
.LC1:
        .long   0
        .long   1078263808

(allez-voir le Godbolt pour une correspondance plus visuelle : https://godbolt.org/z/5YKEdj73r).

En jaune dans le code assembleur (lignes 6 à 9, équivalent de printf("%d\n", 666);), on peut voir que tout va bien, la valeur 666 est positionnée dans le registre esi et ensuite la fonction printf est appelée. On peut donc confortablement supposer que quand la fonction ptintf lit un %d dans la chaîne qui lui est transmise, elle va afficher ce qu’elle a dans ce registre esi.

Or, quand on regarde le code en bleu (lignes 10 à 14, l’équivalent de printf("%d\n", double(42));), la valeur 42 est positionnée dans un autre registre, qui est xmm0 (du fait que c’est un double). Comme on passe à la fonction printf la même chaîne qu’avant, elle va regarder dans le même registre qu’avant (à savoir esi) et afficher quoique ce soit qui s’y trouve, d’où une valeur incohérente.

On peut prouver cela assez simplement :

\#include <cstdio>
 
int main() {
    printf("%d\n", 666);
    printf("%d %d\n", double(42), 24);
}

Il s’agit du même code qu’avant, sauf qu’on a ajouté l’affichage d’un autre entier dans le second printf.

Quand on regarde l’assembleur (https://godbolt.org/z/jjeca8qd7) :

.LC0:
        .string "%d %d\n"
main:
        push    rbp
        mov     rbp, rsp
        mov     esi, 666
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 0
        call    printf
        mov     rax, QWORD PTR .LC1[rip]
        mov     esi, 24
        movq    xmm0, rax
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 1
        call    printf
        mov     eax, 0
        pop     rbp
        ret
.LC1:
        .long   0
        .long   1078263808

Le double(42) est toujours positionné dans le registre xmm0 et l’entier 24, en toute logique, tombe dans le registre esi. À cause de cela, on obtient la sortie suivante :

666
24 0

Pourquoi ? Et bien comme printf comprend qu’il faut afficher deux entiers, elle va regarder dans le registre esi (et donc afficher 24) puis dans le registre d’entier suivant, (edx) et afficher ce qui s’y trouve (0, de manière fortuite).

Au final, ce comportement survient à cause de la manière dont l’architecture x86_64 est faite. Si vous voulez vous documenter à ce propos, voici deux liens :

Que dit la doc à ce sujet ?

Le point chaud de tout cela est, selon la référence (printf, fprintf, sprintf, snprintf, printf_s, fprintf_s, sprintf_s, snprintf_s – cppreference.com), le prédicat suivant :

If a conversion specification is invalid, the behavior is undefined.

Si une spécification de conversion est invalide, alors le ocmportement est indéfini.

Cette même référence est équivoque quant à la spécification %d :

converts a signed integer into decimal representation [-]dddd.
Precision specifies the minimum number of digits to appear. The default precision is 1.
If both the converted value and the precision are ​0​ the conversion results in no characters.

Convertit un entier signé en sa représentation décimale [-]dddd.
[…]

De fait, transmettre un double à un printf alors que, d’après la chaîne de formattage, il s’attend à un entier est un comportement indéfini. Ce comportement est donc de notre propre faute.

D’ailleurs, ce code déclenche toujours un warning sous clang. Sous gcc, il faut activer -Wall pour le voir.

En résumé

Le langage C est un très, très vieux langage. Il est plus vieux que le C++ (évidemment) qui est lui-même très vieux. Pour rappel, le K&R a été publié en 1978. C’était treize ans avant ma propre naissance. Et (contrairement à nous autres développeur·se·s), les langages de programmation vieillissent mal.

J’aurais pu résumer cet article par un bon vieux « N’écrivez pas de comportements indéfinis » mais je pense que c’est un peu à côté de la plaque dans cette situation. Du coup je vais le dire franchement : n’utilisez pas printf du tout.

Le problème n’est pas avec printf lui-même, c’est d’utiliser une fonctionnalité qui est issue d’un autre langage1 dont la publication originale est vieille de quarante-trois ans. En un mot : n’écrivez pas du code C en C++.

Merci de votre attention et à la semaine prochaine !

(Merci tout particulièrement à Guillaume Delacourt, qui nous a partagé le tweet qui a servi de base à cet article)

1. Oui, que ça vous plaise ou non, le C et le C++ sont bien deux langages disctincts. Ils sont distincts dans leurs intentions, leurs pratiques et leur méta. C’est pourquoi je refuses systématiquement les offres d’emploi pour des postes de type « C/C++ », parce que je ne travaille pas pour des gens qui ne savent pas quel langage ils utilisent.

Article original : Yet another reason to not use printf (or write C code in general) | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

À propos des tailles

Article original : About sizes | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Si j’étais venue ce matin avec une interro surprise sur les tailles des types fondamentaux en C++, beaucoup d’entre nous l’aurait sans doute échoué (moi y compris). La raison à cela : les tailles en C++ sont compliquées.

La taille de chaque type fondamental n’est pas fixée, elle dépend toujours de l’architecture (elle est « implementation-defined« ).

Pourtant, le standard impose des contraintes à ces tailles. Ces contraintes peuvent prendre deux formes différentes :

  • Une comparaison des sizeof des types
  • Le nombre minimum de bits qui composent un type

Qu’est-ce que sizeof() ?

Une des idées reçues les plus largement propagées (même si inoffensive) est celle selon laquelle un octet est composé de 8 bits.

Même si la plupart du temps c’est vrai en pratique, c’est techniquement faux.

Un byte (anglais de octet1) est en réalité défini par la taille d’un char. Bien qu’un char fasse toujours au moins 8 bits, il peut être plus grand. Dans tous les cas, on part de la définition qu’un byte est la taille d’un char.

En C++, toutes les tailles sont des multiples de la taille d’un char. Ainsi, la fonction sizeof(N) renvoie le nombre de bytes qui composent le type N.

De ce fait, si sizeof(int) vaut quatre dans une architecture donnée, cela veut dire qu’il vaut la taille de 4 char, donc au moins 32 bits. Il peut cependant être plus grand. Si un char mesure 32 bits, alors un int fera, dans ce contexte, 128 bits.

La véritable taille d’un byte est enregistrée dans la constante CHAR_BIT.

1. À partir de là et jusqu’à la fin de l’article, j’utiliserai le terme anglais byte à la place de l’équivalent français octet, car ce dernier est, de fait, étymologiquement inexact.

Résumé des tailles en C++

Voici l’intégralité des contraintes de taille sur les types fondamentaux en C++ :

  • 1 ≡ sizeof(char) ≤ sizeof(short) ≤ sizeof(int) ≤ sizeof(long) ≤ sizeof(long long)
  • 1 ≤ sizeof(bool) ≤ sizeof(long)
  • sizeof(char) ≤ sizeof(wchar_t) ≤ sizeof(long)
  • sizeof(float) ≤ sizeof(double) ≤ sizeof(long double)
  • sizeof(N) ≡ sizeof(unsigned N) ≡ sizeof(signed N)
  • Un char fait au moins 8 bits
  • Un short fait au moins 16 bits
  • Un long fait au moins 32 bits

… et c’est tout.

Fait amusant : selon cette définition, il est techniquement valide d’avoir une architecture où tous les types fondamentaux font 32 bits.

Deux mots de sagesse

Puisque les tailles des types fondamentaux dépendent entièrement de l’architecture, il peut être parfois complexe d’écrire du code fiable.

#include <limits>

L’include limits de la librairie standard contient les bornes supérieures et inférieures de tous les types fondamentaux. Il vous permet en plus de savoir si un type est signé ou pas.

Exemple :

#include <limits>
#include <iostream>
 
int main()
{
    std::cout << "largest double == " << std::numeric_limits<double>::max() << std::endl;
    std::cout << "char is signed == " << std::numeric_limits<char>::is_signed << std::endl;
}

Plus d’informations ici : std::numeric_limits – cppreference.com.

Rappel : l’overflow d’entier signé est un comportement indéfini. Utiliser les limites vous permettra d’éviter cela.

#include <cstdint>

Parfois on veut utiliser directement des types de taille définie. Quand on écrit une classe de sérialisation, quand on travaille sur des systèmes à mémoire très limitée ou quand on veut que le code soit compatible cross-plateforme, on veut pouvoir utiliser des types qui ont une longueur (en terme de bits) prédéfinie.

C’est possible avec la librairie cstdint qui contient des types de taille fixe.

En voici quelques-uns:

int8_t
int16_t
int32_t
int64_t
Signed integer type with width of exactly 8, 16, 32 and 64 bits respectively
with no padding bits and using 2’s complement for negative values
(provided only if the implementation directly supports the type)
int_least8_t
int_least16_t
int_least32_t
int_least64_t
Smallest signed integer type with width of at least 8, 16, 32 and 64 bits respectively
intmax_tMaximum-width signed integer type
uint8_t
uint16_t
uint32_t
uint64_t
Unsigned integer type with width of exactly 8, 16, 32 and 64 bits respectively
(provided only if the implementation directly supports the type)
uint_least8_t
uint_least16_t
uint_least32_t
uint_least64_t
smallest unsigned integer type with width of at least 8, 16, 32 and 64 bits respectively
uintmax_tmaximum-width unsigned integer type

Plus d’informations ici : Fixed width integer types (since C++11) – cppreference.com.

En conclusion

Si vous voulez en lire plus à propos des tailles de types, je vous renvoie à la section §6.2.8 de l’ouvrage The C++ Langage (de Bjarne Stroustrup). Plus largement, vous pouvez vous documenter à propos des types et déclaration dans toute la section §6 du livre.

Vous pouvez aussi aller voir Fundamental types – cppreference.com si vous préférez la documentation en ligne.

Merci de votre attention et à la semaine prochaine !

Article original : About sizes | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Lier une lambda à une référence constante

Article original : Yet another pamphlet about inlining | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Contexte

Cette semaine, laissez-moi vous présenter un morceau de code que j’ai pu voir il n’y a pas si longtemps :

#include <vector>
#include <algorithm>
 
int count_even(const std::vector<int>& v)
{
    const auto& my_func = [] (int i)->bool
    {
        return i%2==0; 
    };
 
    return std::count_if(std::cbegin(v), std::cend(v), my_func);
}

En voyant cela, VisualStudio n’était pas certain si cela devait compiler ou pas.

En fait, au cours de la compilation, on pouvait voir une erreur du style « my_func is used but already destroyed » apparaître et disparaître périodiquement, puis disparaître complètement à la fin de la compilation.

Quand j’ai vu tout cela, je me suis dit deux choses :

« Attendez, on peut lier une variable temporaire à une référence constante ? »

et

« Quel est l’intérêt de lier une lambda à une référence constante ? »

C’est à ces deux questions que nous allons répondre aujourd’hui.

Lier une rvalue à une référence constante

Pour la faire courte, oui, on peut lier une un rvalue à une référence constante.

Intuitivement, j’aurais au départ dit que faire cela ne résulterait qu’en une dangling reference, mais ce n’est pas le cas.

Mais en y repensant, c’est plutôt logique, car le langage est pensé de manière intelligente et lorsque vous liez une référence constante à un objet temporaire, l’objet sera mis sur la pile mémoire et vous pourrez toujours y accéder, tant que cette référence constante existera.

Pour illustrer cela, voici deux fonctions :

void foo()
{
    const int& i = 1;
    const int& j = i+1;
}
 
void bar()
{
    int iv = 1;
    const int& i = iv;
    int jv = i+1;
    const int& j = jv;
}

La fonction foo lie directement les rvalues à des références constantes, alors que bar les lient d’abord à des variables avant de lier ces variables (qui sont des lvalues) à des références constantes.

Si on regarde le code assembleur généré par ce code (avec clang 11.0 et -O3), on obtient :

foo():
        push    rbp
        mov     rbp, rsp
        mov     dword ptr [rbp - 12], 1
        lea     rax, [rbp - 12]
        mov     qword ptr [rbp - 8], rax
        mov     rax, qword ptr [rbp - 8]
        mov     eax, dword ptr [rax]
        add     eax, 1
        mov     dword ptr [rbp - 28], eax
        lea     rax, [rbp - 28]
        mov     qword ptr [rbp - 24], rax
        pop     rbp
        ret
bar():
        push    rbp
        mov     rbp, rsp
        mov     dword ptr [rbp - 4], 1
        lea     rax, [rbp - 4]
        mov     qword ptr [rbp - 16], rax
        mov     rax, qword ptr [rbp - 16]
        mov     eax, dword ptr [rax]
        add     eax, 1
        mov     dword ptr [rbp - 20], eax
        lea     rax, [rbp - 20]
        mov     qword ptr [rbp - 32], rax
        pop     rbp
        ret

Nonobstant l’alignement sur la pile, ces deux fonctions sont exactement identiques. Ce fait est confirmé par la documentation IBM : Initialization of references (C++ only) – IBM Documentation et, bien entendu, par le standard (The C++11 Programming Language, §7.7.1)

Il s’agit, au final, d’une pratique assez simple à comprendre mais très peu usitée en pratique, et qui est peu référencée sur le web.

La raison à cela est que lier une temporaire à une référence constante au lieu de simplement la lier à une valeur constante – ou même une valeur tout court – à l’air assez inutile en pratique.

Mais l’est-ce réellement ?

Lier une lambda à une référence constante

Pour revenir un peu au contexte initial, la question était : pourquoi lier une lambda à une référence constante est légal et est-ce utile ?

En tant que rappel, revoici l’exemple :

#include <vector>
#include <algorithm>
 
int count_even(const std::vector<int>& v)
{
    const auto& my_func = [] (int i)->bool
    {
        return i%2==0; 
    };
 
    return std::count_if(std::cbegin(v), std::cend(v), my_func);
}

Si on met ce code dans C++ Insights (cppinsights.io), on obtient le code suivant :

#include <vector>
#include <algorithm>
 
int count_even(const std::vector<int, std::allocator<int> > & v)
{
     
  class __lambda_6_27
  {
    public: 
    inline /*constexpr */ bool operator()(int i) const
    {
      return (i % 2) == 0;
    }
     
    using retType_6_27 = auto (*)(int) -> bool;
    inline /*constexpr */ operator retType_6_27 () const noexcept
    {
      return __invoke;
    };
     
    private: 
    static inline bool __invoke(int i)
    {
      return (i % 2) == 0;
    }
     
    public: 
    // inline /*constexpr */ __lambda_6_27(const __lambda_6_27 &) noexcept = default;
    // inline /*constexpr */ __lambda_6_27(__lambda_6_27 &&) noexcept = default;
    // /*constexpr */ __lambda_6_27() = default;
     
  };
   
  const __lambda_6_27 & my_func = __lambda_6_27{};
  return static_cast<int>(std::count_if(std::cbegin(v), std::cend(v), __lambda_6_27(my_func)));
}

Comme vous pouvez le voir (et vous l’aviez peut-être déjà deviné), une lambda fonction est en réalité un foncteur (ici appelé __lambda_6_27). De ce fait, quand on assigne une lambda à une variable, on appelle en réalité le constructeur du foncteur correspondant. Or, la valeur retournée par cet appel passe nécessairement par une variable temporaire dans ce contexte, ce qui est une rvalue.

Et comme nous l’avons déjà vu, il est légal d’assigner une value à une référence constante.

C’est pour cela que lier une lambda a une référence constante est légal.

Performance et optimisation

Après avoir vu que l’on pouvait lier une lambda à une référence constante, essayons de savoir si on devrait le faire.

Pour cela, analysons les performances en temps d’exécution et de compilation de chaque possibilité.

Temps d’exécution

J’ai utilisé Quick C++ Benchmarks (quick-bench.com) pour benchmarker le temps d’exécution du morceau de code suivant :

std::vector<int> v = {0,1,2,3,4};
 
static void ConstRef(benchmark::State& state)
{
  const auto& l = [](int i)->bool{ return i%2 == 0;};
  for (auto _ : state)
  {
    std::count_if(cbegin(v), cend(v), l);
  }
}
BENCHMARK(ConstRef);
 
static void Plain(benchmark::State& state)
{
  auto l = [](int i)->bool{ return i%2 == 0;};
  for (auto _ : state)
  {
    std::count_if(cbegin(v), cend(v), l);
  }
}
BENCHMARK(Plain);

J’ai effectué le benchmarking sur clang 11.0 et GCC 10.2, avec chaque option d’optimisation.

Voici les résultats :

CompilateurOption
d’optimisation
TE avec
const ref
TE avec
valeur brute
ratio const ref /
valeur brute
Clang 11.0-O032.30633.7320.958
Clang 11.0 -O1224.96204.921.097
Clang 11.0 -O23.9982e-64.0088e-60.997
Clang 11.0 -O33.7273e-64.1281e-60.903
GCC 10.2-O0 64.37965.0170.990
GCC 10.2 -O1 11.75411.8710.990
GCC 10.2 -O2 3.7470e-64.0196e-60.932
GCC 10.2 -O3 3.6523e-63.9021e-60.936

La colonne qui nous intéresse est la dernière, qui calcule le rapport de temps d’exécution entre la version liant une référence constante et la version liant une valeur (> 1 signifie que la version liant une référence constante est plus lente que l’autre).

Voici les graphiques associés :

En tout et pour tout on peut voir que même si la version liant un référence constante est globalement meilleure que la version liant une valeur, la différence est toujours inférieure à 10%.

Si le code est dans un goulot d’étranglement (i.e. dans les 20% du principe de Pareto), alors ces 10% peuvent éventuellement faire la différence, mais cela ne permettra jamais d’avoir plus de deux ou trois pourcents de gain dans la globalité d’un code plus massif.

Par contre, si le code n’est pas dans un goulot d’étranglement, il n’y a dans les faits aucune différence notable entre les deux version.

Temps de compilation

Est-ce que lier une lambda à une référence constante affecte le temps de compilation ? Pour répondre à cela, j’ai utilisé C++ Build Benchmarks (build-bench.com)1

Voici les deux codes que j’ai lancé séparément pour les comparer :

#include <vector>
 
int main() 
{
    const auto& l = [](int i)->bool{ return i%2 == 0;};
    std::vector<int> v= {0,1,2,3,4};
    std::count_if(cbegin(v), cend(v), l);
}

et

#include <vector>
 
int main() 
{
    auto l = [](int i)->bool{ return i%2 == 0;};
    std::vector<int> v= {0,1,2,3,4};
    std::count_if(cbegin(v), cend(v), l);
}

Et voici les résultats :

CompilateurOption
d’optimisation
TB avec
const ref
TE avec
valeur brute
ratio const ref /
valeur brute
Clang 11.0-O00.36290.35101.034
Clang 11.0 -O10.40100.40350.994
Clang 11.0 -O20.37550.37650.997
Clang 11.0 -O30.37450.37351.003
GCC 10.2 -O0 0.39150.39001.004
GCC 10.2 -O1 0.38300.38101.005
GCC 10.2 -O2 0.37650.37750.997
GCC 10.2 -O3 0.37650.37501.004

Dans tous les cas, on observe une différence inférieures à 4%, et même dans la plupart des cas inférieure à 1%. On peut dire sans risque qu’il n’y a pas de différence significative entre les deux versions.

Conclusion

Il n’y a pas de réel avantage significatif a lier une lambda (ou plus généralement une rvalue) à une référence constante plutôt qu’à une valeur. La plupart du temps, vous préférerez utiliser une simple valeur ou une valeur constante, qui a le bénéfice de ne pas trop alourdir l’écriture avec un symbole supplémentaire.

Dans le cas où vous vous trouvez dans un goulot d’étranglement, vous pourriez considérer l’utilisation d’une référence constante en vue d’optimiser un peu le temps d’exécution, mais dans ce cas là je vous suggère de faire vos propre benchmarks pour déterminer si, dans votre contexte spécifique, c’est effectivement utile.

Merci de votre attention et à la semaine prochaine.

Article original : Yet another pamphlet about inlining | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

window.h casse la librairie standard

Article original : windows.h breaks the standard library (and my will to live) | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

En travaillant sur du code pas si vieux que ça, j’ai eu une erreur de compilation plutôt singulière.

Voici le code incriminé :

//...
 
const T_LongType BIG_OFFSET = std::numeric_limits<T_LongType>::max() / 2;
 
//...

Et voici les erreurs générées :

1>MyFile.cpp(42): error C2589: '(' : illegal token on the right side of '::'
1>MyFile.cpp(42): error C2059: syntax error : '::'
1>MyFile.cpp(42): error C2059: syntax error : ')'
1>MyFile.cpp(42): error C2059: syntax error : ')'

Elles étaient accompagnée d’une petite volée de warnings que je ne posterai pas ici.

Il m’a fallut pas mal de temps pour comprendre quel était le problème. Après tout, le type T_LongType était correctement défini (un typedef de long) et je n’avais pas oublié d’inclure <limits>.

Peut-être connaissez vous déjà la cause de cela. Il s’agit de la ligne suivant :

#include <windows.h>

En effet, si on regarde à l’intérieur de ce header, on tombe là-dessus :

#ifndef NOMINMAX

#ifndef max
#define max(a,b)            (((a) > (b)) ? (a) : (b))

#ifndef min
#define min(a,b)            (((a) < (b)) ? (a) : (b))

#endif  /* NOMINMAX */

Explication

Le fait que windows.h definisse les macros min et max implique que, pendant la phase de préprocesseur, toutes les instances de min et max sont remplacée par le code de la macro.

Concrètement, cela signifie que lorsque le compilateur compile, au lieu de voir cela :

const T_LongType BIG_OFFSET = std::numeric_limits<T_LongType>::max() / 2;

Il voit cela :

const T_LongType BIG_OFFSET = std::numeric_limits::(((a) > (b)) ? (a) : (b))() / 2;

Ce qui ne fait aucun sens, d’où les erreurs de compilation ci-dessus.

Plusieurs raisons de ne pas inclure windows.h

Voici une liste non-exhaustive expliquant pourquoi inclure ce header est une mauvaise pratique :

  • Il casse la librairie standard juste en l’incluant. On devrait toujours pouvoir utiliser la syntaxe de la librairie standard peut importe les fichiers qu’on inclut.
  • Il vous force à définir NOMINMAX au début de chaque header qui inclut windows.h. Si jamais vous oubliez de le faire, alors tous les fichiers qui incluent le vôtre devront la définir ou risquer de rencontrer les même erreur que moi.
  • C’est un header dependant du system d’exploitation, il faut éviter de l’utiliser tant que c’est possible. Si vous l’utilisez alors que vous pourriez vous en dispenser, alors votre code ne sera plus portable, vous risquez de prendre de mauvaises habitude de codage (en vous reposant trop dessus), sans oublier que plus une librairie est spécifique, moins elle est maintenue.

Conclusion

Il existe des manière d’utiliser windows.h en toute sécurité, mais vous devez être absolument robuste et sans faille, ou vous proveoquerez des effets de bord indésirables.

Tant que vous les pouvez, ne l’utilisez pas.

Article original : windows.h breaks the standard library (and my will to live) | Belay the C++ (belaycpp.com)
Traductrice :
Chloé Lourseyre