gccにおけるクラス階層ナビゲーションバグ

以下の問題で時間を溶かしたので、メモを残しておきます。

概略

おかしなusing宣言がコンパイラを混乱させ、壊れたバイナリの生成を引き起こすことがあります。 static_castするとポインタの値が変わるような継承構造を持つクラス*1では、基底クラス由来のメンバ関数を呼び出すときにthisポインタの値に適切なオフセットを加減算する機械語コードをコンパイラが生成する必要がある場合がありますが、その計算が正しくないバイナリが生成されます。

呼び出し先の関数ではthisがずれているため、メンバ変数の値が完全におかしく見えます。 例えばポインタであるメンバ変数からその先をたぐってメモリアクセス保護違反になったり、仮想関数を呼び出そうとしたときに仮想関数テーブルを正しく引けずメモリアクセス保護違反になったりします。

デバッグの仕方

ポインタの値がずれると、デバッガからはメモリ破壊バグのように見えます。 しかし、実際にメモリ破壊しているわけではないので、gdbのwatchpoint・valgrind(memcheckおよびexp-sgcheck)・ -fsanitize=addressといったメモリアクセス検査ツールではバグなしと判定されてしまいます。 this周辺のメモリダンプをしてみると、thisポインタの値がずれているのではないかと推測できます。

バグの簡易な説明

仮想関数を含むクラスを二つ以上継承しているクラス*2において、直接の親クラス以外にある仮想関数を対象にusing宣言を行うと、当該仮想関数を呼び出すときのthisポインタずらし計算がバグる、といった感じのようです。

(2021/12/13 6:00ごろ:もともとは「無意味なusing宣言」と書いていましたが、それがないとコンパイルが通らないようなusing宣言であってもこの問題が発生することを確認したため、記述を変更しました。具体的には、基底クラスと同じ名前の関数をオーバーロード*3したが基底クラスの関数を隠すことを意図していない、というときに使うusing宣言で発生することを確認しました。)

問題の発生するgccバージョン

問題の発生しないコンパイラ

網羅的に調べたわけではありませんが、おそらく以下のコンパイラでは問題が発生しなさそうです。

  • gcc 10.1.0 以前
  • gcc 11.2.0 以降
  • clang すべて

問題の発生するオプション

最適化をかけてもかけなくても発生します。 そもそも、機械語生成の誤りというよりはC++コードの解釈の誤りという感じがするので、C++フロントエンドのバグ*4と思われます。

最小再現コード

(2021/12/13 6:00ごろ:さらに短いソースコードでも再現することを確認したので差し換えました。もともと提示していたコードは[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッです。)

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

#include <iostream>

struct IF1 { virtual ~IF1(){} };
struct IF2 {
    virtual void f(){
        std::cout << __PRETTY_FUNCTION__ << ": this=" << this << std::endl;
    }
};

struct Base : IF1, IF2 {
    void f() {
        std::cout << __PRETTY_FUNCTION__ << ": this=" << this << std::endl;
        IF2::f();
    }
};

struct Derived : Base {
    using IF2::f; // This confuses compilers.

    virtual ~Derived() {
        std::cout << __PRETTY_FUNCTION__ << ":"
                  << " this="        << this
                  << " (Base*)this=" << (Base*)this
                  << " (IF1*)this="  << (IF1*)this
                  << " (IF2*)this="  << (IF2*)this
                  << std::endl;
        f();
    }
    // If you want to define below overload, the using declaration is essentially needed.
    // void f(int){}
};

Derived d;

int main() {}

仮想関数を含むクラスを一つだけ継承している場合、この問題は発生しません。 これは、gccが基底クラスの配置を宣言順と逆転させることで仮想関数テーブルへのポインタを統合しようと努力することによります。 例えば、このコードのIF1を仮想関数を持たずintのみ含むクラスにしてみると、(Base*)this(IF2*)thisが一致しこのバグは発生しなくなります。 なお、IF1intを入れずからっぽにするとEBOが効くようになるので、すべてのクラスのアドレスが同じになり、やはりこのバグは発生しなくなります。

期待される答え

virtual Derived::~Derived(): this=0x601c50 (Base*)this=0x601c50 (IF1*)this=0x601c50 (IF2*)this=0x601c58
virtual void Base::f(): this=0x601c50
virtual void IF2::f(): this=0x601c58

Baseクラスの関数を呼ぶので、Baseクラスの中でのthisは、Derivedクラスの中で(Base*)thisとした値になっていると期待します。

実際の答え

virtual Derived::~Derived(): this=0x601c50 (Base*)this=0x601c50 (IF1*)this=0x601c50 (IF2*)this=0x601c58
virtual void Base::f(): this=0x601c58
virtual void IF2::f(): this=0x601c60

Baseクラスの中でのthisの値が、なぜかDerivedクラスの中で(IF2*)thisとした値と一致してしまいました。

おそらく、gccusing IF2::f;に騙されてしまったのでしょう。

なお、Base::f()の中でthisBase*からIF2*に変換されるときの差分(+8 Byte)は正しいですが、Base*の時点でずれてしまっているのでIF2の中でのthisの値もおかしくなっています。

まとめ

直接の親クラス以外のメンバ関数using宣言するのは、プログラマにとっても*5コンパイラにとっても混乱の原因になります。やめましょう。

*1:つまり、多重継承しており、かつEBO(empty base optimization)が効かない場合です。なお、仮想関数を持つクラスを継承している場合、基底クラスは仮想関数テーブルへのポインタを持つため、EBOが効くことはありません。

*2:正確な条件は不明です。仮想関数テーブルへのポインタ(vptrやvfptrと称される)がまとめられないことが条件でしょうか。

*3:オーバーロードとは、引数の型が異なる関数を定義することです。オーバーライドの誤記ではありません。

*4:どのような挙動が正しいのか、私はわかりません。他のコンパイラgccの古いバージョンと新しいバージョンではこの問題が発生しないため、gccに一時的に埋め込まれたバグであると判断しました。

*5:私はそのソースコードの意図も何が起こるのかもわかりませんでした。直接の親クラスのメンバ関数をusing宣言する場合ですら意図が分かりづらいので「基底クラスのこの関数が使えることを明示(usingなしでも動くけれどヘッダファイルだけ見た時に分かりやすいように)」「基底クラスにこの関数があることをチェック(usingなしだと素通ししてADLで誤爆するかもしれないので)」「privateの下でusingすることで基底クラスの関数を使えなくする」「同名関数をオーバーロードしたが基底クラスの関数も隠したくないため」等のコメントを入れるべきでしょう。