こんにちは。田原です。
たいへん良く勘違いされるのですが、C++のコンストラクタはメモリを確保しません。コンストラクタの呼び出し側で確保されたメモリを使用するオブジェクトを初期化するだけです。このメモリ獲得は「こっそり」行われることがとがあるので見落とし易いのだと思います。
「こっそり」といえば、一時オブジェクトもこっそり確保され、こっそり開放されます。
今回は、これらのメモリ確保やコンストラクタ/デストラクタの呼出され方、一時オブジェクトの獲得/開放のされ方について解説します。
実は、この辺を曖昧にしているとC++の振る舞いを理解することが難しくなります。ちょっと早いような気もしますが、「鉄は熱い内に打て」とも言いますし、解説してみたいと思います。
皆さんが作られたC++プログラムはあちこちでメモリを獲得して使い、開放しています。
明示的に獲得したものもあれば、こっそりと獲得されるものもあります。
メモリ領域 | C++プログラムの要素 | |
---|---|---|
① | 静的変数用メモリ | グローバル変数 staticなローカル変数 staticなメンバ変数(後日解説します) |
② | スタック用メモリ | 関数の引数 ローカル変数 一時オブジェクト(計算結果を一時的に記録) |
③ | ヒープ用メモリ | new演算子, new[]演算子で獲得 |
C++コンパイラは、この表の①、②、③について異なるメモリ獲得用のマシン語コードを生成します。
①はリンカにてプログラム全体が必要とするメモリ・サイズを計算し、各変数に対してそのメモリ先頭からのオフセットを決定し、必要に応じてそこをアクセスするコードが生成されます。
②はその領域の寿命に応じてスタックからメモリを獲得し、開放するコードが生成されます。
③はnewとnew[]で明示的に獲得され、deleteとdelete[]で明示的に開放されますので、普通のnewやdelete関数呼び出しとなります。
さて、何か1つ忘れています。クラス内のメンバ変数はどこか?です。
クラス内に定義されているメンバ変数はそのクラスの一部ですから、そのクラスのメモリが割り当てられる時にクラスの一部として割り当てられます。従って、そのクラスが割り当てられた①②③と同じところになります。
更に、クラスはクラス型のメンバ変数を含むことができます。例えば以下のようにです。
struct Inner { int mFoo; }; struct Outer { int mBar; Inner mInner; }; Outer gGlobal; int main() { static Outer sStatic; Outer aLocal; Outer *aHeap=new Outer(); delete aHeap; }
この時、次のように確保されています。
メンバ変数 | 割り当てられる領域 |
---|---|
gGlobal.mBar gGlobal.mInner.mFoo sStatic.mBar sStatic.mInner.mFoo |
①静的変数用メモリ |
aLocal.mBar aLocal.mInner.mFoo |
②スタック用メモリ |
aHeap->mBar aHeap->mInner.mFoo |
③ヒープ用メモリ |
前節の表の「一時オブジェクト(計算結果を一時的に記録)」について説明します。
これはコンパイラが完全にこっそりと確保/開放しますので非常に意識しづらいのですが、これを理解しておくことで初めてあの難解な「右辺値参照」を理解でき、C++で高速なプログラムを記述する際にたいへん有利になります。
右辺値参照の解説はもう少し先ですが、今回、少し踏み込んだ解説をしてみます。
C++では式をたくさん書きますが、この式を計算する過程で計算結果を一時的に記憶しておく領域が必要です。
例えばint型のように小さいものでしたら、CPU内部のレジスタが使われることが多いですし、ちょっと大きめのクラスの場合はスタックが使われます。(それぞれを分けて考えると無駄に複雑になりますので、第9回目で述べたように両方とも「スタック用メモリ」に記録されると考えると理解しやすいです。)
さて、そのような一時オブジェクト用は必要に応じて獲得され、式の終わり(ほとんどの場合;セミコロンまで)で開放されます。それを示すため、次のようなプログラムを作ってみました。
#include <iostream> struct Foo { int mData; Foo(int iData) : mData(iData) { std::cout << "Foo(" << mData << ")\n"; } ~Foo() { std::cout << "~Foo(" << mData << ")\n"; } }; int main() { std::cout << "start\n"; std::cout << "S\n" << (Foo(1).mData+Foo(2).mData) << "\nE\n"; std::cout << "end\n"; }
このコードのようにコンストラクタとデストラクタで標準出力へ出力して、いつ生成されて、いつ破棄されるのか実際にみてみることが可能です。結構便利ですよ。
std::cout << "S\n" << (Foo(1).mData+Foo(2).mData) << "\nE\n";
がターゲットです。
これはC++的には1つの式ですね。
std::cout
はstd::ostream
型の変数で標準出力が割り当てられています。
そして、この演算子<<は、右辺を標準出力へ出力し、戻り値はstd::coutです。
ですので、上記のターゲットの式は次のような3つの部分的な式へ分解できます。
① std::cout << "S\n" ② std::cout << (Foo(1).mData+Foo(2).mData) ③ std::cout << "\nE\n
①の戻り値がstd::coutなので、
std::cout << "S\n" << (Foo(1).mData+Foo(2).mData) << "\nE\n";
の①の部分がstd::coutに置き換わり、
std::cout << (Foo(1).mData+Foo(2).mData) << "\nE\n";
となるのです。
これらの式は演算子<<の規定に従って左から順に処理されますので、①②③の順序で実行されます。
ただし、C++規格の面白いところなのですが、(Foo(1).mData+Foo(2).mData)
を処理するタイミングは処理系依存です。3つの部分的な式の実行順序は決められていますが、それらの式の左辺と右辺を処理するタイミングは規格では決まっておらず、処理系(コンパイラ)が自由に決めてよいのです。
これは、普通に式を計算する時にバグを生みにくくしつつ、処理系の実装の自由度を上げて性能を改善しやすくするための考え方です。
Visual C++とgccはどちらも(Foo(1).mData+Foo(2).mData)
が最初に処理されるようです。
そのため、main()関数の実行をスタートし、ここまでの処理が行われた時点で標準出力へ、次のように出力されます。
start Foo(1) Foo(2)
なお、Foo(1).mData+Foo(2).mData
は A + B
の形式の式ですが、AとBの処理順序もまた処理系依存です。Visual C++とgccはここもたまたま同じ順序(左から右)に処理するようですので”Foo(1)”→”Foo(2)”の順序で出力されています。
この結果は3なので、続いて次の3つの式を①②③の順序で処理します。
① std::cout << "S\n" ② std::cout << 3 ③ std::cout << "\nE\n
その結果、この時点での出力は以下のようになります。
start Foo(1) Foo(2) S 3 E
そして、式の終わりである ;(セミコロン)に到達した時点で一時オブジェクトが開放されるため、この時点でデストラクタが実行されます。この時、デストラクタが実行される順序はコンストラクトされた順序の逆となることが規格で決められています。
Visual C++とgccは規格通り動作するため、このタイミングでは次のような出力となります。
start Foo(1) Foo(2) S 3 E ~Foo(2) ~Foo(1)
そして、最後に”end\n”が出力されますから、最終的な出力は次のようになるわけです。
start Foo(1) Foo(2) S 3 E ~Foo(2) ~Foo(1) end
さて、以上のように式を計算する途中で必要になる一時オブジェクトもスタック上に確保され、そして式の最後(ほとんどの場合 ; です)で開放されます。
ところで、標準規格でスタックに記録すると決まっているわけではありません
混乱させて申し訳ないのですが、C++の標準規格でローカル変数などをスタックへ記録すると決められているわけではありません。
しかし、C++は関数が自分自身を再帰的に呼び出しても、バグでなければ呼出された側が呼び出した側のローカル変数を破壊することはありません。そして、この仕様を実現するためには、ローカル変数をスタック構造(Fist In Last Out)で記録する以外の方法が事実上ありません。一時オブジェクトや関数の引数も同様です。ですので、当講座ではスタック上にこれらの領域が確保されるものとして解説します。
一応、C++の標準規格でスタックにローカル変数等を記録することが定められているわけではないと言う事実を頭の片隅に置いてて下さい。
第6回目 左辺値・右辺値は演算子で決まる!!で、容器は左辺値、値は右辺値と説明しました。
一時オブジェクトはメモリを確保しますので、一見容器のように見えます。メモリが割り当てられているならアドレスもあるでしょう。本当に容器のように見えます。
しかし、C++の標準規格では値(右辺値)として定義されています。
1は右辺値です。1+1は2になりますが、これも値ですから右辺値ですね。さて、この2はどこかのメモリに一時的に記録されている筈です。
であれば、左辺値として扱ってもいいじゃないか?という発想も有りえます。
しかし、もし左辺値として定義すると、1は右辺値で1+1が左辺値というのは直感的に可笑しいですね。1+1がもし容器なら中に2以外の値を入れることもできます。これはありえませんね。
再度しかし、後日解説する予定ですが、C++には値を変更出来ないという意味のconst修飾子があります。これを使って1+1はconst修飾された左辺値と考える事はできるかも知れません。
これは私の推測ですが、左辺値は一時オブジェクトより寿命が長いです。一時オブジェクトと同じくスタックに記録される左辺値の寿命はそれが定義された{}ブロックの終わりまでです。
もし、一時オブジェクトを左辺値として定義すると、スタックに記録される他の左辺値と異なる寿命になります。その結果が及ぼすだろう無駄な混乱を防ぐために寿命が異なる左辺値を増やしたくなかったのではないかと想像しています。じぁ、一時オブジェクトの寿命を{}ブロックの終わりまでと規定すれば良いんじゃないかって?
それは無意味です。一時オブジェクトは暗黙的に確保されるため名前が付いていません。式の計算途中の値をいれているだけなので名前がなくても問題ないですが、その式が終わった後でも生きていることにするのならば、それをアクセスする方法が必要です。それは名前を付けるしかないですが、名前が付いていないのでアクセスする術がないです。なので、その寿命を延ばす意味がありません。更に更に、実は後日解説する予定のconst参照は右辺値を参照できます。そして、const参照で右辺値を参照すると名前が付くと同時に寿命が伸びます!! const参照はそれが定義された{}ブロックの終わりまで有効なので、参照された右辺値の寿命が{}ブロックの終わりまで伸びるのです。
Foo const& foo=Foo(3); // 通常は ; でFoo(3)は破棄されます。 std::cout << foo.mData << std::endl; // しかし、ここでもFoo(3)は生きてます。
1.で書いたように各アイテム(変数と一時オブジェクト)は各記憶領域へ記録されます。
その時、①②③の領域によって獲得コードは異なるため、記憶領域を確保/開放するコードはコンパイラが生成します。
そして、それらの領域の初期化と終了処理は、それぞれコンストラクタ、デストラクタとして定義されます。
この2つを適切に実行するため、コンパイラは以下のように呼び出します。
- オブジェクト生成時
まず、オブジェクトに割り当てるメモリを獲得します。
次に、(そのメモリをthisポインタとして渡して)コンストラクタを呼び出します。 -
オブシェクト開放時
まずは、(そのオブジェクトを記録しているメモリをthisポインタとして渡して)デストラクタを呼び出します。
次に、オブジェクトへ割り当てていたメモリを開放します。
静的変数用メモリの場合、プログラム起動時にメモリが全部まとめて獲得され、このタイミングで全て0でクリアされます。(ゼロ初期化と呼ばれる処理です。)
その後、処理系が定める順序でコンストラクタが呼出されます。
この時、必ずしも必要に応じて呼ばれるわけではないので頭痛いです。
静的変数が初期化される順序は、同じコンパイル単位に属するものについては、変数を定義した順序です。
異なるコンパイル単位の静的変数間の順序については規定されていませんし、ビルドする時の手順でも変わることが普通にあります。
これはコンストラクタや静的変数を初期化する時に差の静的変数の値に依存する時に問題になります。
例えば、以下のコードはgFoo0.mDataが0になることもあれば123になることもあります。
#include "common.h" int main() { gFoo0.print("gFoo0"); gFoo1.print("gFoo1"); return 0; }
#ifndef COMMON_H #define COMMON_H #include <iostream> class Foo { int mData; public: Foo(int iData) : mData(iData) { } void print(char const* iTitle) { std::cout << iTitle << " mData=" << mData << "\n"; } int get() { return mData; } }; extern Foo gFoo0; extern Foo gFoo1; #endif // COMMON_H
#include "common.h" Foo gFoo0(gFoo1.get());
#include "common.h" Foo gFoo1(123);
gFoo0用のFooコンストラクタが呼ばれる時までに、gFoo1用のFooコンストラクタが呼ばれておらず、ゼロ初期化されただけの0が入っているかも知れません。
コンストラクタが呼ばれる順序は同じコンパイル単位であれば定義された順序ですが、コンパイル単位が異なるとどちらが先に呼ばれるか決まっていません。
リンクの順序の影響を受けるようですが、必ずしもそうとは限りません。
コンパイラ・オプションで指定した順序なのか逆順なのか、はたまた別の順序なのか処理系依存です。
gccの場合はコンパイラ・オプションで指定した時の後ろ側から先に初期化されるようですが、インクリメンタル・リンクすると、そうとは限らないようです。
上記のwandboxの例で、左側中央付近にあるCompiler options:で、追加のソース・ファイルを指定しますが、この順序を逆にするとgFoo0が0になったり123になったりします。
なお、この性質ビルド方法で異なりますので、あてにしないことを強くお勧めします。
基本的にはコンストラクト時に他の静的変数をアクセスしない方が良いと思います。
どうしても必要な時は、実行時の静的初期化順序に関するイディオム等を参考にして下さい。
そして、main()関数終了後にデストラクタが呼出され、最後の最後、プログラムの終了時にメモリが全部まとめて開放されます。
2017年6月17日:サンプル・ソースを見直し、現象を確認できるようにしました。
ローカル変数、関数の引数、一時オブジェクトですが、これらは定義された文が実行される時に、そのコンストラクタが実行されます。
- ローカル変数は通常通り上から下へ実行されますので定義順と同じになります。
- 関数の引数の処理順序は処理系依存ですので、どの順序でコンストラクトされるのかは処理系毎に異なります。
- 一時オブジェクトも上記で説明したように処理系に依存します。
こちらは明示的にnewやdeleteを呼び出しますので、newやdeleteを実行した順序でコンストラクタが呼ばれます。
メンバ変数は上記の全ての場所に記録されます。メンバ変数を含むクラスとそれと同じレベルにあるクラスのコンストラクタが呼ばれる順序は上述の通りです。
今まさにコンストラクトされようとしているクラス内部のメンバ変数のコンストラクト順序も、C++の規格で規定されています。
これは単純でメンバ変数が定義されている順序と同じです。
勘違いし易い点が1つあります。
コンストラクタは引数違いのものを複数定義でき、それぞれ初期化子リストを記述できます。従って、初期化子リストはコンストラクタの数だけ書けます。
ということは、コンストラクタ毎にメンバ変数の初期化順序を制御できように見えてしまいます。
しかし、実際には初期化子リストに記述した順序ではなく、メンバ変数を定義した順序で初期化されます。
#include <iostream> struct Foo { int mData; Foo(int iData) : mData(iData) { std::cout << "Foo(" << mData << ")\n"; } ~Foo() { std::cout << "~Foo(" << mData << ")\n"; } }; struct Bar { Foo mFoo0; Foo mFoo1; Bar() : mFoo0(0), mFoo1(1) { } Bar(bool) : mFoo1(11), mFoo0(10) { } }; int main() { Bar bar0; Bar bar1(true); }
実際にやってみるとこのようにメンバ変数の定義順でコンストラクタが呼び出されることが分かります。
Wandboxの結果を見ても分かりますが、gccは親切に定義順と異なる順序で初期化子リストを書いていると警告してくれます。このような思い違いをしている人が非常に多いからだろうと思います。(かく言う私もその一人でした。)
なんでこんなに違和感の強い決まりごとになっているのでしょう?
後述しますが、各メンバ変数のデストラクタが呼ばれる順序は決まっています。コンストラクトされた時の逆順です。
初期化した時と逆の順序で終了処理するのは、大抵の場合適切です。例えば、マトリョーシカ人形は開いて行った時と逆の順序で仕舞わないと仕舞えませんね。それと同じことはコンピュータの世界でもたいへん良くあることなのです。そして、デストラクタは1つしか定義できません。コンストラクタ毎にデストラクタも定義できれば良いのですが、C++の仕様はそうなっていません。(実際にそうなると面倒です。)この場合、メンバ変数のコンストラクト順序を何か1つに決めないとデストラクタのコードが複雑になります。その1つとして、メンバ変数の並び順に決めたということだろうと思います。
上記でも少し触れましたが、クラス型メンバ変数のデストラクタは、そのメンバ変数を含むクラスのデストラクタが呼ばれた時に呼ばれます。
またデストラクタの呼び出し順序はコンストラクタが呼ばれたのと逆順です。
- 基底クラスのコンストラクタ
- メンバ変数のコンストラクタ
- 自クラスのコンストラクタのボディ
の順序で呼ばれますので、デストラクタは次の順序で呼ばれます。
- 自クラスのデストラクタのポディ
- メンバ変数のデストラクタ
- 基底クラスのデストラクタ
2-4-2.を2017年7月2日に追加しました。
デストラクタはコンストラクトされた時の逆順で実行されると規格で決められています。
例外は、以下の通りです。
- newで獲得した領域(deleteした順序でデストラクト)
- 寿命が伸ばされた一時オブジェクト(寿命が伸びたものは、その寿命までデストラクトが遅れる)
なお、標準ライブラリについてはこの規定は適用されていないようです。
例えば、std::vectorはVisual C++, gccのどちらとも先頭から順に解放されました。
憶えておく必要はありませんが、意外に嵌まることがあるので規格書の関連する部分を纏めてみました。
アイテム | N3337 | C++11の文法と機能 |
---|---|---|
グローバル変数 staticなローカル変数 staticなメンバ変数 |
3.6.2 Initialization of non-local variables | 3.6.2 非ローカル変数の初期化 |
ローカル変数 | 6.6 Jump statements | なし |
一時オブジェクト | 12.2 Temporary objects | 12.2 一時オブジェクト |
基底クラス、メンバ変数 | 12.4 Destructors 12.6.2 Initializing bases and members |
12.4 デストラクター |
配列 | 12.4 Destructors 12.6 Initialization |
12.4 デストラクター 12.6 初期化 |
規格書は”reverse order”、C++11の文法と機能は”逆順”で検索すると見つけやすいです。
今回は、C++の入門講座で取り扱われることが少ない、一時オブジェクトやコンストラクト順序について取り上げました。
コンストラクトやデストラクトの順番は知らなくても問題になるケースは比較的少ないですが、この辺りの仕組みは知ってくと様々な場面で有用なので解説しました。
また、一時オブジェクトについては難しいですが、少し頑張って見て下さい。この一時オブジェクトを参照するものが「右辺値参照」なのです。容器のような値のような どうもはっきりしない理解し辛い概念なのです。
さて、次回から数回を使って、オブジェクト指向プログラミングのコアと言われる、カプセル化、継承、動的ポリモーフィズムについて解説します。C++の最も強力な部分の1つですので少し時間を割きたいと思います。
C++の膨大な言語仕様の中ではあまり多くを締めているわけではありませんが、応用範囲が広いので学習効果が最も出やすい部分と思います。お楽しみに。