C++のインスタンスとは
オブジェクト指向の入門書の冒頭にはこういう説明がよく出てくる。
これが C++ ではどのように実現されているかを今回は観察してみる。
環境は Mac OS X 10.6.2, x86_64, gcc 4.2.1
早速簡単なクラスを定義してみる。
Hoge5.cpp
class Hoge5 { public: int value; int getValue(); void setValue(int newValue); }; int Hoge5::getValue() { return value; } void Hoge5::setValue(int newValue) { value = newValue; } int main(int argc, char *argv[]) { int value; Hoge5 *hoge5 = new Hoge5(); hoge5->setValue(5); value = hoge5->getValue(); delete hoge5; return 0; }
インスタンスを生成、プロパティの設定&取得をするだけのコード。
$ g++ --save-temps -o Hoge5 Hoge5.cpp
生成されたアセンブリ抜粋(Hoge5.s)
01: .text 02: .align 1,0x90 03: .globl __ZN5Hoge58getValueEv 04: __ZN5Hoge58getValueEv: 05: LFB2: 06: pushq %rbp 07: LCFI0: 08: movq %rsp, %rbp 09: LCFI1: 10: movq %rdi, -8(%rbp) 11: movq -8(%rbp), %rax 12: movl (%rax), %eax 13: leave 14: ret 15: LFE2: 16: .align 1,0x90 17: .globl __ZN5Hoge58setValueEi 18: __ZN5Hoge58setValueEi: 19: LFB3: 20: pushq %rbp 21: LCFI2: 22: movq %rsp, %rbp 23: LCFI3: 24: movq %rdi, -8(%rbp) 25: movl %esi, -12(%rbp) 26: movq -8(%rbp), %rdx 27: movl -12(%rbp), %eax 28: movl %eax, (%rdx) 29: leave 30: ret 31: LFE3: 32: .globl _main 33: _main: 34: LFB4: 35: pushq %rbp 36: LCFI4: 37: movq %rsp, %rbp 38: LCFI5: 39: subq $32, %rsp 40: LCFI6: 41: movl %edi, -20(%rbp) 42: movq %rsi, -32(%rbp) 43: movl $4, %edi 44: call __Znwm 45: movl $0, (%rax) 46: movq %rax, -16(%rbp) 47: movq -16(%rbp), %rdi 48: movl $5, %esi 49: call __ZN5Hoge58setValueEi 50: movq -16(%rbp), %rdi 51: call __ZN5Hoge58getValueEv 52: movl %eax, -4(%rbp) 53: movq -16(%rbp), %rdi 54: call __ZdlPv 55: movl $0, %eax 56: leave 57: ret
__ZN5Hoge58getValueEv や __ZN5Hoge58setValueEi は c++filt コマンドで元のメソッドシグネチャを得ることができる。
$ c++filt -_ __ZN5Hoge58getValueEv Hoge5::getValue() $ c++filt -_ __ZN5Hoge58setValueEi Hoge5::setValue(int)
C++では引数の型が異なれば同名の関数を定義することができるので、名前の衝突を避けるために引数の型情報なども含むようコンパイラが名前を変更する。これを名前マングル(マングリング)という。マングルされた名前を元に戻すことはデマングルという。名前マングルのルールは「C++ ABI: 5.1 External Names (a.k.a. Mangling) 」を参照。
アセンブリをもう一度見てみると、名前がマングリングされている意外はC言語の関数と特に異なるところは見当たらない。
ここで、x86_64での関数引数の渡され方について少しメモ。基本的に引数はレジスタ経由で渡される。引数の型によってどのレジスタが使用されるかが決まる。整数型の場合は rdi, rsi, rdx, rcx の順に第一引数、第二引数、・・・とレジスタが割り当てられる。戻り値は rax レジスタに設定される。詳しくは「System V Application Binary Interface AMD64 Architecture Processor Supplement: 3.2.3 Parameter Passing」参照。ちなみに32bit値を読み書きする命令ではrdi, rsi, rdx, rcx, rax レジスタはそれぞれ、32bit部分だけを指して edi, esi, edx, ecx, eax と呼ぶ。
43: movl $4, %edi 44: call __Znwm
43〜44行目でインスタンスが生成されている。(__Znwm は operator new(unsigned long)) 第一引数(ediレジスタ経由)には 4 が渡されているので 4byteのメモリを確保していることがわかる。new() で確保されたメモリの先頭アドレスは戻り値としてraxレジスタに格納される。
4byte・・・・
class Hoge5 { public: int value; ← 4byte !!!!! int getValue(); void setValue(int newValue); };
インスタンスの正体はプロパティを格納するために確保されたメモリ領域なのかな??まだわからんけど。
45: movl $0, (%rax) 46: movq %rax, -16(%rbp)
raxには確保したメモリの先頭アドレスが入っているので、45行目ではそのメモリ領域に4byteの0を書き込んでいることになる。46行ではそのアドレスをスタック上の-16(%rbp)の位置に格納している。-16(%rbp)の位置は Hoge5.cpp の中でいうと変数 hoge5 に当たる。
47: movq -16(%rbp), %rdi 48: movl $5, %esi 49: call __ZN5Hoge58setValueEi
47〜49行目では Hoge5::setValue(int) を呼び出している。47行目ではrdiレジスタ、つまり第一引数にインスタンスのアドレスを設定している。48行目ではesiレジスタ、つまり第二引数に valueプロパティに設定する値である 5 を設定している。49行目で関数呼び出し。
呼び出された側を見てみると・・・
18: __ZN5Hoge58setValueEi: 19: LFB3: 20: pushq %rbp 21: LCFI2: 22: movq %rsp, %rbp 23: LCFI3: 24: movq %rdi, -8(%rbp) 25: movl %esi, -12(%rbp) 26: movq -8(%rbp), %rdx 27: movl -12(%rbp), %eax 28: movl %eax, (%rdx) 29: leave 30: ret
19〜22はお決まりのスタックフレームの追加操作で、24行目で第一引数(= インスタンスのアドレス)をスタック上の -8(%rbp)の位置に格納している。C++のキーワードで言うところの this だ。25行目で第二引数(= value プロパティに設定する値)を -12(%rbp) の位置に格納している。これが Hoge5.cpp の中でいうと仮引数 newValue に当たる。
28行目で value プロパティに設定する値をインスタンスのアドレスに書き込んでいる。つまりインスタンスのアドレスは value プロパティのアドレスと同じであった事がわかる。
これらのことから インスタンスの正体は「プロパティを格納するために確保されたメモリ領域」であることがわかる。「インスタンスのメソッドを呼び出す」とは内部的にはインスタンスのアドレスを引数に指定して関数を呼び出すようになっている。インスタンスが複数作成されても関数はメモリ上に一つだけ。コンパイラが「クラス」の構造を解釈し、このようなコードを生成する。
C言語で書き直してみるとこんな感じか。(Hoge6.c)
#include <stdlib.h> struct Hoge6 { int value; }; int getValue(struct Hoge6 *this) { return this->value; } void setValue(struct Hoge6 *this, int newValue) { this->value = newValue; } int main(int argc, char *argv[]) { int value; struct Hoge6 *hoge6; hoge6 = (struct Hoge6 *) malloc(sizeof(struct Hoge6)); setValue(hoge6, 5); value = getValue(hoge6); free(hoge6); return 0; }
このことだけを見れば C++ は C の構造体とそれを扱う関数を結びつけるための仕組みと言えるかもしれない。継承とか、アクセス制限とか他のことはとりあえず目をつぶれば(・┰・)
インラインアセンブラでプロパティを引っこ抜いてみる
見えない第一引数(rdiレジスタ)でインスタンスのアドレスが渡されることがわかったので、それを元にインラインアセンブラでプロパティにアクセスしてみる。
#include <stdio.h> class Hoge7 { public: int value1; int value2; void doSome(); }; void Hoge7::doSome() { int v1, v2; __asm__ ("movl (%%rdi), %0 \n\t" "movl 4(%%rdi), %1 \n\t" : "=a"(v1), "=d"(v2)); printf("v1: %d, v2: %d\n", v1, v2); } int main(int argc, char *argv[]) { Hoge7 *hoge7 = new Hoge7(); hoge7->value1 = 3; hoge7->value2 = 4; hoge7->doSome(); Hoge7 *hoge7_2 = new Hoge7(); hoge7_2->value1 = 8; hoge7_2->value2 = 9; hoge7_2->doSome(); delete hoge7; delete hoge7_2; return 0; }
コンパイル&実行結果
$ g++ -o Hoge7 Hoge7.cpp $ ./Hoge7 v1: 3, v2: 4 v1: 8, v2: 9
おおおー。取れた取れた。正しいプロパティ値が取得できた。
クラスのサイズ
上の例ではint型プロパティが2個並んでいるので2個目のアドレスは1個目のアドレス+4だろうと安易にアドレスを決めているがこれは実は非常に危険。
実験用コード(Hoge8.cpp)
#include <stdio.h> class Hoge8 { public: char charValue1; int intValue; char charValue2; long longValue; void doSome(); }; void doSome() { puts("this is doSome().\n"); } int main(int argc, char *argv[]) { Hoge8 *hoge = new Hoge8(); printf("charValue1: size=0x%lx, addr=0x%016lx\n", sizeof(hoge->charValue1), (unsigned long)&hoge->charValue1); printf("intValue: size=0x%lx, addr=0x%016lx\n", sizeof(hoge->intValue), (unsigned long)&hoge->intValue); printf("charValue2: size=0x%lx, addr=0x%016lx\n", sizeof(hoge->charValue2), (unsigned long)&hoge->charValue2); printf("longValue: size=0x%lx, addr=0x%016lx\n", sizeof(hoge->longValue), (unsigned long)&hoge->longValue); printf("size of Hoge8: 0x%016lx\n", sizeof(Hoge8)); }
実行結果
$ ./Hoge8 charValue1: size=0x1, addr=0x0000000100100080 intValue: size=0x4, addr=0x0000000100100084 charValue2: size=0x1, addr=0x0000000100100088 longValue: size=0x8, addr=0x0000000100100090 size of Hoge8: 0x0000000000000018
charValue1プロパティのサイズは1byteなのに、その次のintValueプロパティは +4byte の位置に配置されている。longValueも同様にcharValue2プロパティから +8byte の位置に配置されている。この状態でさっきのように安易に charValue1 から1byteの位置にintValue プロパティがあるだろうと考えてコードを書くととんでもコードが出来上がってしまう。
なぜこのような無駄な”隙間”が開いてるかというと、ハードウェア的にパフォーマンスを出しやすいデータ配置というのが有って、それに合うようにプロパティがアラインメント(配置)されるから。x86ではデータ自身のサイズで割り切れるアドレスにアラインメントするのが好ましいのでこのようなアドレスになっている。アラインメントに関しては「noocyte のプログラミング研究室: データ型のアラインメントとは何か,なぜ必要なのか?」が非常にわかりやすい。
クラスのサイズもその影響を受けてプロパティのサイズを足しあわせたサイズではなく”隙間”のサイズも合わせたサイズとなる。