/dev/null

脳みそのL1キャッシュ

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;
}

catdog の型は Animal クラス(へのポインタ)型だが、cat->bark()dog->bark() を実行すると、 Animal クラスの子クラスである CatDog クラス型のメソッドが呼び出される。このように、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

まず、確保されたメモリ領域の一部を見てみると、上のようになっていた。0x00000000000000200x0000000000000040 はコンストラクタに渡した引数だと推測できるが、これらの値の直前に存在する 0x0000000000600da80x0000000000600d90 はなんだろうか。

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

どうやら、実体は CatDog の 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

0x00000000004008e4bark() メソッドへのポインタだが、 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() を呼び出すときは

  1. vtable が参照され、Cat::bark() のポインタが取得される。
  2. Cat::bark() の第 1 引数にインスタンスへのポインタ(Python の self に似た役割)を渡して呼び出す。

インスタンスメソッドの中で、オブジェクトのプロパティにアクセスできるのは、実はメソッドの第 1 引数にインスタンスへのポインタを渡しているからだとわかる。

まとめ

  • C++ の動的ディスパッチは vtable によって実現されている
  • vtable はクラスごとに存在する
  • インスタンスの先頭には vtable へのポインタが格納されている
  • メソッド呼び出しをする際には vtable が参照され、適切なメソッドが動的に解決される
  • C++ のメソッドには隠された第 1 引数が存在し、それにはインスタンスへのポインタが渡される
  • C++インスタンス化は 1. 領域確保 (new)2. 初期化 (コンストラクタ) の 2 ステップに分かれている
  • vtable はコンストラクタによる初期化時にセットされる