web-dev-qa-db-ja.com

拡張されたGCC 6オプティマイザーが実用的なC ++コードを壊すのはなぜですか?

GCC 6には新しいオプティマイザー機能がありますthisは常にnullではないと想定し、それに基づいて最適化します。

値範囲の伝播では、C++メンバー関数のthisポインターがnullでないと仮定するようになりました。これにより、一般的なヌルポインターチェックが不要になりますが、一部の不適合なコードベース(Qt-5、Chromium、KDevelopなど)も破損します。一時的な回避策として、-fno-delete-null-pointer-checksを使用できます。間違ったコードは、-fsanitize = undefinedを使用して特定できます。

変更文書は、頻繁に使用される驚くべき量のコードを破壊するため、これを明らかに危険と呼びます。

この新しい仮定が実用的なC++コードを破壊する理由は何ですか?不注意なプログラマーや知識のないプログラマーがこの特定の未定義の動作に依存する特定のパターンはありますか? if (this == NULL)を書く人は想像できません。それはとても不自然だからです。

149
boot4life

そもそも善意の人々が小切手を書くのはなぜ答えが必要な質問だと思います。

最も一般的なケースは、おそらく、自然に発生する再帰呼び出しの一部であるクラスがある場合です。

あなたが持っていた場合:

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

cでは、次のように記述できます。

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

C++では、これをメンバー関数にすると便利です。

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

C++の初期(標準化前)では、そのメンバー関数はthisパラメーターが暗黙的である関数の構文糖であることが強調されていました。コードはC++で記述され、同等のCに変換され、コンパイルされました。 thisをnullと比較することが意味のある明示的な例さえあり、元のCfrontコンパイラもこれを利用しました。したがって、Cのバックグラウンドから来て、チェックの明白な選択は次のとおりです。

if(this == nullptr) return;      

注:Bjarne Stroustrupは、thisのルールが長年にわたって変更されていることにも言及しています here

そして、これは長年にわたって多くのコンパイラで機能していました。標準化が行われたとき、これは変わりました。さらに最近では、コンパイラはthisであるnullptrが未定義の動作であるメンバー関数の呼び出しを利用し始めました。つまり、この条件は常にfalseであり、コンパイラはそれを自由に省略できます。

つまり、このツリーを走査するには、次のいずれかが必要です。

  • traverse_in_orderを呼び出す前にすべてのチェックを行います

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

    これは、nullルートがある可能性があるかどうか、すべての呼び出しサイトで確認することも意味します。

  • メンバー関数を使用しないでください

    これは、古いCスタイルコード(おそらく静的メソッド)を記述し、オブジェクトとしてパラメーターとして明示的に呼び出すことを意味します。例えば。呼び出しサイトでNode::traverse_in_order(node);ではなくnode->traverse_in_order();を書くことに戻ります。

  • この特定の例を標準に準拠した方法で修正する最も簡単/最も簡単な方法は、nullptrではなく実際にセンチネルノードを使用することです。

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

最初の2つのオプションはどちらも魅力的ではないようであり、コードはそれを回避できますが、適切な修正を使用する代わりにthis == nullptrで不正なコードを作成しました。

それが、これらのコードベースのいくつかがthis == nullptrチェックを含むように進化した方法だと推測しています。

87
jtlim

「実用的な」コードが壊れていて、そもそも未定義の動作が含まれていたためです。 null thisを使用する理由はありません。ただし、通常は非常に時期尚早な最適化として使用します。

クラス階層のトラバースによるポインタの調整 はnull thisを非nullに変えることができるため、これは危険な慣行です。そのため、少なくとも、メソッドがnull thisで動作することになっているクラスは、基底クラスのない最終クラスでなければなりません。私たちはすぐに実用的なものから gly-hack-land に移行しています。

実際には、コードはい必要はありません。

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

空のツリー(ルートがnullptrなど)の場合、このソリューションは、nullptrを使用してtraverse_in_orderを呼び出すことにより、未定義の動作に依存しています。

ツリーが空の場合、別名null Node* rootの場合、非静的メソッドを呼び出すことは想定されていません。期間。明示的なパラメーターによってインスタンスポインターを取得するCのようなツリーコードを使用することはまったく問題ありません。

ここでの引数は、nullインスタンスポインターから呼び出すことができるオブジェクトに非静的メソッドを何らかの形で記述する必要があるということに要約されるようです。そのような必要はありません。このようなコードを記述するC-with-objectsの方法は、少なくともタイプセーフであるため、C++の世界ではまだはるかに優れています。基本的に、null thisは非常に狭い最適化であり、使用範囲が狭いため、それを拒否することは完全に問題ありません。 null thisに依存するパブリックAPIはありません。

65
Kuba Ober

変更文書は、頻繁に使用される驚くべき量のコードを破壊するため、これを明らかに危険と呼びます。

この文書は危険とは呼んでいない。また、驚くべき量のコードを壊すと主張していません。この未定義の動作に依存することが知られていると主張するいくつかの人気のあるコードベースを単に指摘し、回避策オプションが使用されない限り、変更のために壊れます。

この新しい仮定が実用的なC++コードを壊すのはなぜですか?

practicalc ++コードが未定義の動作に依存している場合、その未定義の動作に変更を加えると破損する可能性があります。これが、UBに依存するプログラムが意図したとおりに動作しているように見える場合でも、UBを回避する理由です。

不注意なまたは知識のないプログラマがこの特定の未定義の動作に依存する特定のパターンはありますか?

anti-patternが広まっているかどうかはわかりませんが、知識のないプログラマーは、以下を行うことでプログラムのクラッシュを修正できると考えるかもしれません。

if (this)
    member_variable = 42;

実際のバグが別の場所でNULLポインターを逆参照している場合。

プログラマーに十分な情報がない場合、このUBに依存するより高度な(アンチ)パターンを思い付くことができると確信しています。

if (this == NULL)を書く人は想像できません。それはとても不自然だからです。

私は出来ます。

35
eerorika

壊れた「実用的」(「バギー」を綴る面白い方法)コードの一部は次のようになりました。

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

また、p->bar()がnullポインタを返すことがあるという事実を説明するのを忘れていました。つまり、baz()を呼び出すために逆参照することは未定義です。

破損したすべてのコードに明示的なif (this == nullptr)またはif (!p) return;チェックが含まれているわけではありません。いくつかのケースは、メンバー変数にアクセスしない単純な関数であったため、appearedは正常に機能します。例えば:

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

このコードでは、func<DummyImpl*>(DummyImpl*)をnullポインターで呼び出すと、p->DummyImpl::valid()を呼び出すポインターの「概念的な」逆参照がありますが、実際には、メンバー関数は*thisにアクセスせずにfalseを返します。 return falseはインライン化できるため、実際にはポインターにアクセスする必要はまったくありません。そのため、一部のコンパイラでは正常に動作しているように見えます。nullを間接参照するセグメンテーション違反はありません。p->valid()はfalseなので、コードはdo_something_else(p)を呼び出し、nullポインタをチェックし、何もしません。クラッシュや予期しない動作は観察されません。

GCC 6でもp->valid()の呼び出しを取得できますが、コンパイラーはその式からpが非ヌルでなければならないと推論し(そうでなければp->valid()は未定義の動作になります)、その情報をメモします。推論された情報はオプティマイザーによって使用されるため、do_something_else(p)への呼び出しがインライン化された場合、if (p)チェックは冗長であると見なされるようになりました。 :

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

これにより、実際にはヌルポインターが逆参照されるため、以前は動作していたコードが機能しなくなります。

この例では、バグはfuncにあり、最初にnullをチェックする必要があります(または、呼び出し側がnullで呼び出したことはないはずです)。

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

覚えておくべき重要な点は、このような最適化のほとんどは、コンパイラーが「ああ、プログラマーはこのポインターをnullに対してテストしたので、迷惑になるように削除する」というものではないということです。起こるのは、インライン化や値の範囲の伝播などのさまざまな実行の最適化が組み合わされて、それらのチェックが以前のチェックまたは逆参照の後に来るため、冗長になります。コンパイラーは、ポインターが関数のポイントAで非ヌルであると認識し、ポインターが同じ関数内の後続のポイントBの前に変更されない場合、Bでもポインターが非ヌルであることを認識します。ポイントAおよびBは、実際には元々別々の関数にあったコードの一部である可能性がありますが、現在は1つのコードに結合されており、コンパイラはポインターがより多くの場所で非NULLであるという知識を適用できます。これは基本ですが、非常に重要な最適化であり、コンパイラーがそれを行わなかった場合、毎日のコードはかなり遅くなり、同じ条件を繰り返し再テストするために不要なブランチについて不満を言うでしょう。

25
Jonathan Wakely