C++

C vs C++ : initialisation des variables statiques

On lit souvent C/C++. Hé hé… S’il est indéniable que le ++ de C++ n’est pas là pour rien, il faut aussi savoir qu’il y a des différences en forme de pièges dans la partie « commune ». Ces différences sont particulièrement trompeuses quand on écrit du code C++ qu’on souhaite être compatible C. On va s’intéresser aujourd’hui à l’initialisation des variables statiques.

Voici un code C++ a priori anodin :

#include <stdlib.h>

int getValue()
{
    return rand();
}

int f(int c)
{
    static const int initial = getValue();

    if (initial != c)
        return c;
    else
        return initial;
}

int main()
{
    return f(42);
}

La ligne intéressante est la ligne 10 : on initialise une variable statique avec la valeur de retour d’une fonction.

Plaçons ce code dans un fichier main.cpp, plaçons ce fichier dans un projet Eclipse, et laçons un build :

[pre]16:52:21 **** Incremental Build of configuration Debug for project Experiences ****
Info: Internal Builder is used for build
g++ -std=c++0x -O0 -g3 -pedantic -Wall -Wextra
-c -o « src\\main.o » « ..\\src\\main.cpp »
g++ -o Experiences.exe « src\\main.o »

16:52:21 Build Finished (took 498ms)[/pre]

Renommons simplement le fichier pour forcer sa compilation en C. En effet, GCC se base sur l’extension du fichier pour savoir comment le compiler. Voici le résultat du build dans ce cas :

[pre]16:53:12 **** Incremental Build of configuration Debug for project Experiences ****
Info: Internal Builder is used for build
gcc -std=c99 -O0 -g3 -pedantic -Wall -Wextra
-c -o « src\\main.o » « ..\\src\\main.c »
..\src\main.c: In function ‘f’:
..\src\main.c:10:32: error: initializer element is not constant
static const int initial = getValue();
^

16:53:12 Build Finished (took 139ms)
[/pre]

L’erreur est claire. On pourrait soupçonner le qualificatif const mais cela ne change rien si on l’enlève. Le résultat est le même si la variable est statique au fichier au lieu d’être statique à la fonction.

Sans aller creuser dans les spécifications des langages, on se doute que C résoud la valeur d’une variable statique à la compilation alors que C++ autorise qu’elle soit résolue à l’exécution. Pour cela, le compilateur va sans doute générer un peu de code pour savoir si la variable a déjà été initialisée et l’initialiser le cas échéant (inutile de préciser que cela a un coût). On peut le vérifier en regardant le code assembleur de la fonction f() :

[pre] f(int):
0040144d: push %ebp
0040144e: mov %esp,%ebp
00401450: sub $0x18,%esp
10 static const int initial = getValue();
00401453: mov $0x40d020,%eax
00401458: movzbl (%eax),%eax
0040145b: test %al,%al
0040145d: jne 0x40148a
0040145f: movl $0x40d020,(%esp)
00401466: call 0x4014c4
0040146b: test %eax,%eax
0040146d: setne %al
00401470: test %al,%al
00401472: je 0x40148a
00401474: call 0x401440
00401479: mov %eax,0x40d028
0040147e: movl $0x40d020,(%esp)
00401485: call 0x4014bc
12 if (initial != c)
0040148a: mov 0x40d028,%eax
0040148f: cmp 0x8(%ebp),%eax
00401492: je 0x401499
13 return c;
00401494: mov 0x8(%ebp),%eax
00401497: jmp 0x40149e
15 return initial;
00401499: mov 0x40d028,%eax
16 }
[/pre]

Changeons l’initialisation de notre variable par quelque chose de constant, par exemple :

static const int initial = 666;

Voici le nouveau code assembleur, beaucoup plus simple, de la même fonction f() :
[pre] f(int):
0040144d: push %ebp
0040144e: mov %esp,%ebp
12 if (initial != c)
00401450: cmpl $0x29a,0x8(%ebp)
00401457: je 0x40145e
13 return c;
00401459: mov 0x8(%ebp),%eax
0040145c: jmp 0x401463
15 return initial;
0040145e: mov $0x29a,%eax
16 }
[/pre]

Pour avoir un code similaire en C, il faudrait implémenter une protection similaire, comme ceci :

#include <stdbool.h>
int f(int c)
{
    static int initial;
    static bool guard = false;

    if (guard == false)
    {
        guard = true;
        initial = rand();
    }

    if (initial != c)
        return c;
    else
        return initial;
}

On voit quelques inconvénients : perte de lisibilité, perte du qualificatif const, aucune garantie de thread-safety. A noter qu’en C++, le caractère thread-safe d’un tel code semble dépendre de la version du langage, comme débattu dans cette discussion stackoverflow « Is local static variable initialization thread-safe in C++11?« .

C’est tout pour aujourd’hui, vous êtes prévenus 🙂

Édition du 05/04/2017 : il s’avère en fait que la nécessité d’un initializer constant en C ne se limite pas aux variables déclarés avec le mot-clé static. Cette contrainte s’applique à toutes les variables ayant une static storage duration, comme expliqué dans cette discussion stackoverflow « Error “initializer element is not constant” when trying to initialize variable with const« . Ainsi, cette contrainte s’applique aussi aux variables globales.


Appels virtuels dans les constructeurs et destructeurs C++

Dans son livre Effective C++, Scott Meyers consacre l’item 9 aux appels virtuels dans les constructeurs et les destructeurs. Il déconseille de les utiliser.

J’ai écrit deux classes pour expérimenter les problèmes qu’il expose et je les ai testé sous Windows 7 avec MinGW. J’ai commencé par écrire une classe de base, dans laquelle le constructeur fait appel à une fonction virtuelle log(). L’objectif est de tracer la création des objets. La fonction est virtuelle pour permettre aux classes filles de personnaliser les logs, et l’appel est fait dans le constructeur de la classe mère car il est forcément appelé lors de l’instanciation d’une classe de la hiérarchie. Voici le code :

#include <iostream>

class Base
{
public:
    Base()
    {
        std::cout << "Base::Base()" << std::endl;
        log();
    }

    virtual ~Base()
    {
    }

    virtual void log()
    {
        std::cout << "Base::log()" << std::endl;
    }
};

class Derived : public Base
{
public:
    Derived()
    {
        std::cout << "Derived::Derived()" << std::endl;
    }

    void log()
    {
        std::cout << "Derived::log()" << std::endl;
    }
};

int main()
{
    Base b;
    Derived d;
}

Et voici la sortie en console, après une compilation sans warning :

Base::Base()
Base::log()
Base::Base()
Base::log()
Derived::Derived()

On constate que tout ce passe comme attendu quand on crée b mais pas franchement quand on construit d. En effet, lors de sa création, on construit d’abord la partie Base de l’objet. Pendant cette construction, l’objet n’est pas encore considéré comme étant de type Derived, mais comme étant de type Base. Sa vtable résout donc l’appel virtuel en appelant une fonction de la classe Base. Il est impossible de faire appel à Derived::log().

Que se passe t-il si la fonction était virtuelle pure ? On serait bien obligé d’aller chercher l’implémentation fournie par la classe fille, non ? Pour tester cela, on modifie la classe mère et on enlève la ligne Base b; du main() pour alléger la sortie et surtout parce on ne peut plus instancier cette classe car elle devient abstraite :

class Base
{
public:
    Base()
    {
        std::cout << "Base::Base()" << std::endl;
        log();
    }

    virtual ~Base()
    {
    }

    virtual void log() = 0;
};

Cette fois-ci, la compilation ne se passe pas bien : le compilateur émet un warning et l’édition des liens échoue :

..\src\main.cpp: In constructor 'Base::Base()':
..\src\main.cpp:9:13: warning: pure virtual 'virtual void Base::log()'
          called from constructor
[...]
C:\Users\X-pigradot\workspaceTestPGT\Experiences\Debug/../src/main.cpp:9:
          undefined reference to `Base::log()'

Il n’y a donc vraiment pas moyen d’appeler la fonction de la classe fille.

Notez au passage qu’une fonction virtuelle pure peut avoir une implémentation. Oui, oui. Cela pourrait servir à fournir une implémentation par défaut. Que se passe t-il alors ? Puisque lors de la construction de la partie Base de Derived, on ne peut qu’appeler des fonctions de la classe Base, qu’appelle t-on ? Essayons de rajouter une implémentation à Base::log() :

void Base::log()
{
    std::cout << "Base::log()" << std::endl;
}

Le warning à la compilation reste mais l’erreur d’édition des liens disparaît et on peut exécuter le code :

Base::Base()
Base::log()
Derived::Derived()

En fait, ce code « tombe en marche ». L’appel d’une méthode virtuelle pure dans un constructeur est un undefined behavior, comme l’explique Raymon Chen dans cet article C++ corner case: You can implement pure virtual functions in the base class. Ici, ça a un comportement conforme à ce qu’on pourrait attendre mais tout aurait pu arriver.

Vous allez dire « le compilateur prévient quand même, tu l’as bien cherché à ne pas écouter ses alertes ». Et vous auriez raison. À une subtilité prêt. Il faut savoir qu’il peut y avoir bien qu’un double drame n’est pas loin. Pour vous montrer cela, voici une double modification du code. La première est d’enlever l’implémentation par défaut de la fonction virtuelle pure ; la seconde est dans le constructeur pour masquer l’appel direct à cette fonction virtuelle pure en appelant une fonction non virtuelle qui appelle à son tour la fonction qu’on ne devrait pas appeler :

class Base
{
public:
    Base()
    {
        std::cout << "Base::Base()" << std::endl;
        indirection();
    }

    void indirection()
    {
        std::cout << "Base::indirection()" << std::endl;
        log();
    }

    virtual ~Base()
    {
    }

    virtual void log() = 0;
};

// NOTE: ON ENLÈVE L’IMPLÉMENTATION !

Ce code compile sans warning (premier drame) et à l’exécution, on a (deuxième drame) :

Base::Base()
Base::indirection()
pure virtual method called

This application has requested the Runtime to terminate it in an unusual way.
Please contact the application's support team for more information.
terminate called without an active exception

Le programme se termine brutalement. Désolé. Remarquez, si ça crashe on s’en rend compte rapidement… mais on espère que ça crashe avant d’arriver en prod ! Ici, la ficelle est une grosse mais par le jeu de la maintenance, avec plusieurs développeurs, au fil du temps, avec des fonctions qui appellent des fonctions qui appellent d’autres fonctions, vous pouvez vous retrouver dans une situation inconfortable où votre constructeur appelle une fonction virtuelle, pure ou pas, définie ou pas.

Conclusion : méfiez-vous des appels de fonctions virtuelles dans les constructeurs (et les destructeurs, ça a le même effet). Méfiez-vous de toute fonction que vous appelez dans vos constructeurs ou vos destructeurs car elles pourraient appeler des fonction virtuelles (aujourd’hui ou demain). Enfin, si vous rendez une fonction virtuelle ou virtuelle pure, faites attention à ce quelle ne soit pas déjà appelée (directement ou indirectement) depuis un constructeur ou un destructeur.

En complément, vous pouvez lire cet article, Be Careful with Virtual Method Calls from the Constructor (and Destructor) of your Classes in C++/C#/Java!. L’auteur montre ce que je viens de vous montrer en C++ mais aussi ce qui se passe en Java et C#.


Activer la compilation multicœur dans Eclipse CDT

Mon premier article sur ce blog expliquait comment dire à make d’utiliser plusieurs cœurs lors de la compilation. Il suffit d’utiliser l’option -j pour spécifier le nombre de jobs pouvant s’exécuter en parallèle. Si vous utilisez Eclipse CDT et son builder interne, il existe aussi une option activer la compilation multicœur. Je l’ai trouvé par hasard aujourd’hui, elle est dans les propriétés du projet puis C/C++ Build et Behavior :

eclipse-compilation-multicoeur

J’ai étonné que cette option ne soit pas cochée par défaut et j’ai bien sûr immédiatement testé ça. Mon projet n’est pas encore très gros, il n’a que 78 fichiers. En l’activant, je suis passé de 31s.792 ms à 13s.114 ms pour le builder. Wouhou !


Overhead d’une classe et de son constructeur C++ sur une structure C

Il y a quelques temps, il y a eu une discussion assez animée sur Developpez.com autour des avantages de C++ par rapport à C. Comme trop souvent, des gens ont exposés des croyances et des convictions sans fondement ou a minima pas complètement vérifiées. Je débute dans mon utilisation professionnelle du C++, je développe sur micro-contrôleurs relativement contraints et surtout j’aime bien les preuves. Quand il a été dit que les constructeurs C++ étaient très coûteux et nécessitaient des mécanismes complexes, j’ai tiqué : sans avoir vérifié, je ne voyais rien qui pouvait justifier cela a priori. Un constructeur n’est souvent qu’une fonction qui initialise des variables…

Je suis donc allé sur Compiler Explorer (1), un site fort sympathique où vous écrivez du code source à gauche et où le code assembleur correspondant est généré à la volée à droite. Vous pouvez choisir parmi un nombre important de compilateurs dans différentes versions, changer les options de compilation et surtout, des blocs de couleurs font la correspondance entre les lignes du code source et les instructions assembleur correspondantes.

Puisque je travaille sur des interfaces graphiques, j’ai choisi de modéliser un écran avec seulement 2 propriétés : hauteur et largeur. J’ai donc écrit plusieurs codes C et C++ et comparé les codes assembleur générés. Je vous partage les captures d’écran faites depuis Compiler Explorer, vous pouvez cliquez dessus pour les ouvrir en taille réelle.

Pour commencer, voici une classe C++ très basique et très classique :

1-cpp-classe-et-constructeur

On voit tout de suite qu’un constructeur ne génère pas des tonnes d’assembleur et n’utilise pas de mécanique complexe. Comparons tout de suite cela à une structure C avec une fonction d’initialisation :

2-c-struct-et-fonction-creation

C’est donc confirmé : non, un constructeur C++ ne coûte pas les yeux de la tête comparé à une fonction C. Ici, il y a un peu plus d’assembleur mais ça ne change pas la face du monde (2).

Le hic est que ce code C n’est pas très idiomatique : on n’a pas besoin d’une fonction pour créer et initialiser une structure en C…. Voici donc un second code avec la même structure initialisée de manière classique, ainsi qu’une nouvelle fonction d’affichage :

3-c-idiomatic

L’initialisation de la structure tient maintenant en 2 instructions assembleur, c’est beaucoup mieux. Remarquez au passage qu’un appel de fonction (même vide), ça coûte ! Il faut maintenant trouver une alternative crédible en C++. On ne va se trainer un constructeur C++ si coûteux comparé à une bête liste entre accolades en C ! La première idée est celle d’une classe template. On a généralement un seul écran donc la duplication de code liée à la spécialisation des templates n’est pas un problème et la classe sera optimisée à la compilation pour les dimensions de l’écran (bon OK, ici, la classe est vide…). Voici ma classe template et son code assembleur :

4-cpp-template

Le code assembleur est le même que celui de la version C ! WTF ?! Un template C++ ça ne coûte pas super cher ? Et ben non : un template n’est rien de plus qu’une sorte de super macro. Quand on l’instancie, le contenu est copié et les paramètres formels sont remplacés par les arguments de l’instanciation.

Il y a une autre alternative en C++ dans le cas où la duplication du code n’est pas souhaitable : l’aggregate initialization. Pour que cela soit possible, il faut que la classe respecte certaines contraintes, notamment celui que ces champs soient publiques. On a alors un code source comme celui-ci, pour le même code assembleur généré :

5-cpp-aggregate-type

Pour conclure, on voit qu’une classe C++ simple n’est pas vraiment plus coûteux qu’une simple structure en C. Il y a des mécaniques C++ coûteuses, comme les fonctions virtuelles, les exceptions, mais vous n’êtes pas obligés de les utiliser si vous avez de vraies contraintes de tailles et de performances. On peut faire du code en C++ qui a des performances et une empreinte mémoire semblable à un code C, mais le C++ vous apporte des choses intéressantes que vous n’aurez pas en C. Le sujet de cet article n’est pas de lister ces choses intéressantes mais bien de montrer qu’il ne faut pas avoir d’a priori, dans un sens comme dans l’autre, et qu’il est nécessaire de tester, de se documenter et de vérifier.

(1) J’ai découvert ce site en regardant la vidéo de la conférence de Jason Turner à la CppCon 2016. Il montre comment il a codé le jeu pong sur Commodore64 en en C++17. Il utilise les fonctionnalités les plus avancées du langage et montre l’assembleur au fur et à mesure. Cette vidéo est incroyable et détruit le mythe qui voudrait que le C++ moderne et évolué ne permet pas de produire du code compact ! À regarder absolument !

(2) Vous remarquerez que les codes ci-dessus ont été compilés en -O0, c’est-à-dire sans optimisation. Dés que les optimisations sont activées, à partir de -O1, tous ces codes produisent (naturellement ?) l’assembleur suivant :

main:
mov eax, 560
ret

Vérification de types à l’édition des liens

Avec des collègues, on a discuté des vérifications de types à l’édition des liens. Qu’est ce que l’éditeur de liens (le linker) est capable de vérifier ? Je soutenais qu’il ne vérifiait rien alors qu’un collègue soutenait qu’il vérifiait les prototypes des fonctions. J’ai donc souhaité en savoir un peu plus sur le sujet. On parlait de notre expérience en C, mais je m’intéresse aussi au C++ dans cet article.

En première réponse, on peut dire qu’on avait tous les deux tort et raison. En fait, comme (trop) souvent en C (et sans doute en C++, mais je manque d’expérience avec ce langage), chaque toolchain fait ce que bon lui semble. Beaucoup ne vérifient rien, comme le montreront les exemples suivants, l’édition des liens n’étant que la mise en correspondance de symboles qui sont de simples chaines de caractères ; d’autres peuvent utiliser des techniques plus ou moins évolués pour s’assurer de la cohérence des liens.

Ce n’est pas le compilateur qui vérifie le typage ?

Oui, c’est le rôle du compilateur. Pour chaque unité de compilation, il s’assure que l’utilisation des différents éléments (variable, structures, fonctions, etc.) est bien conforme à leurs déclarations. Si vous déclarer une variable de type pointeur, il émettra un warning si vous essayer de vous en servir comme d’un entier. C’est le cas avec le code suivant :

int f(void)
{
    void* p = &amp;main; 
    return p;
}

gcc émet le warning suivant :

main.c: In function ‘f’:
 main.c:24:2: warning: return makes integer from pointer without a cast [enabled by default]
 return p:
 ^
 main.c:24:10: error: expected ‘;’ before ‘:’ token
 return p:
 ^

Si le compilateur fait le boulot, pourquoi se préoccuper du linker ?

Comme précisé ci-dessus, le compilateur fait ce travail pour chaque unité de compilation, mais il ne fait aucun vérification entre unités. C’est le travail du linker que d’assembler les différentes unités compilées. Si une unité de compilation (aka un fichier C) fait référence à une fonction qui est dans une autre unité grâce à une déclaration avec le mot-clé extern, qui vérifie que cette déclarée est conforme à la définition de la fonction ? Le linker est le dernier rempart pour ne pas associer deux symboles qui ne sont pas utilisés de la même manière dans deux unités de compilation.

Pour tester cela, j’ai écrit deux fichiers C. Le premier, code.c contient des variables globales (c’est mal, c’est juste pour l’exemple) et des fonctions :

const float PI = 3.14;
const int magic = 42;

int function_char(signed char n)
{
    return n * 2;
}

double function_doubles(double a, double b)
{
    return a + b;
}

Le second, main.c les utilise :

#include <stdio.h>

extern int function_char(unsigned char a);
extern double function_doubles(float a, float b);
extern int PI(void);
extern int magic(void);

int main(void)
{
	printf("function_char(200) = %d\n", function_char(200));
	
	printf("function_doubles(3.14 + 2.42) = %f\n", function_doubles(3.14, 2.42));
	
	printf("PI as function: %d\n", PI());
	
	printf("magic as function: %d\n", magic());
}

Toutes les déclarations externes sont fausses. Les fonctions n’ont pas les bons prototypes et, beaucoup plus grave, les variables sont ici utilisées comme des fonctions. Ça ne va quand même pas passer !? Désolé de vous décevoir mais gcc vous génère un exécutable sans ciller :

$ gcc -Wall -Wextra -std=c99 main.c code.c && ./a.out 
function_char(200) = -112
function_doubles(3.14 + 2.42) = 6.720002
PI as function: 41
Segmentation fault (core dumped)

Il n’y a donc aucune vérification lors de l’édition des liens…

  • 200, qui tient normalement très bien dans un unsigned char, est finalement interprété comme un signed char, ce qui donne un nombre négatif. On obtient donc -112 au lieu de 400.
  • On pourrait penser que les floats passeraient très bien en devenant des doubles, raté.
  • Exécuter PI donne un résultat ! On se demande un peu ce qu’il est allé exécuter…
  • Exécuter magic provoque une segmentation fault. Je dirai « heureusement ! » car ça nous permet de se rendre compte d’un problème.

Comment se protéger de tels problèmes ?

Faut-il rendre les éditeurs de liens plus sécurisés ? Possible, mais ce n’est pas dans notre périmètre d’actions en tant que développeurs. La meilleure parade est de ne jamais utiliser de déclarations externes dans un fichier source. Celles-ci devraient toujours être dans un fichier d’en-tête, qui serait inclus dans le fichier source où l’on souhaite utiliser les éléments ainsi que dans le fichier source où ils sont définis. Ainsi, le compilateur pourra vérifier que la déclaration donnée dans le fichier d’en-tête est conforme avec l’utilisation et avec la définition.

En reprenant mon exemple précédent, j’ai écrit le fichier d’en-tête suivant code.h :

extern const float PI;
extern const int magic;
int function_char(signed char n);
double function_doubles(double a, double b);

Je l’ai inclus dans main.c (en supprimant les déclarations externes) et dans code.c et j’ai récompilé :

$ gcc -Wall -Wextra -std=c99 main.c code.c && ./a.out 
main.c: In function ‘main’:
main.c:10:35: error: called object ‘PI’ is not a function or function pointer
  printf("PI as function: %d\n", PI());
                                   ^
In file included from main.c:2:0:
code.h:1:20: note: declared here
 extern const float PI;
                    ^
main.c:12:41: error: called object ‘magic’ is not a function or function pointer
  printf("magic as function: %d\n", magic());
                                         ^
In file included from main.c:2:0:
code.h:2:18: note: declared here
 extern const int magic;
                  ^

En supprimant les appels à PI et magic, cela compile sans warning et donne le résultat suivant :

function_char(200) = -112
function_doubles(3.14 + 2.42) = 5.560000

Le calcul avec des flottants fonctionne correctement. On a toujours notre overflow pour 200 et le compilateur ne nous dit rien car il y a probablement une règle de conversion définie. C’est quand même beaucoup mieux !

Notez au passage que cela n’est possible que si vous recompiler tout. Si au lieu de code.c, vous avez code.o ou code.a accompagné de code.h, il ne vous reste plus qu’à souhaiter que le fichier d’en-tête est cohérent avec le contenu de la bibliothèque. Cela m’est déjà arrivé avec des définitions de structures par exemple.

Un pas de plus dans les profondeurs de l’édition des liens

Pour comprendre un peu mieux pourquoi l’éditeur de liens ne peut pas vérifier les types, il faut regarder un peu plus en détails les résultats de la compilation, les fichiers objets *.o. Ici, je suis revenu au code de départ, avant l’utilisation de code.h, et j’ai modifié un peu ma ligne de commande pour obtenir les fichiers objets :

$ gcc -Wall -Wextra -std=c99 main.c code.c -c

Je peux maintenant regarder la table des symboles de deux fichiers objets :

$ objdump -t code.o main.o 

code.o:     file format elf32-i386

SYMBOL TABLE:
00000000 l    df *ABS*    00000000 code.c
00000000 l    d  .text    00000000 .text
00000000 l    d  .data    00000000 .data
00000000 l    d  .bss    00000000 .bss
00000000 l    d  .rodata    00000000 .rodata
00000000 l    d  .note.GNU-stack    00000000 .note.GNU-stack
00000000 l    d  .eh_frame    00000000 .eh_frame
00000000 l    d  .comment    00000000 .comment
00000000 g     O .rodata    00000004 PI
00000004 g     O .rodata    00000004 magic
00000000 g     F .text    00000014 function_char
00000014 g     F .text    0000002e function_doubles

main.o:     file format elf32-i386

SYMBOL TABLE:
00000000 l    df *ABS*    00000000 main.c
00000000 l    d  .text    00000000 .text
00000000 l    d  .data    00000000 .data
00000000 l    d  .bss    00000000 .bss
00000000 l    d  .rodata    00000000 .rodata
00000000 l    d  .note.GNU-stack    00000000 .note.GNU-stack
00000000 l    d  .eh_frame    00000000 .eh_frame
00000000 l    d  .comment    00000000 .comment
00000000 g     F .text    0000007c main
00000000         *UND*    00000000 function_char
00000000         *UND*    00000000 printf
00000000         *UND*    00000000 function_doubles
00000000         *UND*    00000000 PI
00000000         *UND*    00000000 magic

Dans code.o, on voit bien nos variables et nos constantes. Il y a leurs noms et on peut savoir si ce sont des variables ou des fonctions car les premières sont dans la section .rodata (pour Read-Only Data, car ce sont des constants ; elles auraient été dans .data sans le qualificatif const) et les secondes dans la section .text. Dans main.o, ces symboles sont marqués comme *UND* et n’ont pas d’adresse. C’est le principe même de l’édition des liens que de leur donner une adresse en cherchant le symbole de même nom dans les autres fichiers objets. On constate bien que le compilateur ne laisse aucune information dans main.o pour permettre au linker pour vérifier les types.

Que se passe t-il en C++ ?

C’est presque radicalement différent !

$ g++ -Wall -Wextra -std=c++11 main.c code.c && ./a.out 
/tmp/cc7UfAzd.o: In function `main':
main.c:(.text+0x11): undefined reference to `function_char(unsigned char)'
main.c:(.text+0x37): undefined reference to `function_doubles(float, float)'
main.c:(.text+0x4c): undefined reference to `PI()'
main.c:(.text+0x61): undefined reference to `magic()'
collect2: error: ld returned 1 exit status

C’est génial ! Le linker vérifie les types ! Euh… non. Regardons les tables des symboles :

$ g++ -Wall -Wextra -std=c++11 main.c code.c -c
$ objdump -t code.o main.o 
code.o:     file format elf32-i386

SYMBOL TABLE:
00000000 l    df *ABS*    00000000 code.c
00000000 l    d  .text    00000000 .text
00000000 l    d  .data    00000000 .data
00000000 l    d  .bss    00000000 .bss
00000000 l    d  .rodata    00000000 .rodata
00000000 l    d  .note.GNU-stack    00000000 .note.GNU-stack
00000000 l    d  .eh_frame    00000000 .eh_frame
00000000 l    d  .comment    00000000 .comment
00000000 g     O .rodata    00000004 PI
00000004 g     O .rodata    00000004 magic
00000000 g     F .text    00000014 _Z13function_chara
00000014 g     F .text    00000026 _Z16function_doublesdd

main.o:     file format elf32-i386

SYMBOL TABLE:
00000000 l    df *ABS*    00000000 main.c
00000000 l    d  .text    00000000 .text
00000000 l    d  .data    00000000 .data
00000000 l    d  .bss    00000000 .bss
00000000 l    d  .rodata    00000000 .rodata
00000000 l    d  .note.GNU-stack    00000000 .note.GNU-stack
00000000 l    d  .eh_frame    00000000 .eh_frame
00000000 l    d  .comment    00000000 .comment
00000000 g     F .text    0000007c main
00000000         *UND*    00000000 _Z13function_charh
00000000         *UND*    00000000 printf
00000000         *UND*    00000000 _Z16function_doublesff
00000000         *UND*    00000000 _Z2PIv
00000000         *UND*    00000000 _Z5magicv

WTF?! Que sont ces noms ésotériques ?

C++ autorise la surcharge de fonctions, c’est-à-dire que deux fonctions peuvent avoir le même nom tout en ayant des prototypes différents. Comment faire alors pour différencier void f(int) de void f(int, int) ou encore de void f(void) ? Le compilateur utilise la technique du name mangling : il créé le symbole à partir du nom de la fonction tout en rajoutant des caractères plus ou moins cryptiques pour distinguer les prototypes. C’est ce que nous montre les tables ci-dessus. Les symboles sont différents entre les deux fichiers objets car les déclarations sont différentes entre les deux fichiers sources. Lors de l’édition des liens, il n’y a pas plus de vérification de types qu’en C (c’est d’ailleurs toujours ld qui fait ce boulot), mais comme les noms de symboles diffèrent selon le typage, il n’est pas possible de lier function_char utilisée dans main.c avec celle définie dans code.c car elles conduisent à des symboles différents.

Au passage, il n’est pas légal de surcharger une fonction en changeant son type de retour ; le name mangling ne « protège » donc que des incohérences de paramètres.

Le fameux extern "C" sert à préciser au compilateur de pas utiliser de name mangling pour générer le symbole correspondant à une fonction. Cela sert quand on utilise cette fonction dans fichier compilé en C++ alors que sa définition est dans un fichier compilé en C.

Conclusion et quelques liens

En conclusion, on voit qu’il faut se méfier des incohérences de typage entre plusieurs unité de compilation. C’est valable pour les fonctions, les variables, les constants ou encore les définitions de nouveaux types comme les structures. De manière accidentelle, le C++ est moins sensible que le C à cause de la surcharge de fonctions et de la technique du name mangling. Les normes C et C++ ne définissant rien à ce sujet, chaque toolchain pourra implémenter diverses techniques pour éviter des erreurs de type à l’édition des liens. On pourrait imaginer du name mangling en C même sans surcharge. De manière générale, la meilleure solution est celle déjà évoquée de bannir le mot-clé extern des fichiers *.c et de l’utiliser uniquement dans les fichiers *.h, mais de toute façon, vous utilisez déjà toujours des fichiers d’en-tête pour accéder aux API des autres modules, non ?

Some Differences Between C and C++ par Steve Jacobson
Voir les deux dernières sections Name Mangling et Linking C and C++ files (or Libraries)

Beginner’s Guide to Linkers

Is there any type checking in C or C++ linkers? sur stackoverflow

Checking C Declarations at Link Time par Diomidis Spinellis