Analyse Interne C++ : Initialisation des Membres et Performance Constructeur

Concrètement, le compilateur réorganise les opérations contenues dans la liste d'initialisation en se basant sur l'ordre de déclaration des membres au sein de la classe. Ces assignments sont injectés au début du corps du constructeur, avant l'exécution de tout code défini explicitement par le développeur.

Examinons un exemple en C++ moderne :

struct Organisme {
private:
    int valA;
    int valB;
    int valC;
    int valD;
public:
    Organisme() : valB(10), valA(20), valD(30) {
        valC = 40;
    }
};

int main() {
    Organisme obj;
    return 0;
}

Voyons maintenant le code assembleur généré pour la fonction main :

_main PROC
; allocation de la pile pour 'obj'
    push    ebp
    mov     ebp, esp
    sub     esp, 16                 ; espace mémoire réservé pour l'instance obj

; appel au constructeur
    lea     ecx, DWORD PTR [obj]    ; passage de 'this' implicite via le registre ecx
    call    ?Organisme@@QAE@XZ      ; invocation du constructeur

; fin de main
    xor     eax, eax
    mov     esp, ebp
    pop     ebp
    ret     0
_endproc

Lorsqu'on examine les instructions relatives au constructeur de Organisme, la séquence devient plus claire :

?Organisme@@QAE@XZ PROC
; sauvegarde du contexte et récupération du pointeur this
    push    ebp
    mov     ebp, esp
    push    ecx
    mov     DWORD PTR _this$[ebp], ecx

; Initialisations issues de la liste d'initialisation
    mov     eax, DWORD PTR _this$[ebp]
    mov     DWORD PTR [eax], 20       ; Initialisation de valA (déclaré en premier)
    
    mov     ecx, DWORD PTR _this$[ebp]
    mov     DWORD PTR [ecx+4], 10     ; Initialisation de valB (déclaré en deuxième)
    
    mov     edx, DWORD PTR _this$[ebp]
    mov     DWORD PTR [edx+12], 30    ; Initialisation de valD (déclaré en quatrième)

; Corps du constructeur
    mov     eax, DWORD PTR _this$[ebp]
    mov     DWORD PTR [eax+8], 40     ; Assignation de valC (corps utilisateur)

    pop     ebp
    ret     0
_endproc

L'analyse révèle que malgré l'ordre apparent dans le code source (valB avant valA), l'initialisation réelle suit la logique de définition : valA est traité avant valB. De plus, toutes ces opérations précèdent l'instruction interne valC = 40.

Négliger cet ordre peut entraîner des comportements indéfinis. Considérons le cas suivant :

struct ErreurPotentielle {
private:
    int numero;
    int copieNumero;
public:
    ErreurPotentielle() : copieNumero(numero) { // Problème ici
    }
};

Bien que la liste d'initialisation mentionne copieNumero en premier, le compilateur initialisera d'abord numero. Si ce dernier n'a pas été initialisé auparavant (ce qui est le cas ici), sa valeur est indélébile, rendant l'affectation à copieNumero erronée.

Il existe quatre situations où l'utilisation de la liste d'initialisation est obligatoire ou fortement recommandée :

  • L'initialsiation d'un membre de type référence.
  • L'initialisation d'une constante membre (const).
  • La délégaion vers un constructeur de classe de base possédant des paramètres.
  • L'appel au constructeur d'un objet membre nécessitant des arguments.

Prenons l'exemple d'une classe contenant une instance d'une autre classe :

class Component {
private:
    int id;
public:
    Component(int v) : id(v) {}
    Component() {} // Nécessaire sans liste d'initialisation
};

class Container {
private:
    int count;
    Component comp;
public:
    Container() {
        count = 1;
        comp = Component(2); // Affectation après construction par défaut
    }
};

Dans cette configuraton, le compilateur effectue plusieurs étapes inefficaces :

  1. Construction de comp via son constructeur par défaut.
  2. Création d'un tempoarire via le constructeur paramétré Component(2).
  3. Appel de l'opérateur d'affectation (operator=) pour copier le temporaire vers comp.

Cependant, si nous optimisons avec la liste d'initialisation et supprimons le constructeur par défaut inutile :

class OptimizedContainer {
private:
    int count;
    Component comp;
public:
    OptimizedContainer() : comp(2) { // Initialisation directe
        count = 1;
    }
};

L'analyse du code binaire montre une différence notable dans le mécanisme sous-jacent. Dans la version optimisée, lors de l'exécution du constructeur de OptimizedContainer, la première action consiste directement à appeler le constructeur paramétré de Component, en passant l'adresse mémoire de l'objet membre comme cible.

Cela évite la phase intermédiaire de construction par défaut suivie d'une affectation. L'optimisation supprime la création d'objets temporaires superflus et réduit le nombre d'appels de fonctions, améliorant ainsi significativement les performances d'exécution sans compromettre la sémantique du programme.

Le code assembleur correspondant confirme l'appel direct à Component(int) avec le déplacement approprié des registres, éliminant toute opération de copie supplémentaire entre un objet temporaire et le membre final.

Étiquettes: cpp assembleur-x86 constructeur Gestion-mémoire Optimisation

Publié le 26 septembre à 09h02