Programme d'étude sur le C++ bas niveau n°11 : Héritage

L'objectif de cette série d'articles d'Alex Darby sur la programmation « bas-niveau » est de permettre aux développeurs ayant déjà des connaissances de la programmation C++ de mieux comprendre comment leurs programmes sont exécutés en pratique. Ce onzième article s'intéresse à l'héritage et à la façon dont les types C/C++ sont convertis en types assembleur dans les cas où l'héritage s'en mêle.

Retrouvez l'ensemble des articles de cette série sur la page d'index.

N'hésitez pas à commenter cet article : 9 commentaires Donner une note à l'article (5) !

Article lu   fois.

Les deux auteur et traducteur

Traducteur : Profil Pro

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Introduction

Bonjour et bienvenue dans la 11e partie du curriculum de bas niveau C/C++. Il était temps ? Absolument !

La dernière fois, nous avons vu les bases de la Définition de Type Utilisateur : Comment les structures, les classes, et les unions sont organisées en mémoire ; et (quelques-unes) des implications de l'alignement mémoire sur ce tableau.

Dans la 11e partie, nous allons voir comment l'héritage affecte ce tableau, en particulier l'implication sur l'agencement mémoire des types dérivés et aussi leur comportement pendant la construction et la destruction.

Nous allons retirer l'héritage multiple et le mot clé virtual de ce tableau pour commencer.

II. Avant que nous commencions

Je vais partir du principe que vous avez déjà lu les articles précédents de la série, mais je vais aussi mettre directement les liens sur tous les termes importants ou les concepts que vous pourriez avoir besoin de connaître pour comprendre ce que vous lisez. Je me rends utile comme ça.

Je vais faire une autre hypothèse importante, c'est que vous êtes déjà très familiarisé avec le langage C++ et à l'aise avec les fonctionnalités du langage que nous allons détailler, ainsi que les limitations d'utilisation liées à ces fonctionnalités, etc. Si j'ai besoin de démontrer quelque chose qui sorte de l'ordinaire, je l'expliquerai - ou, au minimum, je donnerai un lien vers l'explication.

Dans cette série, j'explique ce qui se produit avec un code simple et non optimisé de débogage Win32 généré par le compilateur VS 2010 - bien que les détails diffèrent sur d'autres plates-formes (et probablement avec les autres compilateurs) le balayage général du code devrait être fondamentalement le même - parce que c'est de l'assembleur qui a été généré par un compilateur C++ - et donc suivre les mêmes exemples donnés ici avec un débogueur de source / désassembleur de la plate-forme de votre choix devrait vous fournir le même aperçu que celui que nous avons ici.

Avec cela en tête, au cas où vous les auriez loupés, voici les liens vers les articles précédents de la série :

Je ne vais pas mentir - ce n'est pas facile à lire. Image non disponible

III. Classe contre Structure : un doux rappel

Les mots clés C++ struct et class définissent des types qui sont identiques au niveau de l'implémentation et de ce que vous pouvez faire avec eux (la seule différence est au niveau du langage : le niveau de visibilité par défaut s'il n'est pas spécifié est private pour les classes, et public pour les structures). Ainsi, tandis que je vais utiliser le mot clé class tout au long de cet article, veuillez considérer comme acquis le fait que tout ce dont nous parlons ici s'applique également aux types définis en utilisant le mot clé struct.

IV. Que se passe-t-il quand je dérive d'un autre type ?

Donc, qu'est-ce qui ce passe quand vous dérivez un type défini par l'utilisateur d'un autre type intégré ?

Clairement, les données membres que vous spécifiez dans les déclarations doivent aller quelque part, et il en va de même pour toutes celles spécifiées dans le(s) type(s) dont vous dérivez.

Au niveau du C++, il n'y a rien d'autre que la norme pour vous dire comment ça fonctionne - et rien d'autre que de regarder ce qui se passe avec le code généré par le compilateur que vous utilisez pour vous l'indiquer précisément.

Comme dans le dernier article, nous allons nous appuyer fortement sur le flag 007 secret du compilateur franchement génial /d1reportSingleClassLayout afin de nous dire exactement comment le (Visual Studio 2012 win32 x86) compilateur a décidé d'organiser nos structures d'exemples en mémoire.

Il est maintenant temps de regarder un exemple de code, et, plutôt que de vous laisser traverser le processus usuel et alambiqué de création d'un projet, j'en ai gentiment créé un pour vous.

Le fichier zip dans ce lien contient une solution VS2010 avec un seul projet et un fichier .cpp amoureusement initialisé pour lancer le code ci-dessous, qui est dans 00_Inheritance.cpp.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
class CTestBase
{
public:
    int _iA;
    int _iB;
};

class CTestDerived : public CTestBase
{
public:
    int _iC;
    int _iD;
};

int main(int argc, char* argv[])
{
    return 0;
}

Quand vous compilez ce projet, vous devriez obtenir ce qui suit dans votre fenêtre de sortie de « Compilation » (la magie de /d1reportSingleClassLayout !) :

 
Sélectionnez
1> class CTestBase size(8):
1>   +---
1> 0 | _iA
1> 4 | _iB
1>   +---
1> 
1> class CTestDerived size(16):
1>    +---
1>    | +--- (base class CTestBase)
1>  0 | | _iA
1>  4 | | _iB
1>    | +---
1>  8 | _iC
1> 12 | _iD
1>    +---

En regardant ça, cela devrait être assez évident que les données membres de CTestDerived ont juste été concaténées à la fin de l'agencement mémoire de CTestBase - et, plus important, que l'agencement mémoire de CTestBase à l'intérieur de CTestDerived est identique à cela quand elle n'est pas une classe de base.

C'est aussi simple que ça ! (pour certaines définitions de « ça » et « simple »).

Armé de cette information provenant de l'article précédent :

« Une garantie est donnée dans les spécifications des langages C et C++ sur le fait que l'adresse mémoire de chaque membre est plus grande que celle du membre déclaré avant lui (voir cet article de  Stack Overflow, en anglais, pour plus de détails sur la formulation). »

Il est évident que depuis que CTestDerived hérite de tous les membres de CTestBase, ses membres doivent apparaître après ceux de CTestBase en mémoire.

Je me rappelle quand j'avais à me l'expliquer - peu après avoir commencé mon premier travail dans l'industrie en tant que jeune diplômé - j'ai eu comme un temps d'arrêt dans ma tête, parce que l'information que je venais de recevoir était tellement évidente que je ne pouvais pas croire que je ne l'avais jamais sue.

V. C'est aussi simple, pourquoi faire un article là-dessus ?

Bonne question !

Le fait que l'agencement mémoire d'un type est identique dans toutes les situations est requis par la norme (et de plus par logique) ; regardons pourquoi…

En premier lieu, téléchargez et ouvrez le fichier de projet VS2010 zippé ; il contient le code ci-dessous dans 01_InheritanceWhithFunctions.cpp :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
class CTestBase
{
public:
    int _iA;
    int _iB;

    CTestBase( int iA, int iB )
    : _iA( iA )
    , _iB( iB )
    {}

    int SumBase( void )
    {
        return _iA + _iB;
    }
};

class CTestDerived
: public CTestBase
{
public:
    int _iC;
    int _iD;

    CTestDerived( int iA, int iB, int iC, int iD )
    : CTestBase ( iA, iB )
    , _iC ( iC )
    , _iD ( iD )
    {}

    int SumDerived( void )
    {
        return _iA + _iB + _iC + _iD;
    }
};

int main(int argc, char* argv[])
{
    CTestBase       cTestBase   ( argc, argc + 1 );
    CTestDerived    cTestDerived( argc, argc + 1, argc + 2, argc + 3 );

    return cTestBase.SumBase() + cTestDerived.SumBase() + cTestDerived.SumDerived();
}

Mettez un point d'arrêt sur l'instruction return du main, puis compilez et lancez la configuration de compilation release.

La première chose à noter est que les agencements mémoire affichés par la fenêtre de sortie lors de la compilation ne sont pas affectés par l'ajout de ces fonctions.

C'est ce à quoi vous vous attendiez. Comme nous le savons, ces appels aux fonctions membres non virtuelles sont résolus lors de la compilation, tout comme les ordinaires non membres et les fonctions statiques membres.

Depuis que CTestDerived est dérivée de CTestBase, nous savons grâce à notre haut niveau de connaissance du C++ que nous pouvons appeler les deux fonctions sur une instance de CTestDerived ; ce que nous cherchons à l'heure actuelle, c'est comment cela est implémenté.

Quand le point d'arrêt est atteint, faites un clic droit et choisissez « Atteindre le code machine ».

J'ai collé la partie dont je souhaite discuter ci-dessous…

(N.B. pour obtenir le même désassembleur que celui-ci, vous devriez avoir les Options d'Affichage suivantes cochées dans la fenêtre désassembleur : ‘Afficher le code source', ‘Afficher les numéros de lignes', ‘Afficher les adresses', et ‘Afficher les noms de symboles').

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
    44:     return cTestBase.SumBase() + cTestDerived.SumBase() + cTestDerived.SumDerived();
0129109A  lea         ecx,[cTestDerived]  
0129109D  call        CTestDerived::SumDerived (1291060h)  
012910A2  lea         ecx,[cTestDerived]  
012910A5  mov         esi,eax  
012910A7  call        CTestBase::SumBase (1291020h)  
012910AC  lea         ecx,[cTestBase]  
012910AF  add         esi,eax  
012910B1  call        CTestBase::SumBase (1291020h)  
012910B6  pop         edi  
012910B7  add         eax,esi

Nous avons précédemment étudié que la convention d'appel win32 pour les fonctions membres (‘thiscall') passe this aux fonctions membres dans le registre ecx.

Corrélativement, vous noterez que les adresses de CTestBase et CTestDerived sont en train d'être stockées dans ecx en utilisant lea (‘load effective address') immédiatement avant d'appeler leur fonction membre.

En particulier, notez que l'adresse de CTestDerived est passée non-altérée à l'intérieur de ecx quand on appelle la fonction de la classe de base CTestBase::SumBase. Rappelez-vous cela pour plus tard (et pour l'article suivant !).

Donc, allons regarder le désassembleur pour CTestBase::SumBase et CTestDerived::SumDerived. J'ai tendance à parcourir pas à pas le désassembleur et entrer dans celles-ci, mais positionner des points d'arrêt à l'intérieur de celles-ci est plus sûr. Image non disponible

CTestBase::SumBase
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
    14:     int SumBase( void )
    15:     {
    16:         return _iA + _iB;
01291020  mov         eax,dword ptr [ecx+4]  
01291023  add         eax,dword ptr [ecx]  
    17:     }
01291025  ret
CTestDerived::SumDerived
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
    33:     int SumDerived( void )
    34:     {
    35:         return _iA + _iB + _iC + _iD;
01291060  mov         eax,dword ptr [ecx+0Ch]  
01291063  add         eax,dword ptr [ecx+8]  
01291066  add         eax,dword ptr [ecx+4]  
01291069  add         eax,dword ptr [ecx]  
    36:     }
0129106B  ret

Nous pouvons voir que tous les déplacements depuis ecx utilisés dans les deux fonctions correspondent à l'agencement mémoire que nous avons dans le résultat de la compilation pour le type auquel appartient la fonction.

Puisque _iA et _iB sont à la même position à l'intérieur de CTestBase et CTestDerived (c'est-à-dire 0 et 4 respectivement), CTestBase::SumBase peut être appelé sans danger sur les instances de CTestDerived.

Nous savons déjà que c'est possible, depuis notre compréhension haut niveau du C++, mais maintenant nous connaissons l'implémentation détaillée qui rend cela possible.

Bien que les spécificités du désassembleur puissent probablement différer d'une plate-forme à une autre, les principes qui sous-tendent son fonctionnement eux ne le devraient pas.

VI. Résumé

Pour résumer ce que nous avons établi à ce jour :

  • Dans une fonction membre, une donnée membre d'une classe est accédée via des déplacements depuis le pointeur this.
  • Ces déplacements sont constants à la compilation et sont cuits dans le code assembleur pour les fonctions membres.
  • Cela signifie que l'agencement mémoire des membres d'une classe donnée doit toujours être identique ou les fonctions membres ne fonctionneront pas.

Si nous poursuivons cette logique, nous pouvons voir que :

  • L'agencement mémoire de la classe B qui hérite d'une autre classe A doit contenir les membres de la classe A dans le même agencement mémoire que la classe A.
  • L'agencement mémoire de n'importe quelle classe A est identique, indépendamment du fait que ce soit une instance de A, ou si elle est incluse dans la mémoire d'un type dérivé de A.
  • Note : ce comportement est requis par la norme, et (plus significativement) par logique.

Finalement, il s'ensuit que (parce que chaque membre d'une structure doit avoir une adresse supérieure aux membres déclarés avant lui) :

  • L'espace mémoire supplémentaire requis par la classe dérivée B sera concaténé à la fin de l'agencement mémoire de sa classe de base A.

C'est tout pour le moment - la fois prochaine nous verrons comment l'héritage multiple affecte ce tableau.

Je sais que c'est court, mais cela signifie juste que la fois prochaine arrivera plus rapidement. Image non disponible

VII. Épilogue - Pour ceux qui voudraient ce que j'ai changé dans les propriétés du projet

Il y a très peu de changements par rapport aux propriétés du projet console Win32 VS2010 par défaut dans le projet que j'ai zippé pour cet article.

Les changements permettent de faire en sorte que les optimisations de la configuration de compilation release laissent la structure du code tranquille (c'est-à-dire, ne pas dépouiller ou « plier » les fonctions pour réduire la taille de l'EXE, prévenir la transformation des fonctions en fonction inline), et préviennent l'insertion de code de « vérification débogue » étranger (rend l'appel des fonctions plus lent, et le code moins facile à suivre en désassembleur).

  • Désactiver « Optimisation de l'ensemble du programme » (Propriété de configuration Général).
  • Désactiver « Expansion des fonctions inline » (Propriété de configuration C/C++ Optimisation).
  • Désactiver « Vérification de base à l'exécution » (Propriété de configuration C/C++ Génération de code).
  • Se débarrasser des en-têtes pré-compilés pour rationaliser le nombre de fichiers (Propriété de configuration C/C++ En-têtes pré-compilés).
  • Désactiver « Activation du repli COMDAT » (Propriété de configuration Éditeur de lien Optimisation).

Cela permet principalement à l'assembleur de la configuration release d'avoir la même structure que les appels de fonction WRT de débogage.

De plus, j'utilise le paramètre argc du main comme entrée du code, et retourne une valeur calculée à partir de cela de sorte que l'optimiseur ne puissent pas prendre des valeurs d'entrée et de sortie constantes.

Si vous utilisez des entrées constantes, ou si vous ne retournez pas une valeur calculée à partir de celles d'entrées alors il est assez difficile de convaincre l'optimiseur de ne pas optimiser l'EXE entier avec « return 0 »… ;)

VIII. Remerciements

Merci (encore) à Bruce - roi (ou au moins duc) des conseils et des examens par un pair.

Cet article est la traduction de l'article « C/C++ Low Level Curriculum Part 11: Inheritance » écrit en anglais par Alex Darby. Alex Darby a aimablement autorisé l'équipe C/C++ de Developpez.com à traduire et diffuser son article en français.

Nous tenons à remercier Winjerome et LittleWhite pour la relecture technique ainsi que ced et jpcheck pour la relecture orthographique de cette traduction.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Copyright © 2013 Alex Darby. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.