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

Laisser un commentaire