株式会社エス・スリー・フォー

sizeofの不思議

はじめに

Cではsizeofによって構造体がメモリ上で占める大きさ(バイト数)を知ることができます。

struct s{
  int x;
  int y;
};
...
cout << sizeof(s) << endl;

僕の愛用する処理系、Visual C++ 6.0では 8が得られました。intひとつにつき4byteを消費するからでしょう。

それではC++でのclassの大きさはどうでしょう。内包するメンバ変数それぞれの占めるバイト数の総和になるのでしょうか。

class c {
  int x;
  int y;
};
...
cout << sizeof(c) << endl;

答は…やはり 8 です。でもね、C++ではいつもこうなるとは限らないのですよ。

「C++はCよりデカい」と言われることがあります。その理由のひとつがここに明らかになります。

仮想関数

上記class cにメンバ関数を追加します。

class c {
  int x;
  int y;
public:
  int sum();
};
...
cout << sizeof(c) << endl;

結果は 8 でした。メンバ関数を追加してもサイズに変化はないようです。

ところが、仮想関数を追加すると…

class c {
  int x;
  int y;
public:
  virtual int sum();
};
...
cout << sizeof(c) << endl;

結果は12。4バイト増加しています。まるでメンバ関数へのポインタがクラス内にこっそり追加されたかのようです。
ならば仮想関数が2つあったら8バイト増えるのかな…

class c {
  int x;
  int y;
public:
  virtual int sum();
  virtual int sub();
};
...
cout << sizeof(c) << endl;

依然として 12 のままです。実体を伴わない純粋仮想関数を追加したら…

class c {
  int x;
  int y;
public:
  virtual int sum() =0;
};
...
cout << sizeof(c) << endl;

…ふむ、純粋仮想関数を追加してもクラスの大きさは 12 ですね。

これらのことから、クラスに仮想関数がひとつ以上あるとき、クラスの大きさは 4 バイト増加すると思われます。

何故でしょう。その理由は仮想関数が実行されるときのからくりにあります。

仮想関数を含むクラスを宣言すると、そのクラスの中メンバ変数'vptr'がひとつ、こっそり追加されます。これは"仮想関数へのポインタ配列'vtbl'へのポインタ"です。
vtblはクラス内にある仮想関数のポインタを配列内に納めたもので、仮想関数を含むクラス毎にひとつ作られます。仮想関数がコールされるとき、vptrの指す配列vtblの特定の位置から仮想関数へのポインタを取り出し、そこをコールします。

このようなからくりのために、仮想関数を含むクラスは、仮想関数のないクラスより少し大きいのです。

継承

継承したときのクラスの大きさはどうでしょうか。

class c0 {
  int x;
  int y;
};

class c1 : public c0 {
  int a;
  int b;
};
...
cout << sizeof(c0) << endl;
cout << sizeof(c1) << endl;

結果はそれぞれ 8,16 でした、予想通りの結果です。

仮想関数を含むとどうなるでしょう。

class c0 {
public:
  int x;
  int y;
  virtual int sum();
};

class c1 : public c0 {
public:
  int a;
  int b;
  virtual int diff();
};
...
cout << sizeof(c0) << endl;
cout << sizeof(c1) << endl;

sizeof(c0)が 12だから、c1はそれにint2個分(8byte)と'vptr'(4byte)を加えて 24 …ではなくて 20 となりました。仮想関数テーブルポインタ'vptr'はクラス内にひとつあればいいからです。

cout << offsetof(c1,x) << endl;
cout << offsetof(c1,y) << endl;
cout << offsetof(c1,a) << endl;
cout << offsetof(c1,b) << endl;

の結果はそれぞれ、4, 8, 12, 16 となりました、このことから、class c1のメモリ上でのレイアウトは、

offset member
+0 c1::vptr
+4 c0::x
+8 c0::y
+12 c1::a
+16 c1::b

となっていると考えられます。

多重継承

多重継承の場合、ちょっとばかしややこしくなります。

class c0 {
public:
  int x0;
  int y0;
  virtual void f0() {}
};

class c1 {
public:
  int x1;
  int y1;
  virtual void f1() {}
};

class c2 : public c0, public c1 {
public:
  int x2;
  int y2;
  virtual void f2() {}
};
...
cout << "c0  :        " << sizeof(c0)  << endl;
cout << "c1  :        " << sizeof(c1)  << endl;
cout << "c2  : c0, c1 " << sizeof(c2)  << endl;

'仮想関数ポインタテーブル(vptr)はクラスにひとつ'であるならば、sizeof(c2)は、10,c1,c2それぞれのメンバ変数領域の総和(8×3=24byte)にvptr(4byte)を加えた28byteとなりそうです。

ところが結果は予想に反して 32となりました。vptrが2つ含まれているようです。

cout << offsetof(c2, x0) << endl;
cout << offsetof(c2, y0) << endl;
cout << offsetof(c2, x1) << endl;
cout << offsetof(c2, y1) << endl;
cout << offsetof(c2, x2) << endl;
cout << offsetof(c2, y2) << endl;

の結果: 4, 8, 16, 20, 24, 28 から察するに、c2のメモリ・レイアウトは:

offset member
+0 c2::vptr
+4 c0::x0
+8 c0::y0
+12 c1::vptr
+16 c1::x1
+20 c1::y1
+24 c1::x2
+28 c1::y2

であろうと考えられます。

仮想継承

さらに仮想継承を考えると、話はさらにややこしくなります。

class c0 {
public:
  int x0;
  int y0;
  virtual void f0() {}
};

class c1 : virtual public c0 {
public:
  int x1;
  int y1;
  virtual void f1() {}
};

class c2 : virtual public c0 {
public:
  int x2;
  int y2;
  virtual void f2() {}
};

class c3 : public c1, public c2 {
public:
  int x3;
  int y3;
  virtual void f3() {}
};
...
cout << "c0  :        " << sizeof(c0)  << endl;
cout << "c1  : c0     " << sizeof(c1)  << endl;
cout << "c2  : c0     " << sizeof(c2)  << endl;
cout << "c3  : c1, c2 " << sizeof(c3)  << endl;

sizeof(c3)は 52 となりました。なんとも不思議な結果です。上記の各クラスから仮想関数を抜き取ってみましょう。

class c0 {
public:
  int x0;
  int y0;
};

class c1 : virtual public c0 {
public:
  int x1;
  int y1;
};

class c2 : virtual public c0 {
public:
  int x2;
  int y2;
};

class c3 : public c1, public c2 {
public:
  int x3;
  int y3;
};
...
cout << "c0  :        " << sizeof(c0)  << endl;
cout << "c1  : c0     " << sizeof(c1)  << endl;
cout << "c2  : c0     " << sizeof(c2)  << endl;
cout << "c3  : c1, c2 " << sizeof(c3)  << endl;

このとき、sizeof(c3)は 40となりました。仮想継承したベースクラスc0の各メンバは、c3内にはひとつしかないはずなので、予想されるサイズは (8×4=)32
であるはずなのに、8byte余計な領域を必要としています。仮想関数を元に戻し、

cout << offsetof(c3, x1) << endl;
cout << offsetof(c3, y1) << endl;
cout << offsetof(c3, x2) << endl;
cout << offsetof(c3, y2) << endl;
cout << offsetof(c3, x3) << endl;
cout << offsetof(c3, y3) << endl;

を実行すると、それぞれ 8, 12, 24, 28, 32, 36
となりました。この結果から得られるメモリ・レイアウトは:

offset member
+0 ???
+4 ???
+8 c1::x1
+12 c1::y1
+16 ???
+20 ???
+24 c2::x2
+28 c2::y2
+32 c3::x3
+36 c3::y3
+40 ???
+44 ???
+48 ???

'???'の領域のうち、少なくとも2つはc0::x0とc0::y0でしょう。

「C++はCよりデカい」か?

結論から言うと、仮想関数、多重継承、仮想継承などなどを駆使したC++コードは確かにデカくなることがわかりました。最後の例など、Cでなら32byteで済むところが、20byteも余計にメモリを消費します。インスタンスひとつにつき20byteのメモリを余計に消費するのですからアプリケーション全体では無視できないでしょうね。

しかし、だからといってC++コードから仮想関数を排除するのは決して得策とは思えません。仮想関数と同じふるまいを仮想関数なしで実現するには、オブジェクトの本来の型を識別するためのIDをメンバとして用意しなければなりませんし、関数呼び出しのたびに、IDによる処理の振り分けを行なうことになります。結局仮想関数を用いた場合と同等のサイズになることが予想されるうえ、コードのメンテナンス性が著しく低下するでしょう。

※注意:
これはMicrosoft Visual C++ v6.0での実験結果です。
これ以外のOSや処理系では異なる結果が得られることでしょう。

おまけ : やりがちな誤り

仮想関数のお話ついでに、うっかりやってしまいそうな誤りをひとつ紹介しておきます。あなたはいくつかのクラスを設計/実装することになり、それぞれのコンストラクタ/デストラクタが確実に行われているか、画面に表示して確かめたくなりました。そこであなたはこんなコードを書きました。

class base {
public:
  base();
  virtual ‾base();
  virtual void on_ctor();
  virtual void on_dtor();
};

base::base() { 
  on_ctor(); 
}

base::‾base() {
  on_dtor(); 
}

void base::on_ctor() {
  cout << "base::ctor¥n";
}

void base::on_dtor() {
  cout << "base::dtor¥n";
}

class baseはそのコンストラクタ/デストラクタの中からそれぞれ仮想関数on_ctor()/on_dtor()を呼んでいます。ですからbaseの派生クラス側でon_ctor()/on_dtor()を再定義すればいいはずです。

class c0 : public base {
public:
  virtual void on_ctor();
  virtual void on_dtor();
};

void c0::on_ctor() {
  cout << "c0::ctor¥n";
}

void c0::on_dtor() {
  cout << "c0::dtor¥n";
}

class c1 : public base {
public:
  virtual void on_ctor();
  virtual void on_dtor();
};

void c1::on_ctor() {
  cout << "c1::ctor¥n";
}

void c1::on_dtor() {
  cout << "c1::dtor¥n";
}

int main() {
  c0 x;
  c1 y;
  return 0;
}

さて、結果はどうなったでしょう…

base::ctor
base::ctor
base::dtor
base::dtor

惨澹たる結果となりました。

そう、C++ではコンストラクタ/デストラクタの中から仮想関数を呼んでも、導出クラスで再定義された関数には飛んでこないんです。

この例のように、コンストラクタ/デストラクタから直接仮想関数を呼んでいるのであればそれに気づくのにそう時間はかからないのですが、コンストラクタ/デストラクタが呼んでいる関数の中、あるいはさらにそこから呼ばれている関数…
コンストラクタ/デストラクタが終了するまでに直接的あるいは間接的に呼ばれている一連の関数の中に仮想関数が含まれていたとき、そのふるまいは意図しているものとは異なることになるのです。

コンストラクタ/デストラクタ内から仮想関数を呼び出すことはできませんから、仕方なしにコンストラクト/デストラクトと初期化/後始末を分離しなければなりません。

// ----- これならちゃんと動く,,, -----
class base {
public:
  base() {}
  virtual ‾base() {}
  void    start();
  void    stop();
  virtual void on_ctor();
  virtual void on_dtor();
};

void base::start() { 
  on_ctor(); 
}

void base::stop() {
  on_dtor();
}

void base::on_ctor() {
  cout << "base::ctor¥n";
}

void base::on_dtor() {
  cout << "base::dtor¥n";
}

/*
 * c0, c1 はそのまま...
 */

int main() {
  c0 x; x.start();
  c1 y; y.start();
  y.stop();
  x.stop();
  return 0;
}

これがJavaだと思った通りに動いてくれます。ま、Javaにはデストラクタがありませんけど…

// ----- Trial.java -----
class base {
  base() {
    on_ctor();
  }
  void on_ctor() {
    System.out.println("base::ctor");
  }
  protected void finalize() {
    System.out.println("base::dtor");
  }
}

class c0 extends base {
  void on_ctor() {
    System.out.println("c0::ctor");
  }
  protected void finalize() {
    System.out.println("c0::dtor");
  }
}

class c1 extends base {
  void on_ctor() {
    System.out.println("c1::ctor");
  }
  protected void finalize() {
    System.out.println("c1::dtor");
  }
}

public class Trial {
  public static void main(String[] arg) throws Exception {
    c0 x = new c0();
    c1 y = new c1();
  }
}

実行結果:

c0::ctor
c1::ctor

…ほらね。