C++ の vtable による動的ディスパッチ
はじめに
まず、以下のコードを見てほしい。
// g++ vtable.cpp -o vtable -no-pie #include <iostream> #include <cstdio> using namespace std; class Animal { public: int age; Animal(int age) : age(age) {} virtual void bark() = 0; }; class Cat : public Animal { public: Cat(int age) : Animal(age) {} void bark() override { printf("meow: %d\n", age); } }; class Dog : public Animal { public: Dog(int age) : Animal(age) {} void bark() override { printf("bow: %d\n", age); } }; int main() { Animal *cat = new Cat(0x20); Animal *dog = new Dog(0x40); cat->bark(); dog->bark(); printf("cat: %p\n", cat); printf("dog: %p\n", dog); return 0; }
cat
や dog
の型は Animal
クラス(へのポインタ)型だが、cat->bark()
や dog->bark()
を実行すると、 Animal
クラスの子クラスである Cat
や Dog
クラス型のメソッドが呼び出される。このように、C++ は変数の型が親クラスでも、子クラスのメソッドを正確に呼び出すことができる。
$ ./vtable meow: 32 bow: 64 cat: 0xb69e70 dog: 0xb69e90
これは vtable (仮想関数テーブル) によって実現されている。ここでは gdb でこの事実を確認する。
vtable の実体
gef➤ set $cat = 0xb69e70 gef➤ set $dog = 0xb69e90 gef➤ x/4xg $cat 0xb69e70: 0x0000000000600da8 0x0000000000000020 0xb69e80: 0x0000000000000000 0x0000000000000021 gef➤ x/4xg $dog 0xb69e90: 0x0000000000600d90 0x0000000000000040 0xb69ea0: 0x0000000000000000 0x0000000000000411
まず、確保されたメモリ領域の一部を見てみると、上のようになっていた。0x0000000000000020
や 0x0000000000000040
はコンストラクタに渡した引数だと推測できるが、これらの値の直前に存在する 0x0000000000600da8
と 0x0000000000600d90
はなんだろうか。
gef➤ x/4xg *$cat 0x600da8 <vtable for Cat+16>: 0x00000000004008e4 0x0000000000000000 0x600db8 <vtable for Animal+8>: 0x0000000000600df8 0x00007f8b0fa798e0 gef➤ x/4xg *$dog 0x600d90 <vtable for Dog+16>: 0x0000000000400940 0x0000000000000000 0x600da0 <vtable for Cat+8>: 0x0000000000600de0 0x00000000004008e4
どうやら、実体は Cat
や Dog
の vtable らしい。では、この vtable に入っているデータはなんなのかも見ていく。
gef➤ x/10i **$cat 0x4008e4 <Cat::bark()>: push rbp 0x4008e5 <Cat::bark()+1>: mov rbp,rsp 0x4008e8 <Cat::bark()+4>: sub rsp,0x10 0x4008ec <Cat::bark()+8>: mov QWORD PTR [rbp-0x8],rdi 0x4008f0 <Cat::bark()+12>: mov rax,QWORD PTR [rbp-0x8] 0x4008f4 <Cat::bark()+16>: mov eax,DWORD PTR [rax+0x8] 0x4008f7 <Cat::bark()+19>: mov esi,eax 0x4008f9 <Cat::bark()+21>: lea rdi,[rip+0xf5] # 0x4009f5 0x400900 <Cat::bark()+28>: mov eax,0x0 0x400905 <Cat::bark()+33>: call 0x400660 <printf@plt> gef➤ x/10i **$dog 0x400940 <Dog::bark()>: push rbp 0x400941 <Dog::bark()+1>: mov rbp,rsp 0x400944 <Dog::bark()+4>: sub rsp,0x10 0x400948 <Dog::bark()+8>: mov QWORD PTR [rbp-0x8],rdi 0x40094c <Dog::bark()+12>: mov rax,QWORD PTR [rbp-0x8] 0x400950 <Dog::bark()+16>: mov eax,DWORD PTR [rax+0x8] 0x400953 <Dog::bark()+19>: mov esi,eax 0x400955 <Dog::bark()+21>: lea rdi,[rip+0xa3] # 0x4009ff 0x40095c <Dog::bark()+28>: mov eax,0x0 0x400961 <Dog::bark()+33>: call 0x400660 <printf@plt>
このように、 vtable にはそれぞれのクラスで定義された bark()
メソッドへのポインタが格納されていることがわかった。 ここで、 vtable の他の領域についても見てみる。
gef➤ x/4xg *$cat-16 0x600d98 <vtable for Cat>: 0x0000000000000000 0x0000000000600de0 0x600da8 <vtable for Cat+16>: 0x00000000004008e4 0x0000000000000000
0x00000000004008e4
は bark()
メソッドへのポインタだが、 0x0000000000600de0
はなんだろうか。
gef➤ x/4xg 0x0000000000600de0 0x600de0 <typeinfo for Cat>: 0x00007f8b0fd61438 0x0000000000400a1f 0x600df0 <typeinfo for Cat+16>: 0x0000000000600df8 0x00007f8b0fd607f8
どうやら、 Cat
クラス型の typeinfo が格納されているらしい。何に使われるものなのかは別の機会で調べるとして、 vtable は型に関する情報へのポインタも格納していることがわかった。
ここまでの情報で、インスタンスのために確保したメモリ領域は以下のように使われていたことがわかる。
確保領域の先頭からのオフセット | 用途 |
---|---|
+0 | vtable へのポインタ |
+8 | int age |
また、vtable は以下のように使われていたことがわかる。
vtable 先頭からのオフセット | 用途 |
---|---|
+0 | ??? |
+8 | typeinfo へのポインタ |
+16 | bark() へのポインタ |
+24 | ??? |
vtable の使われ方
次に vtable がどのように使われるのかをアセンブリレベルで見てみる。すべてのアセンブリを載せると長くなるので、以下の 2 箇所だけに絞って見てみる。
1. Animal *cat = new Cat(0x20);
0x0000000000400790 <+9>: mov edi,0x10 // sizeof(Cat) 0x0000000000400795 <+14>: call 0x400680 <operator new(unsigned long)@plt> // new(sizeof(Cat)) 0x000000000040079a <+19>: mov rbx,rax 0x000000000040079d <+22>: mov esi,0x20 0x00000000004007a2 <+27>: mov rdi,rbx 0x00000000004007a5 <+30>: call 0x4008b2 <Cat::Cat(int)> 0x00000000004007aa <+35>: mov QWORD PTR [rbp-0x20],rbx
まず、最初にサイズが 16 バイトのメモリ領域が new()
によって確保される。この時点ではまだ Cat
に関する情報が含まれていない。
次に new()
の戻り値を Cat::Cat()
、つまり、コンストラクタに渡して、ここで初めて初期化(インスタンス変数や vtable のセット)が行われる。
コンストラクタの具体的な処理は以下の通り
0x00000000004008b2 <+0>: push rbp 0x00000000004008b3 <+1>: mov rbp,rsp 0x00000000004008b6 <+4>: sub rsp,0x10 0x00000000004008ba <+8>: mov QWORD PTR [rbp-0x8],rdi 0x00000000004008be <+12>: mov DWORD PTR [rbp-0xc],esi 0x00000000004008c1 <+15>: mov rax,QWORD PTR [rbp-0x8] 0x00000000004008c5 <+19>: mov edx,DWORD PTR [rbp-0xc] 0x00000000004008c8 <+22>: mov esi,edx 0x00000000004008ca <+24>: mov rdi,rax 0x00000000004008cd <+27>: call 0x40088c <Animal::Animal(int)> // 親クラスのコンストラクタが呼び出される 0x00000000004008d2 <+32>: lea rdx,[rip+0x2004cf] # 0x600da8 <vtable for Cat+16> 0x00000000004008d9 <+39>: mov rax,QWORD PTR [rbp-0x8] 0x00000000004008dd <+43>: mov QWORD PTR [rax],rdx // vtable へのポインタがインスタンスの先頭にセットされる 0x00000000004008e0 <+46>: nop 0x00000000004008e1 <+47>: leave 0x00000000004008e2 <+48>: ret
4. cat->bark();
0x00000000004007cc <+69>: mov rax,QWORD PTR [rbp-0x20] // rax = cat_pointer 0x00000000004007d0 <+73>: mov rax,QWORD PTR [rax] // rax = cat_vtable 0x00000000004007d3 <+76>: mov rax,QWORD PTR [rax] // rax = Cat::bark 0x00000000004007d6 <+79>: mov rdx,QWORD PTR [rbp-0x20] // rdx = cat_pointer 0x00000000004007da <+83>: mov rdi,rdx // rdi = rdx (cat_pointer) 0x00000000004007dd <+86>: call rax // Cat::bark(cat_pointer)
このように Cat::bark()
を呼び出すときは
vtable
が参照され、Cat::bark()
のポインタが取得される。Cat::bark()
の第 1 引数にインスタンスへのポインタ(Python の self に似た役割)を渡して呼び出す。
インスタンスメソッドの中で、オブジェクトのプロパティにアクセスできるのは、実はメソッドの第 1 引数にインスタンスへのポインタを渡しているからだとわかる。