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 のプログラミング研究室: データ型のアラインメントとは何か,なぜ必要なのか?」が非常にわかりやすい。

クラスのサイズもその影響を受けてプロパティのサイズを足しあわせたサイズではなく”隙間”のサイズも合わせたサイズとなる。

まとめ