GitHubで公開しているC++参考書に、以下のようなpull requestが送られてきた。
Pure virtual function by daisukekoba · Pull Request #153 · EzoeRyou/cpp-book
純粋virtual関数は、宣言を分ければ、定義も持てるそうだ。実際にまともなC++コンパイラーでコンパイルしてみると、たしかにその通りだ。
どうやら、私の規格の文面の解釈が間違っていたらしい。
C++の規格に曰く、
10.4 paragraph 2
A function declaration cannot provide both a pure-specifier and a definition.
ひとつの関数宣言はpure指定子と定義の両方を提供することができない。
これを読むと、以下のコードがエラーになることがわかる。
struct S { // エラー、pure-specifierと定義を両方持つ virtual void f() = 0 { } } ;
しかし、私は「ひとつの」という部分を見落としていた。つまり、複数の関数宣言を使えば、両方とも持てるのだ。
struct S { // pure-specifier virtual void f() = 0 ; } ; // 定義 void S::f() { }
規格の文面を正しく解釈すると、このコードは正しいはずで、実際に、既存のC++コンパイラーはこのコードを通す。
これをなぜかと考えると、abstract classでも、デストラクターは定義したいという利用例がある。
class Base { int * ptr ; public : Base() : ptr( new int(0) ) { } // コピーとムーブの特別なメンバーの宣言など virtual ~Base() = 0 ; } ; Base::~Base() { delete ptr ; } class Derived : public Base { virtual ~Derived() { } } ;
なるほど、もちろん、このように書きたい。すると、pure virtual functionかつ定義をもつというデストラクターは、至極当然のように思われる。デストラクターに許されているのだから、一貫性を保つために、普通の非staticメンバー関数でも、当然許されるべきだ。
しかし、このことについて色々と試していた結果、以下のようなコードが書き上がってしまった。
struct Base { virtual void f() = 0 ; virtual ~Base() = 0 ; } ; void Base::f() { } Base::~Base() { // Derivedはすでに破棄されている // デストラクター呼び出しは警告、実行時abort、ともになし。 // GCC, Clang、ともにコンパイル時警告 // GCCでは問題なく実行可能 // Clangでは実行時に純粋仮想関数呼び出しエラーで意図的にabort f() ; // Base::fを呼ぶ } struct Derived : Base { virtual void f() { } virtual ~Derived() { } } ; int main() { Derived d ; }
なんと、GCCとClangでは挙動が違っているではないか。しかも、GCCでは問題なく実行できるのに対し、Clangでは実行時エラーとしてabortするという、実行時にまで影響を呼ぼす差異だ。これはどちらかのコンパイラーが間違っているのではないか。しかし、どちらの挙動が正しいのか。
私の当初の間違った考えでは、デストラクターも純粋仮想関数かつ定義をもつのに、警告も実行時abortもないため、どうも一貫性にかける。これは、コンパイラーがおかしいのではないか、とくに、Clangがおかしいのではないかと考えた。
久しぶりに、よくよく考えてもわからない問題だったので、私よりもっとできる人に聞いてみた。つまり、C++WGのMLにメールを投げた。すると、私の考えは全く持って見当違いだったことが明らかになった。
12.7で規定されているように、ポリモーフィックなオブジェクトの構築時、破棄時は、あたかもそのオブジェクトが最終的なオーバーライダーであるかのように振る舞う。Baseのデストラクター内で呼ばれている未修飾名fが、Base::fを呼ぶのはそのためだ。しかし問題は、未修飾名で呼び出すので仮想関数呼び出しになるということだ。仮想関数呼び出しで純粋仮想関数が呼び出された場合、挙動は未定義である。たとえ定義があろうとも、Base::fは純粋仮想関数であることに変わりはない。純粋仮想関数である以上、仮想関数呼び出しで呼び出された場合、挙動は未定義となる。未定義である以上、何が起きても文句は言えない。
そのため、この場合にBase::fを呼び出したい場合は、修飾名で呼び出して、仮想関数呼び出しを避けなければならない。
Base::~Base() { Base::f() ; // OK }
しかし、デストラクターはどうなのだ。なぜデストラクターにはコンパイル時警告も実行時エラーもないのだ。それはなぜかというと、デストラクター呼び出しは、たとえ仮想関数であっても、派生クラスから明示的に基本クラスのデストラクターが呼び出されたかのように振る舞うのだ。だから、仮想関数呼び出しではない。仮想関数呼び出しではない以上、問題はない。
したがって、GCCとClangの挙動は、どちらとも正しい。規格上未定義なのだから、何が起きても文句は言えない。ただし、GCCはコンパイル時に警告を出しているので、かろうじて問題がわかる程度だが、Clangは実行時にabortを出しているので、とても親切である。だからといって、GCCが規格違反の実装というわけではない。
ああ、まだまだ未熟だ。
これ、デストラクタから普通のメンバ関数呼んでるように見えても
ReplyDelete呼び出し先のどこかで仮想関数呼んでしまったら未定義の動作になるんですよね・・・
仮想デストラクタで何か呼んでるの見るだけで不安になれるC++の嫌な仕様だと思う
いつも有用な情報をありがとうございます.
ReplyDelete規格に詳しくないので教えてください.
function declaration (simple-declaration) はそもそも function-body を持つことができないのではないでしょうか.
「ひとつ」とか「複数」とか関係なく.