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

148

GCC 6 a une nouvelle fonctionnalité d'optimisation : il suppose que ce thisn'est toujours pas nul 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 de pointeurs nulles courantes mais casse également certaines bases de code non conformes (telles que Qt-5, Chromium, KDevelop) . Pour contourner le problème, vous pouvez utiliser -fno-delete-null-pointer-checks. Un code incorrect peut être identifié en utilisant -fsanitize = undefined.

Le document de modification appelle clairement cela comme dangereux car il rompt une quantité surprenante de code fréquemment utilisé.

Pourquoi cette nouvelle hypothèse casserait-elle le code C ++ pratique?Existe-t-il des schémas particuliers où des programmeurs imprudents ou non informés s'appuient sur ce comportement particulier non défini? Je ne peux pas imaginer que quiconque écrive if (this == NULL)parce que ce n'est pas naturel.

boot4life
la source
21
@Ben J'espère que vous le pensez d'une bonne manière. Le code avec UB doit être réécrit pour ne pas invoquer UB. C'est aussi simple que ça. Heck, il existe souvent des FAQ qui vous expliquent comment y parvenir. Donc, pas un vrai problème à mon humble avis. Tout bon.
Réintégrer Monica le
49
Je suis étonné de voir des gens défendre le déréférencement des pointeurs nuls dans le code. Simplement extraordinaire.
SergeyA
19
@Ben, expliquer les comportements indéfinis est la tactique d'optimisation très efficace depuis très longtemps. J'adore, car j'aime les optimisations qui accélèrent l'exécution de mon code.
SergeyA
17
Je suis d'accord avec SergeyA. Tout le brouhaha a commencé parce que les gens semblent s'attarder sur le fait que cela thisest passé comme un paramètre implicite, alors ils commencent à l'utiliser comme s'il s'agissait d'un paramètre explicite. Ce n'est pas. Lorsque vous déréférencez un null this, vous invoquez UB comme si vous déréférençiez tout autre pointeur null. C'est tout ce qu'il y a à faire. Si vous souhaitez transmettre des nullptrs, utilisez un paramètre explicite, DUH . Ce ne sera pas plus lent, ce ne sera pas plus maladroit, et le code qui a une telle API est de toute façon profondément dans les internes, donc a une portée très limitée. Fin de l'histoire je pense.
Réintégrer Monica le
41
Félicitations à GCC pour avoir brisé le cycle du mauvais code -> compilateur inefficace pour supporter le mauvais code -> plus de mauvais code -> compilation plus inefficace -> ...
MM

Réponses:

87

Je suppose que la question à laquelle il faut répondre pourquoi des gens bien intentionnés écriraient les chèques en premier lieu.

Le cas le plus courant 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 ++, c'est bien 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();
}

Au début du C ++ (avant la standardisation), il était souligné que les fonctions membres étaient du sucre syntaxique pour une fonction où le thisparamètre est implicite. Le code a été écrit en C ++, converti en C équivalent et compilé. Il y avait même des exemples explicites que la comparaison thisà null était significative et le compilateur Cfront d'origine en a également profité. Donc, venant d'un fond C, le choix évident pour le contrôle est:

if(this == nullptr) return;      

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

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

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

  • Faites 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 également vérifier sur CHAQUE site d'appel si vous pouvez avoir une racine nulle.

  • N'utilisez pas de 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 en tant que paramètre. par exemple. vous êtes de retour à l'écriture Node::traverse_in_order(node);plutôt que node->traverse_in_order();sur le site d'appel.

  • Je pense que le moyen le plus simple / le plus soigné de corriger cet exemple particulier d'une manière conforme aux normes est d'utiliser en fait 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 aussi attrayante, et bien que le code puisse s'en tirer, ils ont écrit du mauvais code avec this == nullptr au lieu d'utiliser un correctif approprié.

Je suppose que c'est ainsi que certaines de ces bases de code ont évolué pour avoir des this == nullptrvérifications.

jtlim
la source
6
Comment peut 1 == 0être un comportement indéfini? C'est tout simplement false.
Johannes Schaub - litb
11
Le contrôle lui-même n'est pas un comportement indéfini. C'est juste toujours faux, et donc éliminé par le compilateur.
SergeyA
15
Hmm .. this == nullptridiome est un comportement indéfini car vous avez appelé une fonction membre sur un objet nullptr avant cela, ce qui n'est pas défini. Et le compilateur est libre d'omettre la vérification
jtlim
6
@Joshua, le premier standard a été publié en 1998. Ce qui s'est passé avant c'était ce que chaque implémentation voulait. Temps sombres.
SergeyA
26
Hé, wow, je ne peux pas croire que quiconque ait jamais écrit du code reposant sur l'appel de fonctions d'instance ... sans instance . J'aurais instinctivement utilisé l'extrait marqué "Faites toutes les vérifications avant d'appeler traverse_in_order", sans même penser à thisjamais être nullable. J'imagine que c'est peut-être l'avantage d'apprendre le C ++ à une époque où le SO existe pour ancrer les dangers d'UB dans mon cerveau et me dissuader de faire des hacks bizarres comme celui-ci.
underscore_d
65

Il le fait parce que le code "pratique" était cassé et impliquait un comportement indéfini au départ. Il n'y a aucune raison d'utiliser une valeur nulle this, autre que comme une micro-optimisation, généralement très prématurée.

C'est une pratique dangereuse, car l' ajustement des pointeurs en raison de la traversée de la hiérarchie des classes peut transformer un null thisen un non nul. Donc, à tout le moins, la classe dont les méthodes sont censées fonctionner avec un null thisdoit être une classe finale sans classe de base: elle ne peut dériver de rien, et elle ne peut pas être dérivée. Nous passons rapidement de la pratique au laide-hack-land .

Concrètement, le code n'a pas à être moche:

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, c'est-à-dire null Node* root, vous n'êtes pas censé appeler de méthodes non statiques dessus. Période. C'est parfaitement bien d'avoir un code arborescent de type C qui prend un pointeur d'instance par un paramètre explicite.

L'argument ici 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. Il n'y a pas un tel besoin. La manière d'écrire un tel code en C-avec-objets est toujours plus agréable dans le monde C ++, car elle peut au moins être de type sécurisé. Fondamentalement, le nul thisest une telle micro-optimisation, avec un champ d'utilisation si étroit, que l'interdire est parfaitement bien à mon humble avis. Aucune API publique ne doit dépendre d'une valeur nulle this.

Réintégrer Monica
la source
18
@Ben, Celui qui a écrit ce code s'est trompé en premier lieu. C'est drôle que vous appeliez des projets aussi terriblement cassés que MFC, Qt et Chromium. Bon débarras avec eux.
SergeyA
19
@Ben, les styles de codage terribles dans Google me sont bien connus. Le code de Google (au moins accessible au public) est souvent mal écrit, bien que plusieurs personnes pensent que le code de Google est l'exemple brillant. Cela les incitera peut-être à revoir leurs styles de codage (et leurs directives pendant qu'ils y sont).
SergeyA
18
@Ben Personne ne remplace rétroactivement Chromium sur ces appareils par Chromium compilé à l'aide de gcc 6. Avant que Chromium ne soit compilé à l'aide de gcc 6 et d'autres compilateurs modernes, il devra être corrigé. Ce n'est pas non plus une tâche énorme; les thischèques sont sélectionnés par divers analyseurs de code statique, ce n'est donc pas comme si quelqu'un devait tous les rechercher manuellement. Le patch serait probablement quelques centaines de lignes de changements insignifiants.
Réintégrer Monica le
8
@Ben En termes pratiques, un thisdéréférencement nul est un crash instantané. Ces problèmes seront découverts très rapidement même si personne ne se soucie d'exécuter un analyseur statique sur le code. C / C ++ suit le mantra «payer uniquement pour les fonctionnalités que vous utilisez». Si vous voulez des vérifications, vous devez être explicite à leur sujet et cela signifie ne pas les faire this, quand il est trop tard, car le compilateur suppose qu'il thisn'est pas nul. Sinon, il faudrait vérifier this, et pour 99,9999% du code, ces vérifications sont une perte de temps.
Réintégrer Monica le
10
mon conseil à tous ceux qui pensent que la norme n'est pas conforme: utilisez une langue différente. Il ne manque pas de langages de type C ++ qui n'ont pas la possibilité d'un comportement indéfini.
MM
35

Le document de modification appelle clairement cela comme dangereux car il rompt une quantité surprenante de code fréquemment utilisé.

Le document ne l'appelle pas dangereux. Il ne prétend pas non plus qu'il casse une quantité surprenante de code . Il souligne simplement quelques bases de code populaires dont il prétend être connu pour s'appuyer sur ce comportement non défini et qui se briseraient en raison du changement à moins que l'option de contournement ne soit utilisée.

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

Si le code C ++ pratique repose sur un comportement non défini, les modifications apportées à ce comportement non défini peuvent le casser. C'est pourquoi UB doit être évité, même lorsqu'un programme qui en dépend semble fonctionner comme prévu.

Existe-t-il des schémas particuliers où des programmeurs imprudents ou non informés s'appuient sur ce comportement non défini particulier?

Je ne sais pas si c'est un anti- modèle largement répandu , mais un programmeur non informé pourrait penser qu'il peut réparer son programme de planter en faisant:

if (this)
    member_variable = 42;

Lorsque le bogue réel déréférence un pointeur nul ailleurs.

Je suis sûr que si le programmeur n'est pas suffisamment informé, il pourra proposer des (anti) modèles plus avancés qui reposent sur cet UB.

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

Je peux.

Eerorika
la source
11
"Si le code C ++ pratique repose sur un comportement non défini, alors les modifications apportées à ce comportement non défini peuvent le casser. C'est pourquoi UB doit être évité" this * 1000
underscore_d
if(this == null) PrintSomeHelpfulDebugInformationAboutHowWeGotHere(); Comme un joli journal facile à lire d'une séquence d'événements dont un débogueur ne peut pas facilement vous parler. Amusez-vous à déboguer maintenant sans passer des heures à placer des vérifications partout quand il y a un null aléatoire soudain dans un grand ensemble de données, dans du code que vous n'avez pas écrit ... Et la règle UB à ce sujet a été faite plus tard, après la création de C ++. C'était valide.
Stephane Hockenhull
@StephaneHockenhull C'est pour ça -fsanitize=null.
eerorika
@ user2079303 Problèmes: est-ce que cela va ralentir le code de production au point où vous ne pouvez pas laisser l'enregistrement pendant l'exécution, ce qui coûte beaucoup d'argent à l'entreprise? Cela va-t-il augmenter la taille et ne pas rentrer dans le flash? Cela fonctionne-t-il sur toutes les plates-formes cibles, y compris Atmel? Peut-on -fsanitize=nullenregistrer les erreurs sur la carte SD / MMC sur les broches # 5,6,10,11 en utilisant SPI? Ce n'est pas une solution universelle. Certains ont fait valoir que l'accès à un objet nul allait à l'encontre des principes orientés objet, mais certains langages OOP ont un objet nul sur lequel il est possible de fonctionner, ce n'est donc pas une règle universelle de la POO. 1/2
Stephane Hockenhull
1
... une expression régulière qui correspond à de tels fichiers? Dire que, par exemple, si une lvalue est accédée deux fois, un compilateur peut consolider les accès à moins que le code entre eux ne fasse l'une de plusieurs choses spécifiques serait beaucoup plus facile que d'essayer de définir les situations précises dans lesquelles le code est autorisé à accéder au stockage.
supercat
25

Une partie du code "pratique" (façon amusante d'épeler "buggy") qui a été cassé ressemblait à ceci:

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

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

Pas tout le code qui a été brisé contenu explicite if (this == nullptr)ou les if (!p) return;chèques. Certains cas étaient simplement des fonctions qui n'accédaient à aucune variable membre, et qui semblaient donc 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 à appeler p->DummyImpl::valid(), mais en fait, cette fonction membre retourne simplement falsesans accéder *this. Cela return falsepeut être intégré et donc, dans la pratique, le pointeur n'a pas du tout besoin d'être accédé. Donc, avec certains compilateurs, cela semble fonctionner correctement: il n'y a pas de segfault pour déréférencer 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 crash 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 qui pdoit être non-null (sinon ce p->valid()serait un comportement non défini) et prend note de cette information. Ces informations déduites sont utilisées par l'optimiseur de sorte que si l'appel à do_something_else(p)est incorporé, la if (p)vérification est maintenant considérée comme redondante, car le compilateur se souvient qu'elle n'est pas nulle, et donc intègre 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);
  }
}

Cela fait maintenant vraiment déréférencer un pointeur nul, et donc le code qui semblait auparavant fonctionner cesse de fonctionner.

Dans cet exemple, le bogue est présent func, qui aurait dû d'abord vérifier 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 comme celle-ci 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 telles que l'inlining et la propagation de la plage de valeurs se combinent pour rendre ces vérifications redondantes, car elles surviennent 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 dans une fonction et que le pointeur n'est pas modifié avant un point B ultérieur dans la même fonction, alors il sait qu'il est également non nul en 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 à plusieurs endroits.

Jonathan Wakely
la source
Est-il possible d'instrumenter GCC 6 pour générer des avertissements au moment de la compilation lorsqu'il rencontre de telles utilisations de this?
jotik
3
@jotik, ^^^ ce que TC a dit. Ce serait possible, mais vous obtiendrez cet avertissement POUR TOUS LES CODE, TOUT LE TEMPS . La propagation de la plage de valeurs est l'une des optimisations les plus courantes, qui affecte presque tout le code, partout. Les optimiseurs voient juste le code, qui peut être simplifié. Ils ne voient pas "un morceau de code écrit par un idiot qui veut être averti si leur UB stupide est optimisé". Il n'est pas facile pour le compilateur de faire la différence entre "vérification redondante que le programmeur veut être optimisé" et "vérification redondante que le programmeur pense utile, mais redondante".
Jonathan Wakely
1
Si vous souhaitez instrumenter votre code pour donner des erreurs d' exécution pour divers types d'UB, y compris des utilisations non valides de this, alors utilisez simplement-fsanitize=undefined
Jonathan Wakely
-25

La norme C ++ est rompue de manière importante. Malheureusement, plutôt que de protéger les utilisateurs de ces problèmes, les développeurs de GCC ont choisi d'utiliser un comportement indéfini comme excuse pour implémenter des optimisations marginales, même lorsqu'il leur a été clairement expliqué à quel point il est dangereux.

Ici, une personne beaucoup plus intelligente que je ne l'explique en détail. (Il parle de C mais la situation est la même ici).

Pourquoi est-ce nocif?

Recompiler simplement du code sécurisé précédemment fonctionnel avec une version plus récente du compilateur peut introduire des vulnérabilités de sécurité . Bien que le nouveau comportement puisse être désactivé avec un indicateur, les fichiers makefiles existants n'ont pas cet indicateur défini, évidemment. Et comme aucun avertissement n'est produit, il n'est pas évident pour le développeur que le comportement précédemment raisonnable a changé.

Dans cet exemple, le développeur a inclus une vérification du dépassement d'entier, en utilisant assert, qui mettra fin au programme si une longueur non valide est fournie. L'équipe GCC a supprimé la vérification sur la base que le débordement d'entier n'est pas défini, donc la vérification peut être supprimée. Cela a abouti à la redéfinition de la vulnérabilité des instances réelles de cette base de code après la résolution du problème.

Lisez le tout. C'est assez pour te faire pleurer.

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

Il y a longtemps, il y avait un idiome assez courant 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 pObjn'est pas nul, vous utilisez le handle qu'il contient, sinon vous utilisez un handle par défaut. Ceci est encapsulé dans la GetHandlefonction.

L'astuce est que l'appel d'une fonction non virtuelle n'utilise en fait aucun this pointeur, donc il n'y a pas de violation d'accès.

Je ne comprends toujours pas

Il existe beaucoup de code écrit comme ça. Si quelqu'un le recompile simplement, sans changer de ligne, chaque appel à DoThing(NULL)est un bug qui plante - si vous êtes chanceux.

Si vous n'êtes pas chanceux, les appels aux bogues qui se bloquent deviennent des vulnérabilités d'exécution à distance.

Cela peut se produire même automatiquement. Vous avez un système de construction automatisé, non? Le mettre à niveau vers le dernier compilateur est inoffensif, non? Mais maintenant ce n'est pas le cas - pas si votre compilateur est GCC.

OK alors dis-leur!

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

mais pourquoi?

Qui peut dire? Peut-être:

  • Ils valorisent la pureté idéale du langage C ++ par rapport au code réel
  • Ils croient que les gens devraient être punis pour ne pas suivre la norme
  • Ils n'ont aucune compréhension de la réalité du monde
  • Ils ... introduisent des bogues exprès. Peut-être pour un gouvernement étranger. Où habitez-vous? Tous les gouvernements sont étrangers à la plupart des pays du monde et la plupart sont hostiles à une partie du monde.

Ou peut-être autre chose. Qui peut dire?

Ben
la source
32
Pas d'accord avec chaque ligne de réponse. Les mêmes commentaires ont été faits pour les optimisations strictes d'aliasing, et nous espérons que ceux-ci 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.
SergeyA
30
Je suis allé tout lire comme vous l'avez dit, et en effet j'ai pleuré, mais surtout à la stupidité de Félix que je ne pense pas être ce que vous essayiez de faire passer ...
Mike Vine
33
Downvoté pour la diatribe inutile. "Ils ... introduisent des bogues exprès. Peut-être pour un gouvernement étranger." Vraiment? Ce n'est pas / r / conspiration.
isanae
31
Les programmeurs décents répètent encore et encore le mantra n'invoquant pas de comportement indéfini , mais ces nonks ont continué et l'ont fait de toute façon. Et regardez ce qui s'est passé. Je n’ai aucune sympathie. C'est la faute des développeurs, aussi simple que cela. Ils doivent prendre leurs responsabilités. Tu te souviens de ça? Responsabilité personnelle? Les gens se fient à votre mantra "mais qu'en est-il en pratique !" c'est précisément comment cette situation s'est produite en premier lieu. Éviter de telles absurdités est précisément la raison pour laquelle les normes existent en premier lieu. Code aux normes, et vous n'aurez pas de problème. Période.
Courses de légèreté en orbite le
18
"Recompiler simplement du code sécurisé qui fonctionnait auparavant avec une version plus récente du compilateur peut introduire des failles de sécurité" - cela se produit toujours . À moins que vous ne vouliez exiger qu'une version d'un compilateur soit le seul compilateur qui sera autorisé pour le reste de l'éternité. Vous souvenez-vous quand le noyau Linux ne pouvait être compilé qu'avec exactement gcc 2.7.2.1? Le projet gcc a même été fourchu parce que les gens en avaient assez des conneries. Il a fallu beaucoup de temps pour surmonter cela.
MM