C++
clang

仮想デストラクタを持たない基底クラスにキャストしてdeleteした場合の動作は未定義である

JIS X 3014:2003 5.3.5 delete式

演算対象の静的な型がその動的な型と異なる場合、その静的な型は、演算対象の動的な型の基底クラスでなければならず、仮想デストラクタをもっていなければならない。そうでない場合の動作は、未定義とする。

未定義動作だったのか。
てっきり「派生クラスのデストラクタが呼ばれない」だけだと思いこんでいた。

実験

「未定義動作」を「シグナル送出」にしてくれることに定評のあるClangを使って実験してみたところ、

  • 純粋仮想関数を持っている
  • 仮想デストラクタを持っていない

以上の条件を満たすクラスBを継承した派生クラスDをnewし、基底クラスBにキャストしてdeleteするとシグナルが送出されることを確認した。
純粋仮想関数を持っているクラスBはnewできない以上、クラスBのポインタに対するdeleteは必ず“演算対象の静的な型がその動的な型と異なる”の条件に適合するので、deleteは確実に未定義動作になると判断できる、というわけだ。

実験コード

b.h
class B
{
public:
    ~B();
    virtual int func() = 0;
};

class D : public B
{
public:
    virtual int func();
};
b.cpp
#include "b.h"

B::~B() {}

int D::func()
{
    return 1;
}
a.cpp
#include "b.h"

int main()
{
    B* b = new D;
    b->func();
    delete b;
    return 0;
}

実験手順と結果

  1. Clangのバージョンを確認
  2. -O3 -Wall指定で実験コードをコンパイル
  3. 実行ファイルを起動
$ clang++ --version
Apple LLVM version 9.1.0 (clang-902.0.39.1)
Target: x86_64-apple-darwin17.5.0
Thread model: posix
InstalledDir: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin
$ clang++ -O3 -Wall a.cpp b.cpp 
a.cpp:7:5: warning: delete called on 'B' that is abstract but has non-virtual destructor [-Wdelete-non-virtual-dtor]
    delete b;
    ^
1 warning generated.
$ ./a.out 
Illegal instruction: 4

macOS 10.13.4 + Xcode 9.3の環境ではSIGILL(SIGNAL 4)が送出された。

Clangが生成したコード(抜粋)

_main:
100000eb0:  55  pushq   %rbp
100000eb1:  48 89 e5    movq    %rsp, %rbp
100000eb4:  41 56   pushq   %r14
100000eb6:  53  pushq   %rbx
100000eb7:  bf 08 00 00 00  movl    $8, %edi
100000ebc:  e8 87 00 00 00  callq   135
100000ec1:  48 89 c3    movq    %rax, %rbx
100000ec4:  48 8d 05 65 01 00 00    leaq    357(%rip), %rax
100000ecb:  48 83 c0 10     addq    $16, %rax
100000ecf:  48 89 03    movq    %rax, (%rbx)
100000ed2:  48 89 df    movq    %rbx, %rdi
100000ed5:  e8 56 00 00 00  callq   86 <D::func()>
100000eda:  48 89 df    movq    %rbx, %rdi
100000edd:  e8 3e 00 00 00  callq   62 <B::~()>
100000ee2:  48 89 df    movq    %rbx, %rdi
100000ee5:  e8 58 00 00 00  callq   88
100000eea:  31 c0   xorl    %eax, %eax
100000eec:  5b  popq    %rbx
100000eed:  41 5e   popq    %r14
100000eef:  5d  popq    %rbp
100000ef0:  c3  retq
100000ef1:  49 89 c6    movq    %rax, %r14
100000ef4:  48 89 df    movq    %rbx, %rdi
100000ef7:  e8 46 00 00 00  callq   70
100000efc:  4c 89 f7    movq    %r14, %rdi
100000eff:  e8 38 00 00 00  callq   56
100000f04:  90  nop
100000f05:  90  nop
100000f06:  90  nop
100000f07:  90  nop
100000f08:  90  nop
100000f09:  90  nop
100000f0a:  90  nop
100000f0b:  90  nop
100000f0c:  90  nop
100000f0d:  90  nop
100000f0e:  90  nop
100000f0f:  90  nop

B::~():
100000f10:  55  pushq   %rbp
100000f11:  48 89 e5    movq    %rsp, %rbp
100000f14:  5d  popq    %rbp
100000f15:  c3  retq
100000f16:  66 2e 0f f 84 00 00 00 00 00    nopw    %cs:(%rax,%rax)

B::~():
100000f20:  55  pushq   %rbp
100000f21:  48 89 e5    movq    %rsp, %rbp
100000f24:  0f 0b   ud2
100000f26:  66 2e 0f f 84 00 00 00 00 00    nopw    %cs:(%rax,%rax)

Clangが生成したコードについての見解

2つある B::~() のうち、100000f10は派生クラスのデストラクタから間接的に呼ばれるなどの正常系用のコードであり、100000f20は前述した未定義動作用のコードである。100000f20の方が呼ばれると、100000f24のud2によってSIGILLやらSIGTRAPやらが送出される。

一見、未定義動作であることを伝えるためだけに資源効率性品質を犠牲にしているようにも見えるが、適切なソースコード品質管理下においては仮想関数を持つクラスが仮想デストラクタを持たないままリリースされることはなく、未定義動作用のコードが残ることはないので問題ない。

別にコンパイラーは、未定義動作になる場合でも100000f10の方を呼ぶようにしても良い(そうすると、私が勘違いしていたような「派生クラスのデストラクタが呼ばれない」挙動になる)のだが、Clangはこのような未定義動作を呼び起こしてしまうようなプログラマーに対して「プログラマーが仕様を正しく理解しないで実装するとか有り得ないよね?」とわざわざ指摘するような作りになっている。「仕様に沿わないけれども動けばいい、リスク管理も必要ない」という考えのプログラマーが多いと、業界全体において保守性/移植性品質を維持するコストが高くなっていくため、コンパイラーがこういう姿勢でいてくれるのはなかなか心強い。
もっとも、実行時にSIGILLやSIGTRAPが送出されたとして、それが「未定義動作を指摘するものである」ことに気付けるようなプログラマーは、そもそも未定義動作となるようなコードを書かないわけだが。