Maîtrise d’informatique Page de garde Inside C++ Yannis.BRES@cma.inria.fr Maîtrise d’informatique Février 2002
Qu’est-ce qu’un ordinateur ? Fondamentalement, un ordinateur c’est : Un (ou des) processeur(s) De la mémoire vive (non persistante) Des périphériques de stockage persistants (disque dur, graveurs, bandes, …) Des périphériques d’entrées-sorties (clavier, souris, carte vidéo, carte son, …) … La mémoire vive a trois utilisations majeures : Stockage des instructions (le code) des processus Pile d’exécution des processus Espace de tas des processus (là où (m|c)alloc et new allouent les blocs demandés) Le processeur contient des registres, dans lesquels sont mémorisés, pour le processus courant : Le pointeur sur l’instruction en cours Le pointeur courant de la pile Les adresses de base des segments de code, de pile, de tas, … Les premiers arguments des fonctions appelées, la valeur de retour, … … Les registres servent aussi de "variables intermédiaires" dans les calculs.
Qu’est-ce qu’un ordinateur ? La mémoire de pile sert à : Préserver (empiler) les valeurs des registres pour pouvoir les restaurer (dépiler) ultérieurement, par exemple avant et après un appel de fonction Passer les arguments des fonctions qui ne sont pas passés par des registres Allouer de l’espace pour les variables locales et les objets locaux (+ alloca) … Par la suite, nous considèrerons que nous travaillons sur une architecture 32 bits Intel, donc avec un support de pile d’exécution fourni par le processeur (ce qui n’est pas le cas de tous les processeurs). Petit rappel : C/C++ garantissent des relations d’ordre entre les tailles des types primitifs…
Qu’est-ce qu’un ordinateur ? code segment processeur ... mov reg1, [a] add reg1, [b] cmp reg1, [c] jge +5Fh mov [d], 69h jmp main+43h pc sp stack segment reg1 reg2 reg3 reg4 … … PC : Program Counter (pointeur d’instruction) SP : Stack Pointer (pointeur de pile)
Appel de fonction simple XXX f( … ) { … } stack L’adresse de f est connue à la compilation. sp Schéma d’un appel à f : registres caller-save Préservation des registres caller-save Empilement des arguments arguments Saut à la fonction après empilement de l’adresse de retour Réservation d’espace de pile pour les variables/objets locaux return address Préservation des registres callee-save variables locales f fait son boulot… Restauration des registres callee-save utilisés par la fonction registres callee-save Dépilement des variables locales Retour à l’instruction suivant l’appel de f Dépilement des arguments Restauration des registres caller-save
Appel de fonction simple Les registres caller-save sont les registres utilisés par la fonction appelante et que les fonctions appelées ont le droit de modifier. Les registres caller-save sont donc préservés par la fonction appelante même si la fonction appelée ne les modifie pas… Les registres callee-save sont les registres utilisés par la fonction appelée, qui sont donc préservés par la fonction appelée même si les fonctions appelantes ne les utilisent pas… Le stockage des variables locales dans la pile est le mécanisme permettant, par exemple, aux différentes "instances" d’une fonction récursive d’avoir chacune leurs propres variables locales. Les variables locales static ne sont que des variables globales à visibilité réduite. Les conventions d’appels (passage des arguments par registre ou dans la pile, ordre de passage des arguments, responsabilité des dépilements d’arguments, registres callee-save ou caller-save, …) dépendent à la fois de l’architecture matérielle (cf. doc constructeur) et du language (extern "C", extern "Pascal", …).
Appel de méthode non virtuelle Le mécanisme d’appel de méthodes static est strictement identique à celui des appels de fonctions. Les méthodes non-static ont un paramètre semi-caché supplémentaire : le pointeur constant this.
Fonctions et méthodes inline Le mécanisme d’inlining vise à réduire le coût des appels de fonctions/méthodes. Si, au moment de la génération de code pour un appel de fonction ou de méthode : Sa "version" / son adresse est connue (c’est une fonction ou, dans le cas d’une méthode, on connaît le type dynamique de l’objet). Son implémentation est accessible. Alors cet appel de fonction est candidat à l’inlining, c’est-à-dire au remplacement de tout le mécanisme d’appel par l’implémentation de la fonction elle-même. Le compilateur vérifie, pour chaque inlining potentiel, que la taille du code expansé ne soit pas trop supérieure à celle du code avec un appel de fonction, ce qui est généralement le cas avec les accesseurs et les modifieurs. L’inlining augmente généralement la portée des optimisation du compilateur (propagation de constantes dans le corps de la fonction, …).
Memory layout des objets simples class|struct { bool b; char c; double d; float f; int i; long l; void *ptr; char c2; bool bb[2]; int ii[3]; } a; b c &a+4 &a+8 d &a+12 &a+16 f &a+20 i &a+24 l &a+28 ptr &a+32 c2 bb &a+36 ii &a+40 &a+44 Dans les unions, tous les membres commencent à la même adresse et la taille d’une union est celle du plus grand de ses membres.
Memory layout des objets simples Les données sont disposées dans l’ordre de déclaration. Par défaut, les données sont alignées en fonction de leur taille en octets : multiple de 1 pour les bool et les char, multiple de 4 pour les int, les long, les float, les pointeurs, …, multiple de 8 pour les double. Il peut donc y avoir de l’espace inutilisé dans les structures… On peut forcer le compilateur à aligner les données différemment (packing) mais, généralement, un processeur travaillant sur un certain nombre de bits, 32 par exemple, n’aime guère les adresses qui ne sont pas des multiples de 4 octets, ou les données dont la taille ne correspond pas à 4 octets…
Héritage simple Héritage simple En cas d’héritage simple, les données sont simplement accolées (dans un ordre dépendant de l’implémentation) : class A { AAA a; }; class B: public A { BBB b; class C: public B { CCC c; C x; C:: B:: A::a b c B b= c; B:: A::a b A& a= c; Ici, l’initialisation de b par copie de c entraîne le slicing des données de c provenant de la dérivation de la classe B. a étant une référence (un "pointeur constant") vers c, aucune donnée n’est dupliquée.
Héritage simple et fonctions non virtuelles Lorsque les fonctions ne sont pas virtuelles, leur adresse est résolue lors de la compilation en fonction du type statique. class A { public: void f(); }; class B: public A { public: void f(); }; B b; b.f(); // invoque B::f() A a= b; // slicing de b a.f(); // invoque A::f() A& c= b; c.f(); // invoque A::f() bien que c soit en fait “un B”
Fonctions virtuelles Fonctions virtuelles Dès lors qu’une classe a au moins une méthode virtuelle, ses instances ont un pointeur caché ("vptr") vers la table des méthodes virtuelles ("vtbl"). Les tables de méthodes virtuelles sont partagées par toutes les instances d’une même classe. class A { XXX a; public: virtual ~A(); virtual void f(); void g(); }; A::vtbl vptr ~() B b; B:: A::a f() b b.f(); b.g(); class B: public A { YYY b; public: virtual ~B(); virtual void f(); void g(); virtual void h(); }; A& a= b; B::vtbl a.f(); ~() f() a.g(); h()
Casts de pointeur polymorphe en pointeur non-polymorphe Dans les implémentations de compilateurs C++ où le vptr est placé au début des objets, le cast d’un pointeur vers une classe ayant au moins une méthode virtuelle en un pointeur vers une classe n’ayant pas la moindre fonction virtuelle nécessite un décalage. class A { AAA a; }; class B: public A { BBB b; public: virtual void f(); B * b= new B(); vptr B:: A::a A * a= b; b Le compilateur doit tout d’abord vérifier que b ne soit pas 0/NULL, puis lui ajouter sizeof( vptr ). De même, pour pouvoir comparer a et b (==, !=), le compilateur sait qu’il faut normaliser les pointeurs. Par contre, delete a génère du code “faux” : a ne pointe plus sur un début de bloc alloué par new ! Ainsi, les casts (implicites ou non) peuvent nécessiter de générer du code…
Héritage multiple sans fonctions virtuelles class A { AAA a; }; class B: A { BBB b; class C: A { CCC c; class D: B, C { DDD d; D:: B:: A::a b C:: A::a c d D * d= new D(); C * c= d; (c == d) true ! delete c;
Héritage multiple avec fonctions virtuelles class A { AAA a; public: virtual ~A(); }; class B: A { BBB b; class C: A { CCC c; class D: B, C { DDD d; D:: B:: A:: vptr D * d= new D(); a b B * d_b= d; C:: A:: vptr A * d_b_a= d_b; a c C * d_c= d; d A * d_c_a= d_c; Avec, toujours : d == d_c true ! Mais : delete d_c;
Héritage virtuel Héritage virtuel Les données des classes héritées virtuellement ne sont pas "dupliquées". Chaque classe héritant vituellement d’une classe de base contient un pointeur vers les données de cette classe. class A { AAA a; }; class B: virtual A { BBB b; class C: virtual A { CCC c; class D: B, C { DDD d; D:: B:: A* b C:: A* c d A:: a
Le RTTI Le RTTI Lorsque le RTTI est activé, le compilateur initialise une instance de la classe type_info par classe, pour les références, plus ce qu’il faut pour tous les pointeurs rencontrés ainsi que les types primitifs. Ce sont des références vers ces instances (constantes) qui seront renvoyées par typeid. ..... Le résultat de typeid peut-être connu lors de la compilation : Pour les types primitifs : int i; const type_info& i_ti= typeid( i ); Pour les pointeurs (seul le type statique de l’objet pointé intervient) : A * ptr= new C(); // C dérive de A const type_info& ptr_ti= typeid( ptr ); Pour les références (non triviales), le résultat de typeid est déduit du vptr.
typeid et références non triviales Pour les références non triviales, le résultat de typeid est déduit du vptr. La référence vers l’instance de type_info correspondant à la vtbl peut, par exemple, être stockée dans la cellule -1: Avant d’accéder au vptr, le compilateur insère du code visant à vérifier que la référence ne soit pas 0/NULL… -1 X& x= *(new Y()); type_info 1 Y:: vptr 2 … … Y::vtbl Tout cela est fait par une fonction dont le coût n’est pas négligeable !
Construction d’objets La construction d’un objet se fait en deux étapes distinctes : ..... 1 Allocation de l’espace mémoire : Si l’objet est alloué en pile, par simple décalage du pointeur de pile. Si l’objet est alloué dans le tas (par appel à new), par appel de l’operator new de la classe, s’il a été défini ou hérité, ou de l’operator new global. 2 Le constructeur adéquat est invoqué, ce qui peut générer une cascade d’appels aux constructeurs des classes de base. Les données seront construites dans l’ordre des déclarations (au besoin, le compilateur les réordonne avec avertissement selon le niveau de warning). Chaque entrée dans un constructeur implique la mise à jour du vptr (si vptr il y a) : attention aux méthodes virtuelles pures ! Pour les tableaux d’objets : 1 Allocation de l’espace mémoire : similaire si l’objet est alloué en pile ; dans le tas, l’allocation est faite par l’operator new[] adéquat (de classe ou global). 2 Le constructeur par défaut est invoqué de manière itérative pour initialiser chaque cellule (ce qui peut encore générer une cascade d’appels aux constructeurs des classes de base, etc.)
Destruction d’objets Destruction d’objets Inversement, la destruction d’un objet se fait en deux étapes inverses : ..... 1 Invocation ascendante des destructeurs (le vtpr, si vptr il y a, est mis à jour à chaque étage). 2 Libération de la mémoire : Si l’objet est alloué en pile, par simple décalage du pointeur de pile. Si l’objet est alloué dans le tas (par appel à new), par appel de l’operator delete de la classe, s’il a été défini ou hérité, ou de l’operator delete global. Pour les tableaux d’objets, c’est ric-rac. Rappel : attention aux classes polymorphes dont le destructeur n’est pas virtuel !!!!!
operator new et operator delete Les operator new et delete globaux ou d’une classe peuvent être redéfinis, dans le but de fournir une gestion spécifique de la mémoire : ..... #include <cstddef> // à voir … void * operator new( size_t size ); void operator delete( void * ptr ); void operator delete( void * ptr, size_t size ); void * operator new[]( size_t size ); void operator delete[]( void * ptr ); void operator delete[]( void * ptr, size_t size ); La définition des versions avec taille des operator delete sont selon votre bon vouloir… En règle générale, si vous redéfinissez operator new, redéfinissez operator delete ! Exemple d’application : allocateur mémoire rétenseur (qui ne libère jamais vraiment la mémoire), qui chaîne les blocs libérés pour les retourner à la prochaine demande d’allocation.
Placement operator new Les operator new (scalaires ou de tableaux) peuvent être surchargés, le premier argument devant être de type size_t. ..... class A { public: A( XXX, YYY ); void * operator new( size_t, WhatEver1, WhatEver2 ); void operator delete( void *, WhatEver1, WhatEver2 ); … } (idem pour les constructeurs de tableaux) De tels constructeurs sont invoqués par : new ( whatever1, whatever2 ) A( xxx, yyy ); L’operator delete correspondant, s’il a été défini, n’est appelé automatiquement que si le constructeur lance une exception.