T.TAO
ブログへ戻る
/8 min read/Programming

C++ #5 オブジェクトとクラス

#C++#Programming#ComputerSystems

このノートでは C++ のクラス定義、オブジェクトの寿命、そしてこれらの言語機能がメモリレベルで何に対応するのかを扱います。

クラスとはアクセス制御つきの構造体

C++ において classstruct の言語的な違いは既定のアクセス権だけです。struct は public、class は private が既定。それ以外は完全に同じで、どちらもメンバ関数も継承も仮想関数も持てます。

C++class Vector3 {
public:
    Vector3(float x, float y, float z) : x_(x), y_(y), z_(z) {}
    float length() const;

private:
    float x_, y_, z_;
};

慣習として、struct は「ひとまとまりのデータ」、class は「維持すべき不変条件がある」ことを表します。コンパイラは気にしませんが、読む人は気にします。

押さえておきたい事実がひとつ。メンバ関数はオブジェクトの領域を消費しません。 sizeof(Vector3) は 12 バイト、float 三つぶんだけです。メンバ関数は隠れた第一引数——this ポインタ——を持つ通常の関数へコンパイルされます。上の length() の実体は次に近いものです。

C++float Vector3_length(const Vector3* this);

const メンバ関数でメンバを変更できない理由もこれで説明できます。constthis が指す型に付くので、thisconst Vector3* になるからです。

コンストラクタ

初期化リストは糖衣構文ではない

C++class Widget {
public:
    Widget(const std::string& name) : name_(name) {}   // 初期化
    // Widget(const std::string& name) { name_ = name; }  // 代入
private:
    std::string name_;
};

この二つには実質的な違いがあります。すべてのメンバはコンストラクタ本体に入るに構築が完了しています。初期化リストはコピーコンストラクタを直接呼びますが、本体に書くとまず空の string をデフォルト構築し、そのあとコピー代入します。構築が一回余分です。

const メンバや参照メンバでは、本体での代入はそもそも不可能で、初期化リストしか選択肢がありません。

そして罠がひとつ。メンバは宣言順に初期化されます。初期化リストに書いた順序は無関係です。

C++class Buffer {
public:
    Buffer(size_t n) : size_(n), data_(new char[size_]) {}  // 一見問題なさそう
private:
    char*  data_;   // 先に宣言 → 先に初期化
    size_t size_;   // 後に宣言
};

このコードは誤りです。data_ が先に初期化される時点で、size_ はまだ不定値です。宣言順と初期化順を一致させて書くのが、この種のバグに二度と会わないもっとも安価な方法です。

デフォルトコンストラクタと explicit

自分でコンストラクタを一つでも書くと、コンパイラはデフォルトコンストラクタを生成しなくなります。必要なら取り戻せます。

C++Widget() = default;

引数一つのコンストラクタは暗黙変換を持ち込みます。たいていは望んだ動作ではありません。

C++class Buffer {
public:
    Buffer(size_t n);
};

void consume(Buffer b);
consume(42);   // コンパイルが通る:42 が暗黙に Buffer(42) へ変換される

explicit を付ければこの変換を阻止できます。経験則としては、引数一つのコンストラクタは既定で explicit を付けるstd::string(const char*) のように、その変換を本当に望む場合だけ外します。

デストラクタとオブジェクトの寿命

デストラクタはオブジェクトがスコープを抜けるときに自動で呼ばれ、順序は構築の逆——スタックの意味論です。

C++{
    A a;   // 1. a を構築
    B b;   // 2. b を構築
}          // 3. b を破棄   4. a を破棄

「コンストラクタで資源を獲得し、デストラクタで解放する」というこの仕組みが RAII(Resource Acquisition Is Initialization)であり、C にはなく C++ にあるものの中でもっとも価値のあるもののひとつです。ファイルハンドル、ミューテックス、メモリ、いずれもこれで管理できます。

C++class FileHandle {
public:
    explicit FileHandle(const char* path) : f_(std::fopen(path, "rb")) {}
    ~FileHandle() { if (f_) std::fclose(f_); }
private:
    std::FILE* f_;
};

関数が正常に戻ろうと例外を投げようと、デストラクタは走り、ファイルは必ず閉じられます。C が goto cleanup で解決している問題を、ここでは言語自身が保証します。

デストラクタから例外を投げてはいけません。 スタック巻き戻し中にデストラクタがさらに例外を投げると、プログラムはそのまま std::terminate します。C++11 以降、デストラクタは既定で noexcept です。

static メンバ

static メンバはオブジェクトではなくクラスに属し、すべてのインスタンスが一つの実体を共有します。

C++class Counter {
public:
    static int count;                    // 宣言
    static int get() { return count; }   // 静的メンバ関数に this はない
};
int Counter::count = 0;                  // 定義。クラス外に置く必要がある

C++17 では inline static int count = 0; とクラス内で直接定義でき、あの据わりの悪いクラス外定義を省けます。

静的メンバ関数は this を持たないため、非静的メンバに触れません。よくある用途はファクトリ関数と、素の関数ポインタを要求する C インターフェースのコールバックです。

メモリから見る

クラスを理解する最良の方法は、それがメモリ上で何になるかを見ることです。

  • 仮想関数のないクラスは、メンバを宣言順に並べ、アラインメントのパディングを足したものそのままです。等価な struct と完全に同じ配置になります。
  • 仮想関数が付くと、オブジェクトの先頭に vtable へのポインタが加わります(64 ビットで 8 バイト)。int 一つだけのクラスに仮想関数を足すと sizeof が 4 から 16 に跳ねるのはこのためです。vtable ポインタ 8 バイト、int 4 バイト、パディング 4 バイト。
  • 空クラスの sizeof は 0 ではなく 1 です。異なるオブジェクトは異なるアドレスを持たねばならないからです。

通常の業務コードではどれも問題になりませんが、密に詰めたいデータ構造——パーティクル、頂点、コンポーネント配列——を設計するときは、そのままキャッシュの挙動を決めます。レイアウトについては《C++ #2 Struct と Union》も参照してください。

このシリーズの記事

C++ プログラミング
  1. 01C++ #1 データとメモリ
  2. 02C++ #2 Struct と Union
  3. 03C++ #3 ポインタと配列
  4. 04C++ #4 関数
  5. 05C++ #5 オブジェクトとクラス
  6. 06C++ #6 継承
  7. 07C++ #7 コピー制御と演算子オーバーロード
  8. 08C++ #8 ポリモーフィズム
  9. 09C++ #9 C++11 の新機能
  10. 10C++ #10 C++14 の新機能
  11. 11C++ #11 C++17 の新機能