以下の問題で時間を溶かしたので、メモを残しておきます。
概略
おかしな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.2.0(自分でビルドしたもの、およびコンパイラエクスプローラーで確認)
- gcc 10.3.0(コンパイラエクスプローラーで確認)
- gcc 11.1.0(コンパイラエクスプローラーで確認)
問題の発生しないコンパイラ
網羅的に調べたわけではありませんが、おそらく以下のコンパイラでは問題が発生しなさそうです。
問題の発生するオプション
最適化をかけてもかけなくても発生します。 そもそも、機械語生成の誤りというよりはC++コードの解釈の誤りという感じがするので、C++フロントエンドのバグ*4と思われます。
最小再現コード
(2021/12/13 6:00ごろ:さらに短いソースコードでも再現することを確認したので差し換えました。もともと提示していたコードは[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
が一致しこのバグは発生しなくなります。
なお、IF1
にint
を入れずからっぽにすると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
とした値と一致してしまいました。
おそらく、gccはusing IF2::f;
に騙されてしまったのでしょう。
なお、Base::f()
の中でthis
がBase*
から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することで基底クラスの関数を使えなくする」「同名関数をオーバーロードしたが基底クラスの関数も隠したくないため」等のコメントを入れるべきでしょう。