Skip to content

Latest commit

 

History

History
254 lines (199 loc) · 22.6 KB

object.md

File metadata and controls

254 lines (199 loc) · 22.6 KB

オブジェクトと変数

ある型のデータを入れることを想定した連続メモリ領域のことをオブジェクトと呼びます。 オブジェクトには対応する型の具体的なデータ、すなわち値が格納されます。 オブジェクトについた名前を変数といいます。 ここでは、オブジェクトや変数を C++ プログラムにおいてどう扱うべきかについて説明します。

オブジェクトや変数の分類

C++ における変数は値型変数、ポインタ型変数、参照型変数の 3 種類があります。 値型変数は、対応した型の値が格納されます。 算術型 (arithmetic type) の値 (int 型の 1 や float 型の 2.3 など) や、クラス型であればその具体的なインスタンスオブジェクトの内容です。 ポインタ型変数は、オブジェクトの位置を表わすメモリアドレスを格納します。 メモリアドレスが格納されるメモリ領域もオブジェクトとみなします。 つまり、ポインタ型変数にとっての「値」はメモリアドレスということになります。 例えば int** a; は 「int オブジェクトを指しているポインタ型オブジェクト」を指しているポインタ型オブジェクトに a という名前がついていることになり、二段階の関節参照を意味しています。 参照型変数は既に存在するオブジェクトの別名という扱いです。 型についての詳しい分類がここに書いてあります。 その説明によればオブジェクト型はポインタ型を含みますが、参照型を含みません。 関数の参照型引数やクラスの参照型メンバ変数はアドレスを格納しますので、これらの場合はポインタと大体同じものだと思ってもらって構わないと思います。

プログラムから見える、オブジェクトが配置されるメモリ領域には、静的領域、スタック領域、ヒープ領域の 3 つがあります。 グローバル変数やファイル内ローカル変数は静的領域に、関数内ローカル変数はスタック領域にデータが格納されます。 関数呼び出しの度に、呼び出し側のローカル変数を保持したままで新しいローカル変数用の領域を用意する必要があるため、スタックは伸びます。 逆に、関数呼び出し完了の度にそのローカル変数は寿命を迎えて不要になるのでスタックは縮みます。 ヒープ領域にはオブジェクトを配置できますが、それを指して使うには、ポインタ型変数や参照型変数が必要になります、つまりグローバル変数やローカル変数から辿れるようになっていないと使えません。 オブジェクトが配置される領域によって、スタックオブジェクト、ヒープオブジェクトなどと区別することがあります。

オブジェクトのメモリレイアウト

C++ を扱うならば、是非オブジェクトのメモリレイアウトを意識してもらいたいです。 例えば、以下のようなクラス A を考えます。

struct A
{
    int i;
    int j;
};

A のオブジェクトは、コンパイラやそのオプション次第で変わり得ますが、通常 4 + 4 = 8 bytes のデータを持つ連続メモリ断片となります。 つまりコンパイル時に型のサイズ sizeof(A) == 8 が決まります。 連続メモリ断片と呼んでいるのは、プロセスメモリ空間上で、ひとつのアドレス範囲で表現できるメモリ領域ということです。 CPU アーキテクチャが x86_64 などの little endian であれば、たとえば、int 型の 1 は、0x01, 0x00, 0x00, 0x00 という順序で 1 byte ずつ計 4 bytes 配置されます。 算術型など CPU がそのまま扱える型は、どのような値がどのようなバイト列になるのか、CPU によってフォーマットが決まっています。 一般に異なるアーキテクチャの CPU 同士では互換性はありませんが、int などの基本的な型は big endian か little endian くらいの違いしかないことがほとんどです。ポインタ型はアドレスという非負整数なので、サイズの違いはあれど整数型と同じフォーマットです。

以下のクラス B はどうでしょうか。

struct B
{
    int i;
    int j;
    A *a;
};

B のオブジェクトは 64bit アーキテクチャでは通常 4 + 4 + 8 = 16 bytes のデータを持つメモリ断片となります。 つまり sizeof(B) == 16 ということです。 ポインタ型メンバ変数 *a は別のメモリ領域にある A 型オブジェクトを指し得ます。 ひとつの連続メモリ断片としての B のオブジェクトには *a が指す A 型オブジェクトのアドレスは含んでいても、オブジェクトの値そのものは含まれないことに注意してください。 それは一般に、離れたアドレス上に存在する別の連続メモリ断片です。 よく連続メモリ断片としてのオブジェクトを四角形で、ポインタを矢印で表してその構造を図示したりします。 anullptr でない B オブジェクトを図示すると、四角形が 2 つで、それが矢印ひとつで結ばれている図となります。 文脈によっては、ポインタ型や参照型のメンバ変数が指しているオブジェクトも含めて広い意味でひとつの「オブジェクト」と考える場合もあります。

ユーザー定義型のオブジェクトについて、それらの要素のメモリレイアウトは必ずしも一意に定まるというわけではありません。 まず、アラインメントの問題があります。CPU によりますが、算術型やポインタ型は型のサイズと同じサイズのアラインメントを要求することが多いです。 例えば aarch64 ではアラインメントがずれていると命令実行が失敗します。 x86_64 ではアラインメントがずれていても多くの命令は実行できますが、性能に悪影響がでたり、アトミック性が保証されなくなったりします。 つまり、64bit アーキテクチャでは 64 bit 整数やポインタは 8 bytes で、アラインメントも 8 bytes にしておくのが無難です。 アラインメントがずれているメモリ領域にポインタデータを格納したりそこから取り出したりすることは memcpy を使えばもちろんできますが、アラインメントが正しい領域に持ってきてから実行するため、一手間かかります。 そのため、コンパイラは適切にパディングを入れてメンバ変数のアラインメントが正しくなるように調整します。 また、private:public: など異なるアクセス指定子で指定されたメンバ変数同士の順番は保証されないようです。 Virtual 指定によって生成される vtable へのポインタもオブジェクトに含まれますが、その位置はコンパイラ依存だったりします。 多重継承した場合のレイアウトも宣言順というわけではないようです。 C 構造体と同じ機能しか使わないクラスは standard layout ですが、そうだとしてもアラインメントの問題は残ります。

struct C
{
    char c;
    uint64_t i;
};

x86_64 の Linux において、GCC や Clang は C 型のオブジェクトを通常 16 bytes のレイアウトにします。 先頭に c のための 1 byte を割り当て、続く 7 bytes をパディングして、 その後、先頭から 8 bytes 目から i のため 8 bytes 割り当てます。 C 自身は 8 bytes アラインメントの制約を持ちます。 スタックオブジェクトの場合は、8 bytes アラインメントになるようにコンパイラが調整しますし、 malloc なども 8 bytes アラインメントされたメモリを必ず返してくるので 通常はアラインメントの問題が起きないようになっています。

オブジェクトのメモリ上でのレイアウトを常に固定したい場合があります。 CPU や OS などの互換性の制約は受けいれて、オブジェクトのメモリイメージをそのまま ディスクに格納したり、異なるホストに転送したりする場合です。 C 構造体を使うとしてもアラインメントの問題は残っているので、 自分で CPU や OS、コンパイラの気持ちになってそれを制御します。 つまり、パディング用のダミー変数を宣言します。 そして、static_assertoffsetof マクロを使って必要なメモリレイアウトになっているかをチェックします。

struct D
{
    char c;
    char padding0[7];
    uint64_t i;
};

static_assert(offsetof(D, c) == 0);
static_assert(offsetof(D, i) == 8);
static_assert(sizeof(D) == 16);

パディングを使わずにぎゅうぎゅう詰めにしたい場合があるかも知れません。 その場合は GCC や Clang などのコンパイラで用意されている __attribute__((packed)) が使えます。

struct E
{
    char c;
    uint64_t i;
} __attribute__((packed));

static_assert(offsetof(D, c) == 0);
static_assert(offsetof(D, i) == 1);
static_assert(sizesof(D) == 9);

この場合 E オブジェクトの i のアラインメントは当然ずれていますので、 memcpy を使って正しいアラインメントの別領域にコピーしてから使うなどの対応が必要になります。 ここまでくるとオブジェクトのメモリレイアウトはコンパイラに任せてしまって、 必要な場合は自分でフォーマットを定めて明示的にバイト列に変換するのが正しいように思います。

スコープと寿命

スコープとは、変数にアクセス可能なコード範囲のことです。 スコープにはいくつか種類がありますが、代表的なものとして、ブロックスコープ、関数スコープ、関数パラメータスコープ、クラススコープが挙げれらます。 大雑把ではありますが {} で囲まれた領域はスコープと思って下さい。 変数の寿命は変数が宣言されてから、対応するスコープの終わりまでです。 なぜスコープや寿命を意識する必要があるといえば、スコープの終わり、寿命が尽きた変数が指しているオブジェクトのデストラクタが暗黙的に呼ばれるからです。 我々の思い通りにリソースをコントロールするために、我々は変数の寿命がいつ来るかについて知っておく必要があります。 昔の C と違って、C++ ではブロックや関数の途中でも変数が宣言できますので、必要なときに宣言することで、スコープを可能な限り小さくすることができますし、メンテナンスの観点からもそうすべきです。

注意点として、new で確保したヒープオブジェクトのアドレスを生ポインタに格納し、その変数が寿命を迎えると、それが指していたヒープオブジェクトはリークします。 ヒープオブジェクトに元々寿命はないのです。 オブジェクトのリークとは、そのオブジェクトはもはや誰にも使われないのに、期待を裏切って開放されずにメモリを占有し続けてしまうことをいいます。 これは明らかにバグですね。 何故リークしてしまうのかといえば、生ポインタオブジェクトはデストラクタを持たず、それが指しているヒープオブジェクトを開放する責任がないからです。 明示的にヒープオブジェクトを所有させ、変数の寿命と共に delete させたいときはスマートポインタを使いましょう。 スタックオブジェクトは対応する関数呼び出しが完了すれば自動的にスタックが縮むことにより使っていたメモリが再利用可能な状態になりますので、ヒープオブジェクトのようなリークの心配はいりません。

関数によって値返ししたり、コンストラクタを直接呼び出して作ったオブジェクトなどは一時オブジェクトと呼ばれますが、一時オブジェクトの寿命はその使われ方によって変わります。 通常、一時オブジェクトの寿命はそれを含む文が終わるまでですが、const lvalue reference または rvalue reference で受けたときは、その変数のスコープまで寿命が延びます。 ここで注目している一時オブジェクトはあくまでスタックオブジェクトで、ヒープオブジェクトではないことに注意してください。

関数が値返しであるならば、返されるオブジェクトが関数内ローカルオブジェクトや一時オブジェクトだったとき、関数の返り値を受けとる変数にコピーやムーヴされ、それ自身は関数の終わりで寿命が来てデストラクタが呼ばれることが想定されます。 C++ では条件にもよりますが最適化によってこのコピーやムーヴが省略されます。 つまり、格納される変数は関数内のものから呼出側のものへと変わりますが実質的にオブジェクトの寿命が延びるわけです。 C++17 ではこの返り値関連の最適化を含む copy elision が義務化されました。

初期化

オブジェクトの初期化についても気をつける点があります。 原則として変数宣言や領域確保から初期化までの間に別の処理をせず、速やかに初期化を行うようにしましょう。 算術型などでは C 言語と同じように、int i; のように初期化なしで宣言した場合は内容が不定になります。 ヒープオブジェクトの場合も同様で、int* i = new int; と書くと不定となります。 グローバル変数や static 変数など静的領域に確保されるものは static int i; などと宣言するとゼロに初期化されます。 算術型をメンバ変数に持つクラスでも、コンストラクタの初期化子を使って初期化しないと不定になります。 たとえば struct A { int i; }; における iA の初期化後も不定です。struct A { int i; A() : i() {} } で初期化子を使うと A の初期化によってi0 となります。 そのような細かい知識を知らなくても安全なプログラムを書くために、明示的な初期化を心がけることをオススメします。 明示的な初期化とは以下のような操作です:

  1. 宣言だけでは初期化されない型は初期値を明示的に与えて宣言/確保する。例: int i = 0;int* i = new int(0);
  2. 宣言だけでは初期化されない型をメンバ変数に持つクラスは、コンストラクタの初期化子で明示的に値を与える。例: struct A { int i; A() : i(0) {} };
  3. メンバ変数の初期化が保証されていない型を使う場合は、与えられた手段で宣言/確保後出来るだけ速やかに初期化する。必要があれば自動で初期化を行うラッパークラスを作る。

明示的に初期値を与えたくない場合があるとすれば、それはコピーや計算結果の代入など他の手段で初期化する予定で、最初に 0 などを設定するのは無駄であり、性能に影響する、というケースでしょうか。 分かって使う分には問題ありませんが、メンテナンス性の上で、不定な値にアクセスするリスクがあるという認識は持っておいてください。 また、他人の書いた変数宣言やクラス定義のメンバ変数とコンストラクタを見て、宣言/確保時に不定なのか不定でないのかを判断するクセがつくと良いと思います。

C++11 からのユーザー定義型オブジェクトの初期化に関する注意点としては、A a; のような何も指定しない初期化 (default initialization)、() による初期化 (direct initialization)、 = による初期化 (copy initialization) に加えて、{} による初期化 (list initialization) も使えるようになったことです。 Direct initialization はコンストラクタに引数を与えて初期化するごく普通の初期化方法です。 Copy initialization はコピーコンストラクタやムーヴコンストラクタを使う初期化です。 List initialization はコンテナ型のオブジェクトにおいて std::initialization_list<T> を引数とするコンストラクタを用意しておいて、同じ型の要素を並べて初期化に用いるのが典型的な使い方のようです。

所有と借用

C++ で明確に定義されているわけではないと思いますが、プログラムを設計実装する上で重要な概念だと思うので、ここで、所有と借用について説明しておきます。

所有とは、所有者が、所有する対象リソースの開放に責任を持っていることを指します。 所有の関係として考えられるのは、変数とそれが指しているオブジェクトだったり、オブジェクトとそれが管理しているメンバ変数などのリソースだったりします。 あるリソースがあったとき、それを開放するのはひとりだけですから、明示的にせよ潜在的にせよ、任意のオブジェクトについて、その所有者は原則ひとりだけということになります。 C++ においては、[RAII](RAII については別の章で説明します) を使ってコンストラクタでリソースを確保し、デストラクタで開放するクラスを設計した場合、そのインスタンスオブジェクトは、まさに当該リソースを所有していると言えるでしょう。 値型変数は、寿命が来たときにそれが指しているオブジェクトのデストラクタを呼ぶことから、それを所有していると言えるでしょう。 自分でリソースを確保したわけでなくても、他のオブジェクトからムーヴされたリソースがあって、それを自分のデストラクタで開放する必要がある場合、そのオブジェクトは、当該リソースを所有していると言えるでしょう。 また、オブジェクトはそのメンバ変数(が指しているオブジェクト)を所有しているといえるでしょう。 has-a 関係と言われるわけですし、自身のデストラクタ呼び出し後、メンバ変数のデストラクタが呼ばれるわけですから、開放に責任を持っていると言えるわけです。 さらに、生ポインタや参照が指しているヒープオブジェクトについて考えてみましょう。 ポインタや参照はあくまでオブジェクトを指しているだけですし、複数のポインタや参照がひとつのオブジェクトを指していることもあります。 しかし、誰かがオブジェクトを開放しなければ、それはリークしてしまうわけなので、誰かが開放に責任を持っていることが期待され、潜在的であったとしても所有者がひとりいると考えることができます。

借用とは、自分は所有者ではないけれど、対象のオブジェクトやリソースを指していて使う行為を指します。 C++ においては、ポインタ型変数や参照型変数とそれが指しているオブジェクトが、多くの場合、借用関係にあたると考えられます。 先程の例で、複数のポインタがひとつのヒープオブジェクトを指しているとき、ひとりは所有者で、残りは借用者というわけです。

所有と借用の例で分かりやすいのが std::unique_ptr でしょうか。 以下に例を示します:

{
    A *b0, *b1;
    {
        std::unique_ptr<A> a0(new A);  // a0 が所有者
        b0 = a0.get();   // b0 は借用者
        std::unique_ptr<A> a1(std::move(a0));  // a0 から a1 に所有権が移動
        // a0 は nullptr を指すようになる
        b1 = a1.get();   // b1 は借用者
        // b0 も b1 も a1 と同じヒープオブジェクトを指している。

    }  // a0 は nullptr を指しているので何もしない。a1 のデストラクタでヒープオブジェクトを開放。
    // b0 および b1 は dangling pointer になる
}

RAII パターンは、リソースの寿命を変数の寿命と合わせることで、所有者であることを明確にする役割があると思います。 ある変数が、あるリソースを唯一指している状況にも関わらず、その開放に責任を持っているという自覚なしにコードを書けば、リソースリークしやすくなってしまいます。 また、借用者である変数が、その所有者よりも寿命が長いとき、その指している先が不正なメモリ領域である状態が発生します (dangling pointer/reference)。 それにアクセスしてしまうとセグメンテーションフォールトなど不正な動作をします。 すなわちバグです。

バグの少ないコードを書くために、所有者と借用者の区別をしっかりとつけて、所有者はリソースを責任をもって開放すること、所有者よりも借用者の寿命が長くならないように心がけましょう。 C++ では実際の開放はほとんどの場合デストラクタに任せれば良いので、専ら気にすべきなのは dangling pointer/reference ですね。 Rust などでは所有の概念を言語機能として持ち、プログラマに強力な制約を課してくるようですが、C++ はあくまでそれを助ける機能が存在するという印象です。 自由には責任が伴う、ということですね :)