148 votes

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

GCC 6 dispose d'une nouvelle fonction d'optimisation : Il suppose que this est toujours non nulle et optimise en fonction de cela.

La propagation de la plage de valeurs suppose désormais que le pointeur this des fonctions membres C++ est non nul. Cela élimine les vérifications courantes des pointeurs nuls mais casse aussi certaines bases de code non conformes (comme Qt-5, Chromium, KDevelop) . Comme solution temporaire, -fno-delete-null-pointer-checks peut être utilisé. Un code erroné peut être identifié en utilisant -fsanitize=undefined.

Le document de modification indique clairement qu'il s'agit d'un danger, car il casse une quantité surprenante de code fréquemment utilisé.

Pourquoi cette nouvelle hypothèse casserait-elle le code C++ pratique ? Existe-t-il des modèles particuliers où des programmeurs négligents ou mal informés s'appuient sur ce comportement indéfini particulier ? Je ne peux pas imaginer que quelqu'un écrive if (this == NULL) parce que c'est tellement contre nature.

2 votes

stackoverflow.com/a/1844012/1870760 est une bonne lecture. Si votre compilateur vous le permet, vous pouvez faire des hypothèses à partir de cela.

0 votes

Oui, certains développeurs ont un code qui suppose que l'objet peut être un pointeur nul. Par exemple, une bibliothèque peut systématiquement définir les objets comme nullptr après les avoir supprimés, puis appeler certaines fonctions de ces objets supprimés.

0 votes

87voto

jtlim Points 365

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

Le cas le plus courant est probablement celui d'une classe qui fait partie d'un appel récursif naturel.

Si tu l'avais fait :

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 de faire de cette fonction 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 premiers jours du C++ (avant la normalisation), on insistait sur le fait que les fonctions membres étaient du sucre syntaxique pour une fonction où la fonction this est implicite. Le code était écrit en C++, converti en C équivalent et compilé. Il y avait même des exemples explicites qui comparaient this à null était significatif et le compilateur original de Cfront en tirait également avantage. Donc, venant d'un environnement C, le choix évident pour la vérification est :

if(this == nullptr) return;      

Note : Bjarne Stroustrup mentionne même que les règles pour les 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 avantage de l'appel d'une fonction membre dans laquelle this être nullptr est un comportement non défini, ce qui signifie que cette condition est toujours false et le compilateur est libre de 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 signifie qu'il faut également vérifier à CHAQUE site d'appel si vous pouvez avoir une racine nulle.

  • N'utilisez pas 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 avec l'objet explicitement comme paramètre. Par exemple, vous revenez à écrire Node::traverse_in_order(node); plutôt que node->traverse_in_order(); à l'emplacement de l'appel.

  • Je pense que la manière la plus simple et la plus efficace de corriger cet exemple particulier d'une manière conforme aux normes est d'utiliser un nœud sentinelle plutôt qu'un nœud de type 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 très attrayante, et même si le code pouvait s'en sortir, ils ont écrit du mauvais code avec this == nullptr au lieu d'utiliser un correctif approprié.

Je suppose que c'est comme ça que certaines de ces bases de code ont évolué pour avoir this == nullptr des chèques en leur sein.

1 votes

Désolé, la formulation est vague. Je voulais dire que l'introduction de cette ligne rendrait maintenant toute la fonction à comportement indéfini. Je ne défends pas l'utilisation des contrôles de nullité dans ce cas. L'OP demandait comment le code pourrait finir par l'avoir.

0 votes

@jtlim huh, non cette ligne ne cause pas du tout un comportement indéfini. Au contraire, elle l'empêche (en quelque sorte).

0 votes

@JohannesSchaub-litb Comment est la this == nullptr vérifier que le comportement n'est pas indéfini ? Si vous écrivez ceci dans la dernière version de clangs, la vérification sera complètement omise et vous vous planterez probablement avec une violation d'accès à la mémoire.

66voto

Kuba Ober Points 18926

Il le fait parce que le code "pratique" était cassé et impliquait un comportement non défini pour commencer. Il n'y a aucune raison d'utiliser une valeur nulle this si ce n'est 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 traversée de la hiérarchie des classes peut transformer une nullité this en un autre non nul. Donc, au minimum, la classe dont les méthodes sont censées fonctionner avec un objet null this doit être une classe finale sans classe de base : elle ne peut pas dériver de quoi que ce soit, et ne peut pas être dérivée de quoi que ce soit. Nous nous éloignons rapidement de la pratique pour laid-hack-land .

En termes pratiques, le code ne doit pas nécessairement ê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 avez un arbre vide (par exemple, Root est nullptr), cette solution repose toujours sur un comportement non défini en appelant traverse_in_order avec un nullptr.

Si l'arbre est vide, c'est-à-dire s'il est nul. Node* root vous n'êtes pas censé appeler des méthodes non statiques. Point final. Il est parfaitement possible d'avoir un code arborescent de type C qui prend un pointeur d'instance en paramètre explicite.

L'argument semble se résumer à la nécessité d'écrire des méthodes non statiques sur des objets qui pourraient être appelés à partir d'un pointeur d'instance nul. Ce besoin n'existe pas. La façon d'écrire ce type de code en C avec des objets est toujours bien plus agréable dans le monde C++, parce qu'elle peut être au moins sûre au niveau du type. Fondamentalement, le pointeur d'instance null this est une telle micro-optimisation, avec un champ d'utilisation si étroit, que l'interdire est, à mon avis, parfaitement acceptable. Aucune API publique ne devrait dépendre d'une valeur nulle this .

1 votes

En théorie, mais pas en pratique. En pratique, cela fonctionne bien en l'absence d'héritage multiple, à condition que la base ait un vptr - si bien qu'une longue liste de bibliothèques et d'applications existantes utilisent cette technique, de Qt à MFC, en passant par Chromium. Ces problèmes ne seront jamais corrigés, et transformer une hypothèse inoffensive en une vulnérabilité est tout simplement du sabotage délibéré.

18 votes

@Ben, Celui qui a écrit ce code avait tort en premier lieu. Il est amusant que vous nommiez des projets aussi terriblement brisés que MFC, Qt et Chromium. Bon débarras avec eux.

7 votes

@Ben A null this ne fonctionne pas sur un héritage simple à une profondeur sans méthodes virtuelles. C'est du mauvais code, voilà ce que c'est. Si vous voulez que cela fonctionne, vous avez besoin d'une méthode statique qui prend le pointeur d'instance, le compare à null, et procède de manière appropriée. Cela rend votre intention explicite, et c'est ce que visait la norme, et c'est une bonne chose. La norme vous oblige à révéler votre main, elle ne rend pas impossible l'utilisation de la technique.

36voto

user2079303 Points 4916

Le document de modification indique clairement qu'il s'agit d'un danger, car il casse une quantité surprenante de code fréquemment utilisé.

Le document ne le qualifie pas de 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 il affirme qu'elles sont connues pour reposer sur ce comportement non défini et qu'elles seraient interrompues par le changement, à moins que l'option de contournement ne soit utilisée.

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

Si pratique Le code c++ repose sur un comportement indéfini, alors les modifications de ce comportement indéfini peuvent le briser. C'est pourquoi l'UB doit être évitée, même si un programme qui s'appuie sur elle semble fonctionner comme prévu.

Existe-t-il des modèles particuliers où des programmeurs négligents ou mal informés s'appuient sur ce comportement indéfini particulier ?

Je ne sais pas si c'est très répandu. anti -mais un programmeur non averti pourrait penser qu'il peut empêcher son programme de planter en faisant :

if (this)
    member_variable = 42;

Alors que le véritable problème est de déréférencer un pointeur nul ailleurs.

Je suis sûr que si le programmeur est suffisamment mal informé, il sera capable de trouver des (anti)-méthodes plus avancées qui reposent sur cet UB.

Je ne peux pas imaginer que quelqu'un puisse écrire if (this == NULL) parce que c'est tellement contre nature.

Je peux.

11 votes

"Si un code c++ pratique repose sur un comportement indéfini, alors les modifications de ce comportement indéfini peuvent le casser. C'est pourquoi l'UB est à éviter" ce * 1000

0 votes

if(this == null) PrintSomeHelpfulDebugInformationAboutHowWeGotHere(); Par exemple, un journal facile à lire d'une séquence d'événements dont un débogueur ne peut pas facilement vous parler. Amusez-vous à déboguer cela maintenant sans passer des heures à placer des contrôles partout quand il y a un null aléatoire soudain dans un grand ensemble de données, dans un code que vous n'avez pas écrit... Et la règle de l'UB à ce sujet a été faite plus tard, après la création du C++. Avant, elle était valide.

0 votes

@StephaneHockenhull C'est ce que -fsanitize=null est pour.

25voto

Jonathan Wakely Points 45593

Une partie du code "pratique" (drôle de façon d'épeler "buggy") qui était cassé ressemblait à ceci :

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

et il a oublié de tenir compte du fait que p->bar() renvoie parfois un pointeur nul, ce qui signifie que le déréférencer pour appeler baz() est indéfinie.

Tout le code qui a été cassé ne contenait pas d'explicite if (this == nullptr) ou if (!p) return; des contrôles. Dans certains cas, il s'agissait simplement de fonctions qui n'accédaient à aucune variable membre, et ainsi est apparu pour 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 nul, il y a un déréférencement "conceptuel" du pointeur pour appeler p->DummyImpl::valid() mais en fait, cette fonction membre renvoie juste false sans accéder à *this . Ce return false peut être inlined et donc en pratique le pointeur n'a pas besoin d'être accédé du tout. Ainsi, avec certains compilateurs, cela semble fonctionner correctement : il n'y a pas de segfault pour le déréférencement de null, p->valid() est faux, donc le code appelle do_something_else(p) qui vérifie les pointeurs nuls, et ne fait donc rien. Aucun plantage ou comportement inattendu n'est observé.

Avec GCC 6, vous obtenez toujours l'appel à p->valid() mais le compilateur déduit maintenant de cette expression que p doit être non nulle (sinon p->valid() serait un comportement indéfini) et prend note de cette information. Cette information déduite est utilisée par l'optimiseur de sorte que si l'appel à do_something_else(p) est inlined, le if (p) est maintenant considéré comme redondant, car le compilateur se souvient qu'il n'est pas nul, et met donc en ligne le code pour :

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);
  }
}

Cela déréférence réellement un pointeur nul, et donc le code qui semblait fonctionner auparavant cesse de fonctionner.

Dans cet exemple, le bogue se trouve dans func qui aurait dû vérifier l'absence de null en premier lieu (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);
}

Il est important de se rappeler que la plupart des optimisations de ce type ne sont pas un cas où le compilateur dit "ah, le programmeur a testé ce pointeur contre null, je vais le supprimer juste pour être ennuyeux". Ce qui se passe, c'est que diverses optimisations courantes comme l'inlining et la propagation de la plage de valeurs se combinent pour rendre ces vérifications redondantes, parce qu'elles viennent après une vérification antérieure, ou une déréférence. Si le compilateur sait qu'un pointeur est non nul au point A d'une fonction, et que le pointeur n'est pas modifié avant un point B ultérieur de la même fonction, alors il sait qu'il est également non nul au point B. Lorsque l'inlining se produit, les points A et B peuvent en fait être des morceaux de code qui étaient à l'origine dans des fonctions séparées, mais qui sont maintenant combinés en un seul morceau de code, et le compilateur est capable d'appliquer sa connaissance que le pointeur est non nul à plus d'endroits. Il s'agit d'une optimisation de base, mais très importante, et si les compilateurs ne faisaient pas cela, le code de tous les jours serait considérablement plus lent et les gens se plaindraient de branches inutiles pour tester à nouveau les mêmes conditions à plusieurs reprises.

0 votes

Est-il possible d'instrumenter GCC 6 pour qu'il émette des avertissements à la compilation lorsqu'il rencontre de telles utilisations de this ?

3 votes

@jotik, ^^^ ce que T.C. a dit. Ce serait possible, mais vous recevriez cet avertissement. POUR TOUS LES CODES, TOUT LE TEMPS . La propagation des plages de valeurs est l'une des optimisations les plus courantes, qui affecte presque tout le code, partout. Les optimiseurs ne voient que du code qui peut être simplifié. Ils ne voient pas "un morceau de code écrit par un idiot qui veut être averti si son stupide UB est optimisé". Il n'est pas facile pour le compilateur de faire la différence entre "une vérification redondante que le programmeur veut optimiser" et "une vérification redondante que le programmeur pense utile, mais qui est redondante".

-24voto

Ben Points 14995

La norme C++ est brisée sur des points importants. Malheureusement, plutôt que de protéger les utilisateurs de ces problèmes, les développeurs de GCC ont choisi d'utiliser le comportement non défini comme une excuse pour mettre en œuvre des optimisations marginales, même lorsqu'il leur a été clairement expliqué à quel point il est nuisible.

Ici, une personne beaucoup plus intelligente que moi explique en détail. (Il parle de C mais la situation y est la même).

Pourquoi est-ce nuisible ?

La simple recompilation d'un code sécurisé qui fonctionnait auparavant avec une version plus récente du compilateur peut introduire des vulnérabilités en matière de sécurité. . Bien que le nouveau comportement puisse être désactivé avec un drapeau, les makefiles existants n'ont pas ce drapeau activé, évidemment. Et comme aucun avertissement n'est produit, il n'est pas évident pour le développeur que le comportement auparavant raisonnable a changé.

Dans cet exemple, le développeur a inclus une vérification du dépassement des nombres entiers, en utilisant la fonction assert qui mettra fin au programme si une longueur non valide est fournie. L'équipe GCC a supprimé cette vérification en se basant sur le fait que le débordement d'entier n'est pas défini et que la vérification peut donc être supprimée. Il en résulte que des cas réels de cette base de code ont été rendus vulnérables après que le problème ait été corrigé.

Lisez tout. C'est assez pour vous faire pleurer.

OK, mais qu'en est-il de celui-là ?

Il y a longtemps, il y avait une expression idiomatique assez courante qui ressemblait à ceci :

 OPAQUEHANDLE ObjectType::GetHandle(){
    if(this==NULL)return DEFAULTHANDLE;
    return mHandle;

 }

 void DoThing(ObjectType* pObj){
     osfunction(pObj->GetHandle(), "BLAH");
 }

Donc l'idiome est : Si pObj n'est pas nul, vous utilisez le handle qu'il contient, sinon vous utilisez un handle par défaut. Ceci est encapsulé dans l'élément GetHandle fonction.

Le truc, c'est que l'appel d'une fonction non virtuelle n'utilise pas réellement l'interface de l'utilisateur. this donc il n'y a pas de violation d'accès.

Je ne comprends toujours pas.

Il existe un grand nombre de codes écrits de cette façon. Si quelqu'un le recompile simplement, sans changer une ligne, chaque appel à DoThing(NULL) est un bug de plantage - si vous avez de la chance.

Si vous n'avez pas de chance, les appels aux bogues de plantage deviennent des vulnérabilités d'exécution à distance.

Cela peut se produire même automatiquement. Vous avez un système de construction automatique, non ? Le mettre à jour avec le dernier compilateur est inoffensif, non ? Mais maintenant ça ne l'est plus - pas si votre compilateur est GCC.

OK, dites-leur !

On leur a dit. Ils le font en toute connaissance des conséquences.

mais... pourquoi ?

Qui peut le dire ? Peut-être :

  • Ils privilégient la pureté idéale du langage C++ au détriment du code réel.
  • Ils croient que les gens devraient être punis pour ne pas suivre la norme.
  • Ils ne comprennent pas la réalité du monde.
  • Ils sont... en train d'introduire des bugs exprès. Peut-être pour un gouvernement étranger. Où est-ce que tu vis ? Tous les gouvernements sont étrangers à la plupart du monde, et la plupart sont hostiles à une partie du monde.

Ou peut-être quelque chose d'autre. Qui peut le dire ?

32 votes

Je ne suis pas d'accord avec chaque ligne de la réponse. Les mêmes commentaires ont été faits pour les optimisations strictes de l'aliasing, et on peut espérer qu'ils sont maintenant rejetés. La solution est d'éduquer les développeurs, pas d'empêcher les optimisations basées sur de mauvaises habitudes de développement.

30 votes

J'ai lu l'article en entier, comme vous l'avez dit, et j'ai effectivement pleuré, mais surtout devant la stupidité de Félix, ce qui n'est pas, je pense, ce que vous vouliez faire comprendre...

14 votes

Vous semblez très résolu sur votre position concernant la norme étant brisé . Puis-je vous demander si vous avez un rapport de défaut en cours de route ?

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X