C では、基本言語仕様を実現する上で特別なランタイムが必要になることはありませんでした。確かに、C でも処理系によっては、乗除算、浮動小数点演算、ブロック転送等のランタイムルーチンが使われることがあります。
しかし、これらはプロセッサにない機能を補ったり、効率を向上させるためのものであり、本質的ではありません。ところが、C++ では基本言語仕様とライブラリの境界が C ほど明確ではなく、言語に組み込まれた機能を使う場合でさえ、いくつかのクラスや関数が必要になることがあります。
ランタイムが必要になるのは、ごく大雑把にいうと、次の機能を使う場合です。
PC等の環境では、こうしたランタイムは開発ツールが用意してくれているものを使うだけで済みますが、組み込み環境の場合は、C でもスタートアップ等を自分で用意しないといけなかったように、C++ 特有のランタイムも自分で何とかしなければならない場合があります。
以下、これら4種類のランタイムを詳しく見ていきたいと思います。
プログラムの起動と終了のためのランタイム
プログラムの起動と終了のためのランタイムは、主としてスタートアップから呼び出すことになります。処理系によってはサブルーチン化しておらず、直接必要な処理を自分で記述しなければならない場合もあります。
具体的には、main*1が呼び出される前に非局所オブジェクト(関数の外で宣言されたオブジェクト)を構築し、main からリターンした後に静的記憶域期間を持つオブジェクトを解体します。自動記憶域のオブジェクトの構築・解体にはランタイムは本質的には不要です。
なお、exit や abort(そして atexit も)といったライブラリ関数は、C++ では、たとえ自立処理系であっても必ずサポートされることになっています。というのも、これらの関数は内部で静的記憶域期間を持つオブジェクトを解体するために使われるためです。
通常、非局所オブジェクトの動的初期化処理は、コンパイル時に翻訳単位ごとに一つの関数にまとめられ、その関数のポインタが決まったセクションに格納されます。この際、関数内で宣言された静的記憶域期間を持つオブジェクトは対象とはなりません。
プログラムの起動時には、翻訳単位ごとに構築処理関数を集めたセクション*2に格納されたポインタを順にたどり、そのポインタが指す関数を実行していきます。言語仕様上、翻訳単位間の呼び出し順序は未規定ですが、現実にはリンクの順番通りになることが多いようです。
以下に、起動時の処理の概念となるコードを示します。
extern void (* const _ctors_end[])();
for (void (* const* p)() = _ctors_start; p < _ctors_end; p++)
{
(**p)();
}
ここで、_ctors_start は構築処理関数のポインタが格納されたセクションの先頭を、_ctors_end はセクションの終端 +1 を意味するものとします。
起動時の構築処理と同様、通常、静的記憶域期間を持つオブジェクトの解体処理も、コンパイル時に翻訳単位ごとに一つの関数にまとめられ、その関数のポインタが決まったセクションに格納されます。構築処理とは異なり、関数内で宣言された静的記憶域期間を持つオブジェクトも対象となります。
プログラムの終了時には、翻訳単位ごとに解体処理関数を集めたセクション*3に格納されたポインタを順にたどり、そのポインタが指す関数を実行していきます。言語仕様上、翻訳単位間の呼び出し順序は未規定ですが、現実にはリンクの順番の逆になることが多いようです。
規格上は、非局所オブジェクトの初期化中に atexit が呼び出された場合などもからめて、非常に煩雑なルールになっています。しかし、実在する処理系の中で、本当に規格どおりの振る舞いになるものはほとんどないと思います。その意味でも、非局所オブジェクトの初期化順序や静的記憶域期間を持つオブジェクトの解体処理順序に依存したプログラムは避けるべきです。
main とは限りません。*2 GCC では
.ctors セクションがこれにあたります。*3 GCC では
.dtors セクションがこれにあたります。動的メモリ管理のためのランタイム
動的メモリ管理のためのランタイムというのは、早い話が new および delete 演算子とその関連部分のことです。new 演算子は malloc を使って実装されることが多いため、new を使うためには malloc や free といった外部ライブラリも必要になります。
malloc や free といった、動的なメモリ割付け・解放を行うために直接必要な機能の他に、割付けに失敗した場合に必要になる例外処理やハンドラに関するランタイムも同時に必要になります。new および delete は C++ の基本機能ですが、それだけでプログラムサイズはかなり膨らみます。
さらには、malloc を使うにせよ、他の方法を使うにせよ、マルチタスク環境では排他制御のための何らかの機能が必要になります。単純に割り込みを禁止するだけであればランタイムまでは不要でしょうが、応答性能を向上させるにはセマフォ等の機能が必要になる場合もあります。
メモリの割付けに失敗した場合には、std::set_new_handler で登録されたハンドラを呼び出すか、std::bad_alloc 例外を送出することになります。std::bad_alloc クラスは単純なクラスですが、仮想関数を持つために、仮想関数テーブルなどをリンクする必要が発生します。
例外発生を抑止するために std::nothrow を使用する場合でも、必要なランタイムはあまり変わりません。それどころか、std::nothrow のインスタンスが必要になってきます。std::nothrow は new の内部で例外を封じ込めるためのものであって、例外処理を使わないという意味ではないからです。
例外処理のためのランタイム
例外処理は、ある意味で C と C++ の間で最も大きな違いを作り出している要因であるといえます。例外処理は非常に強力な仕組みですが、C++ のプログラムサイズが増大する最大の要因にもなっています。
例外処理のためのランタイムには、std::terminate 関数などの「目に見える」部分と try/catch/throw といった言語レベルの機能を実現するための「目に見えない」部分に分かれます。特に、この「目に見えない」部分は、処理系によって実装方法が大きく異なっています。
まずは比較的簡単な「目に見える」部分について解説します。目に見える部分はライブラリ関数として実装されており、例外処理が特定の状態になったときに呼び出されるハンドラ、例外処理の状態を調べる関数、および例外クラスからなります。
std::terminate は、送出された例外が最後までcatch されなかった場合や、例外が catch される前に別の例外が発生した(二重例外)場合に、呼び出される関数で、std::set_terminate を使って動作を変更することができます。
std::unexpected は、例外指定と矛盾した例外が送出された場合に呼び出されるハンドラで、std::set_unexpected で動作を変更することができますが、いずれにしてもプログラムは強制終了します。
{
throw std::runtime_error("test"); // 例外指定と矛盾した例外を送出
}
std::unexpected のデフォルトの動作では、例外指定に std::bad_exception が含まれている場合、実際に送出された例外を std::bad_exception に置き換えます。そうでなければ std::terminate を呼び出し、プログラムを強制終了させます。
std::uncaught_exception は、例外が送出されてから catch されるまでの間であれば true を、それ以外では false を返します。これによって、例外発生時だけに特定の処理を呼び出したり、二重例外を回避することができます。
これらの関数やクラスはヘッダで宣言定義されています。詳細な定義はヘッダを見るなどしてください。
次に「目に見えない」部分のランタイムについて解説します。前述したように、この部分の実現方法は処理系によって大きく異なっています。代表的な実現方法では、内部で setjmp/longjmp を呼び出しているようです。
この部分のランタイムが、単なる setjmp/longjmp と大きく異なるのは、例外が throw されてから catch されるまでの間にある、自動オブジェクトのデストラクタを呼び出す点です。実際にどれだけのデストラクタが呼び出されるかは、例外が送出された時点のブロックに依存します。
どれだけのデストラクタを呼び出せばよいかの情報は、例えばリスト構造を用いるなどして、ブロックに出入りするたびに再設定しなければなりません。*4これはコンテキストに強く依存するため、マルチタスク環境では、タスクごとに個別に管理する必要が発生します。
もうひとつ、setjmp/longjmp と異なる点は、longjmp が整数値しか返せなかったのに対して、例外処理では型情報を持ったオブジェクトを送出できることです。オブジェクトを送出するためには、コピーするための領域を動的に割り付ける必要が発生します。
例外が送出されるとき、たとえ参照として catch される場合であっても、オブジェクトのコピーは必ず発生します。throw する時点ではどんな方法で catch されるか分からないのと、スタックを巻き戻すことで寿命が尽きてしまうオブジェクトを参照させるわけにはいかないからです。
{
std::logic_error e("test");
throw e;
// もしコピーされなければ、この時点で e の寿命は尽きる
}
catch (std::logic_error& e)
{
}
オブジェクトのコピー先の領域は、スタック以外から動的に割り付ける必要があります。new で割り付けてもよいのですが、new 式自体が例外を送出する場合でも正しく動作させるためには、非常用の静的な予備領域をいくらか確保しておく必要があります。
当然のことながら、動的に割り付けたコピー用の領域は、使い終わった時点で解放しなければなりませんし、解放の前には例外オブジェクト自身のデストラクタを呼び出す必要もあります。
このように、例外処理のためには、かなり複雑な処理を目に見えない裏側で行わなければなりません。C++ を使うとプログラムサイズが大きくなるのは、例外が送出されたときにどのデストラクタを呼び出すべきかの管理情報が生成されることによる影響が最も大きいといえます。
実行時型識別のためのランタイム
ランタイムについての最後の話題は、実行時型識別(RTTI)のためのランタイムです。実行時型識別のためのランタイムは大きく分けて2種類があります。ひとつは typeid によって参照する std::type_info オブジェクトであり、もうひとつは dynamic_cast に関わるものです。
typeid は、ちょうど sizeof のように型または式を演算対象にとる演算子です。sizeof は式を与えた場合でも、実際にその式が評価されることはありませんが、typeid に多相的クラス型の左辺値を渡した場合は、オブジェクトへのアクセスが発生します。
typeid に関するランタイムは演算対象が多相的クラス型の左辺値で、かつそれが空ポインタによる間接参照であった場合には、std::bad_typeid 例外を送出する場合に必要になります。
{
public:
virtual ~foo(); // 仮想関数があるので多相型になる
};
foo* p = 0;
typeid(*p); // 空ポインタによる間接参照が発生し、
// std::bad_typeidが送出される。
std::bad_typid クラスも std::bad_alloc 等と同じく単純なクラスですが、やはり仮想関数テーブルなどのコードやデータが必要になります。また、例外を送出することになるので、例外処理のためのランタイムも必要になります。
もうひとつは dynamic_cast によるものですが、ここでも参照型のキャストの場合、変換に失敗した場合には std::bad_cast 例外が送出されることになるため、std::bad_typeid と同様のランタイムが必要になります。

