CプログラマのためのC++ FAQ

Q. std::stringとChar *型はどう違うのか

コンパイラ付属の標準ライブラリの実装によります。ここでは、C++11以上をターゲットとするlibc++のstd::stringの実装を見ていきます。

前提:

  • C++11以上をターゲットとするlibc++のstd::string
  • 64bitリトルエンディアン

std::string にはLong stringとShort stringという2つのモードがあります。共用体(union)を使って2つのモードを同じバイト列で実現しています。

Long String Mode

Long stringモードが標準的な文字列の実装になります。

  • size_t __cap_ - 文字列を格納しているバッファの容量。ヌル文字を含む文字列の長さが容量を超える場合、バッファを再確保する
  • size_t __size_ - 文字列のサイズ。ヌル文字を含まない
  • char* __data_ - 文字列を格納しているヒープ領域のバッファへのポインタ

各データメンバが8バイトなので、 sizeof(std::string) == 24 です。

std::string は、 __cap_ の最下位ビットを使って、2つのモードを区別しています。最下位ビットが1に設定されてる場合はLong string、0に設定されている場合はShort string。このように最下位ビットを使うことができるのは、文字列バッファのサイズが常に偶数であることが実装によって保証されているためです。

Short String Mode

Short stringモードでは最大22文字をスタック領域のオブジェクト自体に格納します。

Short String Optimizationは、文字列クラスの最適化手法のひとつです。多くのstringオブジェクトが短い文字列を扱うという点に着目して、短い文字列の場合は動的メモリ確保を行いません。

  • unsigned char __size_ - 文字列のサイズ。最初のバイトの最下位ビットがフラグとして使用されているため、実のサイズを表すバイトを左に1シフトしたもの。最下位ビットはShort stringなので0
  • char __data_[23] - 文字列を格納する静的なバッファ

__size_ にはサイズを表すバイトを左に1シフトした値が入っています。例えばstd::string::size() は次のように実装されています。

size_t size() {
    if (__size_ & 1 == 0) { // <short string mode>
        return __size_ >> 1;
    }
    // <else long string mode>
}

参考:

Q. オブジェクトはメモリ上でどのように表現されているのか

オブジェクトが POD型 ("Plain Old Data") である場合、C++のclassとCのstructのメモリレイアウトが同一であることがC++スタンダードで保証されています。

POD型とは:

  • ユーザが宣言したコンストラクターを持たない
  • ユーザ定義のデストラクタを持たない
  • 基底クラスを持たない
  • 仮想関数を持たない
  • ユーザ定義のコピー代入演算子を持たない
  • 参照型の非静的データメンバを持たない

例:

class Point {
public:
    double getX() const;
    double getY() const;
    double x, y;
        static std::size_t count;
};

int main() {
    return sizeof(Point);
}

Point 型オブジェクトのサイズは16バイトになります。

データメンバ xy は順番通りメモリ上に配置されます。つまりオブジェクト自体のアドレスと最初のデータメンバのアドレスが同じになります。

staticデータメンバはインスタンスに紐付いていないので、オブジェクトとは別でBSS領域かデータ領域に存在します。

メンバ関数はオブジェクトとは別でテキスト領域に存在します。呼び出し規約は他のグローバル関数とほとんど同じですが、レシーバへのポインタ this が暗黙的にパラメータに追加されます。

以下のようなメンバ関数を使うと、

Point pt;
int x = pt.getX();

C++コンパイラは次のようなコードを生成します。

Point pt;
int x = Point::getX(&pt);

メンバ関数 getX() のプロトタイプは 次のように変換されます。

int Point::getX(Point *const this);

厳密に言うとこのままで動くわけではないですが、裏で何が起こっているのかはこのようにイメージできます。

またClangはオブジェクトレイアウトを出力する便利なフラグを持っています。

clang++ -cc1 -fdump-record-layouts <filename.cpp>

*** Dumping AST Record Layout
         0 | class Point
         0 |   double x
         8 |   double y
           | [sizeof=16, dsize=16, align=8,
           |  nvsize=16, nvalign=8]

参考:

Q. オブジェクトの継承はメモリ上でどのように表現されているのか

クラスが別のクラスを継承している場合、そのオブジェクトのメモリレイアウトは基底クラスのデータメンバから始まります。

例:

class BaseClass {
    int baseX, baseY;
};

class DerivedClass: public BaseClass {
   int derX, derY;
};

メモリ上の DerivedClass オブジェクトは以下のようになります。

DerivedClass オブジェクトの最初の8バイトは、 BaseClass のデータメンバになっています。派生クラスのインスタンスを基底クラスのインスタンスとして扱うことができるのは、このためでもあります。

オブジェクト継承をこのようにメモリ上で表現することは非常に効率的です。C++と競合していたオブジェクト指向言語の多くは、オブジェクトをより複雑な構造で表現し、複雑なポインタ参照を必要としていました。それに比べ、C++の継承はゼロオーバーヘッドです。

clang++ -cc1 -fdump-record-layouts <filename.cpp>

*** Dumping AST Record Layout
         0 | class BaseClass
         0 |   int baseX
         4 |   int baseY
           | [sizeof=8, dsize=8, align=4,
           |  nvsize=8, nvalign=4]

*** Dumping AST Record Layout
         0 | class DerivedClass
         0 |   class BaseClass (base)
         0 |     int baseX
         4 |     int baseY
         8 |   int derX
        12 |   int derY
           | [sizeof=16, dsize=16, align=4,
           |  nvsize=16, nvalign=4]

Q. 仮想関数はどのように実装されているのか

仮想関数を呼び出すときは、オーバーライドに応じて実行時にどの実装を呼び出すかを決定しなければなりません。特定の仮想関数をオーバーライドする派生クラスはいくらでも存在できるため、単純なswitch文は高価になります。

スタンダードでは定められていませんが、ほとんどのC++コンパイラはvtable(仮想関数テーブル)を使ったアプローチで動的なポリモーフィズムを実現しています。

クラス(またはその基底クラスの1つ)が仮想関数を持っていた場合、そのオブジェクトのメモリレイアウトは vtableへのポインタから始まるようになります。

例:

class BaseClass {
public:
    virtual ~BaseClass() {}
    virtual void doSomething();
private:
    int baseX, baseY;
};

class DerivedClass: public BaseClass {
public:
    virtual void doSomething(); // オーバーライド
private:
    int derX, derY;
};

メモリ上の DerivedClass オブジェクトは以下のようになります。

先ほどと同じように、基底クラスのメンバのあとに派生クラスのメンバが続きます。ただしこのオブジェクトにはもうひとつのデータメンバとして、vtableへのポインタが加えられます。

仮想関数を1つでも持つクラスを作ると、C++はvtableを作成します。vtableにはクラスに関するメタデータと、そのクラスが持つ各仮想関数の関数ポインタが並べられています。

次の図は BaseClass オブジェクト、DerivedClass オブジェクト、およびそれぞれのvtableを示しています。

BaseClass のvtableには、自身のデストラクタと自身が実装した doSomething への関数ポインタがあります。

DerivedClass のvtableにも同様に、自身のデストラクタと自身が実装した doSomething への関数ポインタがあります。

基底クラスと派生クラスのvtableの関数ポインタは同じ順番で並んでいます。これにより、C++は仮想関数を効率的に呼び出すことができます。

例:

BaseClass* ptr = randomChance(0.5) ? new BaseClass : new DerivedClass;
ptr->doSomething();
delete ptr;

上のコードはランダムに BaseClassDerivedClass のオブジェクトを生成しています。C++のコンパイラは2-3行目で次のような手続きを行うマシンコードを生成します:

ptr->doSomething();

  • ptr が指すオブジェクトの最初の8バイトを参照する
  • vtable*をたどり、仮想関数テーブルの2番目の関数ポインタを取り出す
  • その関数を呼び出す

delete ptr;

  • ptr が指すオブジェクトの最初の8バイトを参照する
  • vtable*をたどり、仮想関数テーブルから最初の関数ポインタを取り出す
  • その関数(デストラクタ)を呼び出す
  • ptr が指すメモリブロックを解放する

*このように派生クラスのポインタを基底クラスのポインタとして扱う場合、基底クラスのデストラクタを仮想化していないと delete ptr がうまく動作しなくなります。

このような構造のおかげで、仮想関数が何度オーバーライドされていようが、1つの参照で適切な関数を呼び出すことができます。

参考: