web-dev-qa-db-fra.com

Pourquoi l'optimiseur GCC 6 amélioré casse-t-il le code C ++?

GCC 6 a une nouvelle fonctionnalité d'optimisation : Il suppose que this n'est pas toujours nul et optimise en fonction de cela.

La propagation par plage de valeurs suppose maintenant que le pointeur this des fonctions de membre C++ est non-nul. Ceci élimine les contrôles de pointeur null usuels mais coupe également certaines bases de code non conformes (telles que Qt-5, Chromium, KDevelop) . En tant que solution temporaire, vous pouvez utiliser des vérifications -fno-delete-null-pointer. Un code incorrect peut être identifié en utilisant -fsanitize = undefined.

Le document de modification indique clairement que cela est dangereux car il casse une quantité surprenante de code fréquemment utilisé.

Pourquoi cette nouvelle hypothèse casserait-elle le code C++ pratique? Y a-t-il des modèles particuliers dans lesquels des programmeurs négligents ou non informés s'appuient sur ce comportement non défini particulier? Je ne peux pas imaginer que quelqu'un écrive if (this == NULL) parce que c'est si peu naturel.

149
boot4life

Je suppose que la question à laquelle il faut répondre est de savoir pourquoi des personnes bien intentionnées écrivent les chèques en premier lieu.

Le cas le plus commun est probablement si vous avez une classe qui fait partie d'un appel récursif naturel.

Si tu avais:

struct Node
{
    Node* left;
    Node* right;
};

en C, vous pourriez écrire:

void traverse_in_order(Node* n) {
    if(!n) return;
    traverse_in_order(n->left);
    process(n);
    traverse_in_order(n->right);
}

En C++, il est agréable d’en faire une fonction membre:

void Node::traverse_in_order() {
    // <--- What check should be put here?
    left->traverse_in_order();
    process();
    right->traverse_in_order();
}

Dans les débuts de C++ (avant la standardisation), il était souligné que les fonctions membres étaient un sucre syntaxique pour une fonction où le paramètre this était implicite. Le code a été écrit en C++, converti en C équivalent et compilé. Il y avait même des exemples explicites montrant que la comparaison de this avec null était significative et que le compilateur Cfront d'origine en tirait parti également. Venant donc d’un fond C, le choix évident pour le test est:

if(this == nullptr) return;      

Note: Bjarne Stroustrup mentionne même que les règles pour this ont changé au fil des ans ici

Et cela a fonctionné sur de nombreux compilateurs pendant de nombreuses années. Lorsque la normalisation est arrivée, cela a changé. Et plus récemment, les compilateurs ont commencé à tirer parti de l'appel d'une fonction membre où this étant nullptr est un comportement indéfini, ce qui signifie que cette condition est toujours false et que le compilateur est libre. l'omettre.

Cela signifie que pour faire une traversée de cet arbre, vous devez soit:

  • Effectuez toutes les vérifications avant d'appeler traverse_in_order

    void Node::traverse_in_order() {
        if(left) left->traverse_in_order();
        process();
        if(right) right->traverse_in_order();
    }
    

    Cela implique également de vérifier sur CHAQUE site d’appel si vous pouvez avoir une racine NULL.

  • Ne pas utiliser une fonction membre

    Cela signifie que vous écrivez l'ancien code de style C (peut-être en tant que méthode statique) et que vous l'appelez explicitement avec l'objet en tant que paramètre. par exemple. vous êtes de nouveau en train d'écrire Node::traverse_in_order(node); plutôt que node->traverse_in_order(); sur le site d'appel.

  • Je crois que le moyen le plus simple et le plus simple de corriger cet exemple particulier d’une manière compatible avec les normes est d’utiliser un nœud sentinelle plutôt qu’un nullptr.

    // static class, or global variable
    Node sentinel;
    
    void Node::traverse_in_order() {
        if(this == &sentinel) return;
        ...
    }
    

Aucune des deux premières options ne semble si attrayante, et bien que le code puisse s'en sortir, ils ont écrit un code incorrect avec this == nullptr Au lieu d'utiliser un correctif approprié.

J'imagine que c'est ainsi que certaines de ces bases de code ont évolué et ont été vérifiées par this == nullptr.

87
jtlim

C'est parce que le code "pratique" était cassé et impliquait un comportement indéfini pour commencer. Il n'y a aucune raison d'utiliser un this nul, sinon comme une micro-optimisation, généralement très prématurée.

C'est une pratique dangereuse, car ajustement des pointeurs en raison de la hiérarchie des classes peut transformer un null this en un non-null. Donc, à tout le moins, la classe dont les méthodes sont supposées fonctionner avec un null this doit être une classe finale sans classe de base: elle ne peut dériver de rien et ne peut pas être dérivée de . Nous partons rapidement de pratique pour moche-bidouille-terre .

Concrètement, le code ne doit pas forcément être laid:

struct Node
{
  Node* left;
  Node* right;
  void process();
  void traverse_in_order() {
    traverse_in_order_impl(this);
  }
private:
  static void traverse_in_order_impl(Node * n)
    if (!n) return;
    traverse_in_order_impl(n->left);
    n->process();
    traverse_in_order_impl(n->right);
  }
};

Si vous aviez un arbre vide (par exemple, root est nullptr), cette solution repose toujours sur un comportement indéfini en appelant traverse_in_order avec un nullptr.

Si l'arborescence est vide, a.k.a un null Node* root, vous n'êtes pas censé appeler de méthodes non statiques. Période. C'est très bien d'avoir un code d'arbre de type C qui prend un pointeur d'instance par un paramètre explicite.

L'argument semble ici résulter en quelque sorte du besoin d'écrire des méthodes non statiques sur des objets pouvant être appelés à partir d'un pointeur d'instance nul. Il n'y a pas un tel besoin. La manière d'écrire un tel code avec C-with-objects est toujours bien meilleure dans le monde C++, car elle peut au moins être sécurisée. Fondamentalement, le null this est une micro-optimisation, avec un domaine d’utilisation aussi étroit, qu’il est parfaitement correct de le refuser. Aucune API publique ne devrait dépendre d'un null this.

65
Kuba Ober

Le document de modification indique clairement que cela est dangereux car il casse une quantité surprenante de code fréquemment utilisé.

Le document n'appelle pas cela dangereux. Il ne prétend pas non plus qu'il casse une quantité surprenante de code . Il indique simplement quelques bases de code populaires dont on prétend qu’il est connu qu’elles reposent sur ce comportement indéfini et qui s’effondreraient en raison du changement, à moins que l’option de solution de contournement ne soit utilisée.

Pourquoi cette nouvelle hypothèse casserait-elle le code C++?

Si pratique le code c ++ repose sur un comportement non défini, les modifications apportées à ce comportement non défini peuvent le perturber. C’est la raison pour laquelle UB doit être évité, même si un programme s’appuyant sur celui-ci semble fonctionner comme prévu.

Existe-t-il des modèles particuliers dans lesquels des programmeurs négligents ou non informés s'appuient sur ce comportement non défini particulier?

Je ne sais pas s'il s'agit d'un motif répandu anti -, mais un programmeur non informé pourrait penser qu'il peut empêcher son programme de planter:

if (this)
    member_variable = 42;

Lorsque le bogue actuel est en train de déréférencer un pointeur nul ailleurs.

Je suis sûr que si le programmeur est assez mal informé, il sera capable de proposer des (anti) motifs plus avancés qui s'appuient sur cet UB.

Je ne peux pas imaginer que quelqu'un écrive if (this == NULL) parce que c'est si peu naturel.

Je peux.

35
eerorika

Une partie du code "pratique" (manière amusante d'épeler "buggy") qui était cassé ressemblait à ceci:

void foo(X* p) {
  p->bar()->baz();
}

et il a oublié de prendre en compte le fait que p->bar() renvoie parfois un pointeur nul, ce qui signifie que son déréférencement pour appeler baz() n'est pas défini.

Tout le code cassé ne contenait pas de contrôles if (this == nullptr) ou if (!p) return; explicites. Certains cas étaient simplement des fonctions qui n’accédaient à aucune variable membre, et donc semblait fonctionner correctement. Par exemple:

struct DummyImpl {
  bool valid() const { return false; }
  int m_data;
};
struct RealImpl {
  bool valid() const { return m_valid; }
  bool m_valid;
  int m_data;
};

template<typename T>
void do_something_else(T* p) {
  if (p) {
    use(p->m_data);
  }
}

template<typename T>
void func(T* p) {
  if (p->valid())
    do_something(p);
  else 
    do_something_else(p);
}

Dans ce code, lorsque vous appelez func<DummyImpl*>(DummyImpl*) avec un pointeur null, il existe un déréférencement "conceptuel" du pointeur à appeler p->DummyImpl::valid(), mais cette fonction membre ne fait que renvoyer false sans accéder à *this. Ce return false Peut être en ligne et, dans la pratique, il n'est pas nécessaire d'accéder au pointeur. Donc, avec certains compilateurs, cela semble fonctionner correctement: il n'y a pas de segfault pour la suppression des références null, p->valid() est false, le code appelle donc do_something_else(p), qui recherche les pointeurs nuls et ne fait rien. Aucun crash ou comportement inattendu n'est observé.

Avec GCC 6, vous obtenez toujours l'appel à p->valid(), mais le compilateur déduit désormais de cette expression que p doit être non nul (sinon, p->valid() serait un comportement indéfini). ) et prend note de cette information. L’optimiseur utilise cette information déduite pour que, si l’appel à do_something_else(p) soit en ligne, le contrôle if (p) soit désormais considéré comme redondant, car le compilateur se souvient qu’il n’est pas nul, insère le code à:

template<typename T>
void func(T* p) {
  if (p->valid())
    do_something(p);
  else {
    // inlined body of do_something_else(p) with value propagation
    // optimization performed to remove null check.
    use(p->m_data);
  }
}

Maintenant, cela déréférence un pointeur nul et le code qui semblait auparavant fonctionner ne fonctionne plus.

Dans cet exemple, le bogue se trouve dans func, qui aurait dû d'abord vérifier la valeur null (ou les appelants n'auraient jamais dû l'appeler avec null):

template<typename T>
void func(T* p) {
  if (p && p->valid())
    do_something(p);
  else 
    do_something_else(p);
}

Un point important à retenir est que la plupart des optimisations de ce type ne concernent pas le compilateur: "ah, le programmeur a testé ce pointeur contre null, je le supprimerai simplement pour être ennuyeux". Ce qui se passe, c’est que diverses optimisations classiques, telles que l’inclusion et la propagation par plage de valeurs, se combinent pour rendre ces contrôles redondants, car ils interviennent après un contrôle précédent ou un déréférencement. Si le compilateur sait qu'un pointeur est non nul au point A d'une fonction et qu'il n'est pas modifié avant un point B ultérieur dans la même fonction, il sait qu'il est également non nul en B. les points A et B peuvent en réalité être des morceaux de code qui étaient à l'origine dans des fonctions séparées, mais sont maintenant combinés en un morceau de code, et le compilateur peut appliquer sa connaissance du fait que le pointeur est non nul à plusieurs endroits. Il s’agit d’une optimisation fondamentale, mais très importante, et si les compilateurs ne le faisaient pas, le code de tous les jours serait considérablement plus lent et les gens se plaindraient des branches inutiles pour tester à nouveau les mêmes conditions.

25
Jonathan Wakely