メモリとオブジェクト | Programming Place Plus 新C++編

トップページ新C++編

このページの概要

このページでは、メモリとオブジェクトに関する話題を取り上げます。ここでいうオブジェクトは、オブジェクト指向のものではなく、C++ の用語としてのオブジェクトのことで、たとえば変数を定義したときに作られているものを指しています。オブジェクトはメモリ上に構築されますが、メモリがどのように使われているのか、またいつまでそこにあり続けるのかといったルールを知っておく必要があります。

以下は目次です。要点だけをさっと確認したい方は、「まとめ」をご覧ください。



メモリモデル

前のページでポーカープログラムは終わりにして、ここからは新しいテーマを設定して進めていくことにします。

C++ は、いざとなればメモリ上のデータを直接読み書きできるプログラミング言語です。つまり、メモリ上のどの位置に何があるか管理できているのならば、変数を使わずに直接メモリ上のデータを触ることができます。必要性がないかぎりそのようなプログラムを書くべきではないですが、これが可能であることは C++(やC言語)の強さでもあります。そこで、そろそろメモリを意識した話題を取り上げていこうと思います。

このページからの新しいテーマとして、バイナリエディタ (binary editor)を設定します。バイナリエディタは、ファイルの内容を 1バイト単位で調べたいときに使われるツールです。無料で入手できるありふれたツールですが、あえて自力で作ってみることを通して、メモリに関する理解を深めていくことを狙います。


さて、C++ の標準規格では、C++ のプログラムが実行されるコンピュータのメモリに関して、以下の想定を立てています。これをメモリモデル (memory model) と呼びます1

C++ でストレージ (storage) と呼んでいるものは、ようはメモリ領域のことです。

一般的なコンピュータ用語としては、ストレージは、記憶装置の総称のことです。ややこしくならないように、今後は C++ のストレージをメモリ領域と表記します。

最小単位がバイトであるというのは、メモリ領域に配置したいデータが 1ビットの大きさしかないとしても、1バイト分の場所が取られることを意味しています。

ビット演算という演算や、ビットフィールドという機能を使うことで、1ビット単位でデータを取り扱う細かい処理も可能になります。ビット演算については、「ビット単位の処理」のページで解説します。

ユニークなアドレスが割り当てられているというのは、メモリ領域内の 1つ1つのバイトを区別できる表現があるということです。ユニーク (unique) とは「一意である」「同じものがない」という意味です。なお、このアドレスのことをメモリアドレス (memory address) と呼びます。メモリアドレスについては後で再び取り上げます

オブジェクト

C++ では、変数を定義するなどの手段によって、メモリ領域にオブジェクト (object) を構築します。ここでいうオブジェクトは、C++ の用語であって、オブジェクト指向におけるオブジェクトとは別のものです。また、「変数の定義によって構築されるオブジェクト」のことを単に「変数」というケースが多いです。

【上級】関数はメモリに置かれますが、オブジェクトではありません2

【上級】オブジェクトを構築する手段にはほかに、new式があります。

オブジェクトはメモリ上にあって(値がメモリ上に記憶されていて)、名前を付けることができ、型を持ち、寿命があります。

これまでのページで何度も変数を定義してきた経験から、メモリ上にあること、名前を付けられること、型を持つことは自然に受け入れられると思います。

寿命(存続期間) (lifetime) は、そのオブジェクトがメモリ上に存在することが保証される期間をあらわす用語です。そのオブジェクトのためのメモリ領域が確保された時点から始まり、そのメモリ領域を使わなくなったときに終わります。

【上級】明示的なコンストラクタによってオブジェクトの初期化が行われる場合は、その初期化が終わった時点からが寿命の開始になります。同様に、明示的なデストラクタがあるなら、それによる終了処理を終えた時点で寿命が終わりを迎えます。3

オブジェクトは、ほかのオブジェクトを含むことがあります。構造体が分かりやすい例で、構造体型の変数を定義すれば、それ自体が1つのオブジェクトですが、その中に含まれるデータメンバもそれぞれがオブジェクトです。同様に、配列の各要素もオブジェクトです。含まれる側のオブジェクトをサブオブジェクト (subobject) と呼びます。また、サブオブジェクトではないオブジェクトを、完全オブジェクト (complete object) と呼びます。

メモリアドレス

&演算子 (& operator) を使うと、変数のメモリ上での位置(メモリアドレス)を取得できます。

&変数

&演算子によって得られるメモリアドレスは、オペランドの型に応じたポインタ型 (pointer type) で返されます。ポインタ型は、メモリアドレスを保持するための型です。メモリアドレスは整数で表現されるので、その値を出力してみることで、オブジェクトがメモリ上のどの位置に配置されたのかを確認できます。

ポインタ型は総称した呼び名であり、どんな型のオブジェクトのメモリアドレスであるかによって型名が異なります。たとえば、int型のオブジェクトのメモリアドレスは int* と表現します。char なら char*、Data構造体なら Data*、std::vector<int> なら std::vector<int>* です。これは参照型の考え方と同じで、使う記号が & から * になっているだけです。

また、参照型に対して &演算子を使った場合、参照先のオブジェクトのメモリアドレスを取得します。int&型の変数 r を int& r {a}; のように定義したあと、&r で得られるものは、a のメモリアドレスです。したがってその場合の型は int* です。int&* ではありません。参照のポインタ型というもの自体ありえません

#include <iostream>

int main()
{
    int a {};
    short b {};
    long long c {};

    int* pa {&a};
    short* pb {&b};
    long long* pc {&c};

    int& r {a};
    int* pr {&r};

    std::cout << pa << "\n"
              << pb << "\n"
              << pc << "\n"
              << pr << "\n";
}

実行結果:

004FF7B8
004FF7AC
004FF79C
004FF7B8

この実行結果は環境によってまったく異なるものになりますし、同じ環境でも、実行するたびに変化する可能性があります。

std::cout はポインタ型の値を出力できますが、実行結果のように 16進数による表記になります。16進数に馴染みがない方は、コンピュータサイエンス編>「基数」のページを参照してください。

ここでは、変数a のオブジェクトが 004FF7B8 というメモリアドレスに配置されていることが分かります。a は int型で、この処理系では 4バイトなので、004FF7B8 を先頭に 4バイト分のメモリを消費しています(004FF7B8~004FF7BB)。a と b のメモリアドレスを見比べると、b の方が小さいので、変数を定義した順番どおりにメモリアドレスが増えていくわけではないことも分かります。


ところで、std::cout は char*型を特別扱いしてしまうため、char* の値はうまく出力できません。どうしてもメモリアドレスを確認したければ、std::cout << static_cast<void*>(&c) のように、void* という型にキャストしてください。

#include <iostream>

int main()
{
    char c {'A'};

    std::cout << static_cast<void*>(&c) << "\n";
}

実行結果:

00EFFBD7

構造体の場合

今度は構造体にまとめてみます。

#include <iostream>

int main()
{
    struct S {
        int a;
        short b;
        long long c;
    };

    S s {};
    S* ps {&s};
    int* pa {&s.a};
    short* pb {&s.b};
    long long* pc {&s.c};

    std::cout << ps << "\n"
              << pa << "\n"
              << pb << "\n"
              << pc << "\n";
}

実行結果:

00BEF814
00BEF814
00BEF818
00BEF81C

構造体のデータメンバのメモリアドレスは、&s.a のような記述で取得できます。

C言語的な意味での構造体であれば、データメンバのオブジェクトは、宣言された順番どおりにメモリ上に配置されます4。実行結果からもそれが分かります。構造体変数そのもののメモリアドレスと、先頭のデータメンバのメモリアドレスが同じであることも分かります。

【上級】この保証は同一のアクセス指定で、static でないデータメンバに限られています。

なお、データメンバどうしの間隔が開く場合があります4。さきほどのサンプルプログラムの場合、この処理系の short型は 2バイトですが、b と c のあいだが 4バイト開いていることが分かります。これは効率の向上や、実行環境の都合などの理由でコンパイラが行う調整によるものです。この余分な隙間のことを、パディング (padding) と呼びます。

パディングが追加された場合には、構造体型の大きさは、すべてのデータメンバの大きさを足し合わせたものよりも大きくなります。

【上級】データメンバの順番に重大な意味がないのならば、大きいほうから順に並べるようにすることで、パディングが入る量を抑えられます。

offsetofマクロ

C言語的な意味合いでの構造体であれば、データメンバがその構造体の先頭から何バイト目のところにあるかを、offsetofマクロを使って調べられます。offsetofマクロは、<cstddef> で定義されています。関数形式マクロになっており、次のように使います。

offsetof(構造体型名, データメンバ名)

型名とデータメンバ名が分かればいいので、構造体型の変数を定義する必要はありません。結果は、std::size_t型で返されます。

【上級】offsetofマクロの対象が、標準レイアウトクラスと呼ばれる形式に合致する構造体型でない場合は未定義の動作になります。また、静的データメンバやメンバ関数に対する使用も未定義の動作です5

以下は使用例です。

#include <cstddef>
#include <iostream>

int main()
{
    struct S {
        int a;
        short b;
        long long c;
    };

    std::cout << offsetof(S, a) << "\n"
              << offsetof(S, b) << "\n"
              << offsetof(S, c) << "\n";
}

実行結果:

0
4
8

配列の場合

配列の場合を確認します。

#include <iostream>
#include <vector>

int main()
{
    std::vector<int> v {0, 1, 2, 3, 4};

    auto pv = &v;  // std::vector<int>* pv {&v};
    std::cout << pv << "\n";

    for (std::vector<int>::size_type i = 0; i < v.size(); ++i) {
        std::cout << &v[i] << "\n";
    }
}

実行結果:

006FFA74
00AD5D58
00AD5D5C
00AD5D60
00AD5D64
00AD5D68

std::vector 自身のメモリアドレスだけは全然違う数値になりますが、配列の各要素はメモリ上で隙間なく連続的に並びます6この例では、要素は 4バイトの int型なので、メモリアドレスも 4 ずつ増加しています。この特性は、配列のメモリ上での表現を理解するうえで非常に重要です。

この特性は std::string でも同様です。

アラインメント

オブジェクトが配置されるメモリアドレスには、型に応じた制約があります。これをアラインメント (alignment) と呼びます。

アラインメントは整数値であり、オブジェクトは、メモリアドレスがこの数値の倍数になるように配置されます。たとえば、ある型のアラインメントが 4 であるとすると、その型のオブジェクトが配置されるメモリアドレスは 4 の倍数のところに制約されます。

このような制約が課せられている意味は、オブジェクトにアクセスする効率を高めることや、環境によっては一定の倍数のアドレスでなければアクセス自体ができないといった事情に対応することです。こういった事情は環境によって異なるため、どの型にどんなアラインメントが設定されるかは処理系定義になっています。

alignof式

ある型のアラインメントは、alignof式 (alignof expression) を使って取得できます。

alignof(型名)

得られる値は、std::size_t型の定数です。

#include <iostream>

int main()
{
    struct S {
        long long a;
        char b;
        short c;
    };

    std::cout << "char: " << alignof(char) << "\n"
              << "short: " << alignof(short) << "\n"
              << "int: " << alignof(int) << "\n"
              << "long: " << alignof(long) << "\n"
              << "long long: " << alignof(long long) << "\n"
              << "S: " << alignof(S) << "\n";
}

実行結果:

char: 1
short: 2
int: 4
long: 4
long long: 8
S: 8

一般的に、char や int などの基本型 (fundamental types) のアラインメントは、sizeof演算子で取得できる大きさと一致します。

S のアラインメントが 8 であることに注目しましょう。sizeof(S::a) + sizeof(S::b) + sizeof(S::c) が 11 なので 11 になりそうですが、そうはなりません。S のアラインメントが 8 になるのは、S のデータメンバの中で、一番大きいアラインメントに合わされた結果です(このサンプルでは long long型のデータメンバ a の 8 が一番大きい)。

そもそも前述したパディングの存在によって、S の大きさは 11 より大きくなります。パディングがどう入るかといえば、データメンバのアラインメントを保つように入ります。S型の変数 s を定義すると、S のアラインメントは 8 なので 8 の倍数のメモリアドレスに配置されます(たとえば 1000)。先頭のデータメンバ a は long long型で、このアラインメントも 8 なので問題ありません。データメンバ b のアラインメントは 1 なので、どこに配置しても問題ないので、続きの場所 (1000 + 8 = 1008) に置かれます。その次のデータメンバ c を続きの場所(1008 + 1 = 1009)に置くことはできません。c のアラインメントは 2 ですが、1009 は 2 の倍数ではないからです。そこで 2 の倍数である 1010 に配置するため、b と c のあいだに 1バイトのパディングを挟みます。

ここまでの想定どおりなら、S が占有するメモリの範囲は 1000~1011 ということになりますが、もし s が S型の “配列” だったらどうなるでしょうか。s[0] が占有するメモリの範囲が 1000~1011 ならば、s[1] は 1012 のところに配置されなければなりません。なぜなら、前述しているように、配列の要素は隙間なく連続的に並ばなければならないからです。ところが 1012 は 8 の倍数ではないため、s[1].a のアラインメントの要件を満たせません。

この問題を解決するために、構造体の最後のデータメンバの後ろにもパディングが必要です。s[1].a を 8 の倍数である 1016 に置けるように、不足している 4バイト分のパディングを末尾に加えます。結果、S が占有するメモリの範囲は 1000~1015 の 16バイト分ということになり、このうち 5バイト分はパディングが占めていることになります(sizeof(S::a) + sizeof(S::b) + 1 + sizeof(S::c) + 4 = 16)。実際、sizeof(S) をしてみれば 16 が得られます。

パディングを入れずともアラインメントを適切に保てるのならば、パディングが入ることはありません。

alignas

アラインメント指定子 (alignment specifier) を使うと、アラインメントの指定をデフォルトよりも厳しい値に強制できます。この機能が必要になる場面はあまりないですが、一応紹介しておきます。

アラインメント指定子は alignas キーワードで表され、以下の2つの使い方があります。

alignas(整数の定数式)
alignas(型名)

定数式を指定する場合は、その定数式の結果が新しいアラインメントになります。0 になる場合は無視されます。また、処理系が対応していない値はエラーになります。

型名を指定する場合は、その型のアラインメントと同じになります。つまり、alignas(T)alignas(alignof(T)) と同義です。

アラインメント指定子を記述できる位置はいくつかあります。

記述する位置がやや分かりづらいですが、次のように型名の手前に書きます。

#include <iostream>

int main()
{
    struct alignas(16) S1 {
        int a;
        short b;
    };
    struct S2 {
        alignas(8) int a;
        short b;
    };
    enum class alignas(16) E {
        e1,
        e2,
        e3,
    };
    alignas(8) int a {};
    alignas(S1) short b {};

    std::cout << alignof(S1) << "\n"
              << alignof(S2) << "\n"
              << alignof(E) << "\n";
}

実行結果:

16
8
16

【上級】alignas は複数併記することができ、その場合は一番厳しいものが適用されます。

ストレージ期間

オブジェクトの寿命の開始と終了のタイミングは、ストレージ期間(記憶域期間) (storage duration) という考え方で決まります。

ストレージ期間には以下の4種類あります。

  1. 自動ストレージ期間
  2. 静的ストレージ期間
  3. 動的ストレージ期間
  4. スレッドストレージ期間

あるオブジェクトがどのストレージ期間になるかは、オブジェクトをどのように構築したかによって決定されます。このページでは、自動ストレージ期間と静的ストレージ期間について取り上げます。ほかの2つは機会をあらためて説明することにします。

サブオブジェクトについては、それが含まれる完全オブジェクトのストレージ期間と同じになります7

ストレージ期間の範囲外のタイミングでオブジェクトにアクセスすることは未定義の動作なので、避けなければなりません。

ストレージ期間の終わりを迎えたからといって、わざわざメモリ領域を消去することは時間の無駄なので、メモリ上に値が残ったままになっている可能性はあります。しかしそのような期待をして、アクセスしてはいけません。

自動ストレージ期間

次のように構築されたオブジェクトは、自動ストレージ期間 (automatic storage duration) を持ちます8

つまり、これまでのページで行ってきた方法で、関数内で定義した変数が該当します。

自動ストレージ期間を持つオブジェクトの寿命は、定義を行ったときに始まり、変数の定義を行ったブロックの終わりで終了します。ある関数内で定義した変数が自動ストレージ期間を持つのなら、その関数から抜け出したときには確実に寿命を終えることになります。関数内で定義した変数の参照を返すことが危険なのはこのためです(「関数から値を返す」のページを参照)。

また、関数に入るたびに作り直されているので、メモリアドレスが毎回変わる可能性があります。

【上級】ほとんどの処理系で、自動ストレージ期間を持つオブジェクトを、メモリ上のスタック領域に配置しますが、C++ の規格上はそのように強制しているわけではありません。

同じ位置で定義したとしても、staticキーワードを付加した場合は、静的ストレージ期間を持ちます。

void f()
{
    int v1 {100};         // 自動ストレージ期間
    static int v2 {100};  // 静的ストレージ期間
}

externキーワードを付加した場合、その変数の定義が別のところで行われていることを示しており、つまり定義ではなく宣言になります。その宣言自体はオブジェクトを構築しません。

#include <iostream>

int v {100};  // 定義

int main()
{
    extern int v;  // 宣言
    v = 200;
    std::cout << v << "\n";
}

実行結果:

200

変数v の定義は関数の外側にあり、この場合、静的ストレージ期間を持つことになります。


registerキーワードは非推奨の機能であり、使いどころがないので解説は省きます。

【C++17】registerキーワードは、仕様からも正式に削除されました(予約語としては残っています)9

自動ストレージ期間を持つ変数を自動変数 (automatic variable) と呼ぶことがあります。また、ローカル変数(局所変数) (local variable) という呼び方をすることがありますが、この用語はストレージ期間が何であるかを無視しています。staticキーワードを付加して宣言されたローカル変数は、静的ストレージ期間を持つため、ローカル変数ではあっても自動変数ではないことに注意が必要です。

静的ストレージ期間

次のように構築されたオブジェクトは、静的ストレージ期間 (static storage duration) を持ちます10

静的ストレージ期間を持つオブジェクトの寿命は、プログラムの実行開始直後に始まり、プログラムの実行が終わったときに終了します。つまりプログラムを実行しているあいだずっと生き続けており、メモリアドレスも変化しません

静的ストレージ期間を持つ変数を静的変数 (static variable) と呼ぶことがあります。

前の項ですでに取り上げたとおり、staticキーワードを付加して宣言されたローカル変数は、静的ストレージ期間を持ちます。よく静的ローカル変数 (static local variable) と呼ばれています。

静的ローカル変数を使うと、関数から抜け出したあとも値が保持されるローカル変数を実現できます。

#include <iostream>

void f()
{
    static int s {0};  // ずっと生存しているので、関数を抜けても値が保持されている
    std::cout << s << "\n";
    s++;
}

int main()
{
    for (int i = 0; i < 10; ++i) {
        f();
    }
}

実行結果:

0
1
2
3
4
5
6
7
8
9

プログラムが実行されているかぎり寿命が終わらないので、呼び出し元に参照を返して、参照経由でアクセスしても未定義の動作になる恐れはありません。

静的ローカル変数は便利なこともありますが、取り扱いが難しくなるケースもあり、基本的には避けておくのが無難です。


データメンバの宣言にも staticキーワードを付加できます。その場合、そのデータメンバは静的ストレージ期間を持つことになります。このようなデータメンバは、静的データメンバ (static data member) と呼ばれます。静的メンバ変数 (static member variable) と呼ぶこともあります。

静的データメンバの解説は先のほうのページ「静的メンバ」で行います。

関数の外で定義される変数や、thread_localキーワードについては、ここでは解説を省きます。


静的変数には少々複雑な初期化のルールがあります11

まず、プログラムの実行開始後、main関数が始まるよりも前の時点でオブジェクトが構築され、ゼロ初期化 (zero-initialize) が行われます。

ゼロ初期化とは名前のとおり 0 で初期化することをいいます。0 は整数なので、初期化されるオブジェクトが整数型でない場合は 0 をその型にキャストしたものを使います(たとえば bool型なら false ということになる)。配列や構造体では、すべての要素(サブオブジェクト)に同じことを適用します。12

静的変数に定数式による初期化子を与えている場合は、上記のゼロ初期化に続いて、その定数式による初期化が行われます。これを定数初期化 (constant initialization) と呼びます。

ゼロ初期化と定数初期化を合わせて、静的初期化 (static initialization) といいます。そして、静的初期化ではないすべての初期化は、動的初期化 (dynamic initialization) と呼ばれます。動的初期化には、定数式でない初期化子による初期化や、コンストラクタの呼び出しが該当します。

コンストラクタは、クラスの機能の1つです。「コンストラクタ」のページで取り上げます。

静的ローカル変数の動的初期化は、プログラムの実行中にその変数の定義を初めて通過するときに行われます13。ローカル変数でない場合の動的初期化のタイミングは実装定義となっていますが14、いずれにしても、動的初期化は、必ず静的初期化よりも後です。

【C言語プログラマー】C言語では、静的ストレージ期間を持つオブジェクトの初期化は、プログラムの実行開始直後に1度だけ行うことになっています。そのため、静的ローカル変数の初期化子は定数でなければなりません。

ある静的変数を初期化するために、アクセスできる位置にあるほかの静的変数を使うこともできますが、初期化の順序に注意しなければなりません。

同じ翻訳単位で定義されている静的変数は、定義された順序通りに初期化される保証があります15。一方で、異なる翻訳単位にある静的変数とのあいだでは、どのような順序で初期化されるかという保証がありません。

まとめ


新C++編の【本編】の各ページには、末尾に練習問題があります。ページ内で学んだ知識を確認する簡単な問題から、これまでに学んだ知識を組み合わせなければならない問題、あるいは更なる自力での調査や模索が必要になるような高難易度な問題をいくつか掲載しています。


参考リンク


練習問題

問題の難易度について。

★は、すべての方が取り組める入門レベルの問題です。
★★は、自力でプログラミングができるようなるために、入門者の方であっても取り組んでほしい問題です。
★★★は、本格的にプログラマーを目指す人のための問題です。

問題1 (確認★)

ローカル変数について、そのストレージ期間が自動ストレージ期間である場合と、静的ストレージ期間である場合とで、どのような違いがあるか説明してください。

解答・解説

問題2 (基本★)

std::string の各文字がメモリ上で隙間なく連続的に並ぶことを確認してください。

解答・解説

問題3 (基本★★)

パディングが入った構造体型を std::vector の要素にしたとき、各要素のデータメンバがメモリ上にどのように配置されるか確認してください。

解答・解説


解答・解説ページの先頭



更新履歴




はてなブックマーク に保存 Pocket に保存 Facebook でシェア
X で ポストフォロー LINE で送る noteで書く
rss1.0 取得ボタン RSS 管理者情報 プライバシーポリシー
先頭へ戻る