LoginSignup
33
31

メモリ領域の再利用は結構気を付けたほうがよい件

Last updated at Posted at 2023-12-12

おことわり

この記事で言及する C++ 規格はすべて、C++20 相当のドラフト N4861 (PDF/HTML) を基準にしています。

はじめに

私が書いている C++ プログラムでは、処理の途中経過や結果を格納するためのメモリ領域を必要に応じて再利用しています。
一般的には処理の先頭で必要なぶんのメモリを確保し、処理の末尾で解放するわけですが、メモリの確保・解放にかかるコストと処理の粒度を勘案し、再利用するほうが効率的だと判断するときがあります。

// メモリ領域を再利用するイメージ
void reuse_storage()
{
	// この記事において pointer のアドレスは各オブジェクトに必要なアライメントを満たしているとする
	auto pointer = operator new(1024);

	// メモリ領域を std::string に利用
	auto str = new (pointer) std::string{ "hoge" };
	...
	str->~basic_string();

	// メモリ領域を double の配列に再利用
	auto ds = new (pointer) double[16]{};
	...

	operator delete(pointer);
}

ちょっと前の私はメモリ領域の再利用について、「placement new と明示的なデストラクタ呼び出しを気を付ければいいんでしょ?」くらいの感覚しかありませんでした。
しかし cppreference.comcpprefjp を眺めているうちに、ほかにも色々と気を付けるべきポイントがあることが分かりました。
今回はこの件について皆さんに共有できればと思います。

生存期間と記憶域期間

C++ のオブジェクトには、その有効性に影響する 生存期間 (lifetime)記憶域期間 (storage duration) という 2 つの性質があります。
記事ではおおむねこの 2 つについて深掘りをすることになりますので、ここで軽く説明します。

生存期間

オブジェクトの生存期間の開始と終了は 6.7.3[basic.life]/1 で示されています。
生存期間が開始するタイミングは、オブジェクトのために適切なサイズおよびアライメントの 記憶域 (storage) が確保され、初期化が完了したときであり、終了するタイミングは、クラス型の場合は自身のデストラクタの実行を開始したとき、それ以外の場合は記憶域が解放されるか別のオブジェクトによって再利用されたときです。

このことから、生存期間とはざっくり オブジェクトが持つ値が有効である期間 と言えるでしょうか。
よって生存期間外のオブジェクトもしくはそれを指すポインタに対して可能な操作は限定的で、例えばオブジェクトが持つ値へのアクセスやメンバ関数の呼び出しなど、生存期間内のオブジェクトに対して行うような操作はだいたい未定義の動作になります (6.7.3[basic.life]/6 および 7)。

記憶域期間

記憶域期間はオブジェクトを含んでいる記憶域の最低限の潜在的な生存期間 1 を表したものです。
オブジェクトの作り方に応じて以下の 4 つのタイプがあり、それぞれに持続時間の範囲が定められています (6.7.5[basic.stc]/1)。

  • 静的記憶域期間 (static storage duration)
  • スレッド記憶域期間 (thread storage duration)
  • 自動記憶域期間 (automatic storage duration)
  • 動的記憶域期間 (dynamic storage duration)

生存期間と同様に、記憶域期間外の領域に対する操作 (例えばポインタの間接参照を介したアクセス) は未定義の動作になります (6.7.5[basic.stc]/4)。

生存期間と記憶域期間の違い

オブジェクトの生存期間と記憶域期間は同じように見えますし、実際同じ場合も多いのですが、クラス、特にユーザ定義のコンストラクタやデストラクタを持つクラスの存在が、両者を分けて考える必要性を生み出しています。
C ではオブジェクトの記憶域期間が生存期間を決定づけます (N1570 6.2.4/1) が、C++ ではそのようなことはありません。
生存期間と記憶域期間を適切に管理すれば、上記のようなクラスも含め、記憶域を再利用したり共有したりといった柔軟な利用が可能になります。

// 生存期間・記憶域期間の例
void lifetime_and_storage_duration()
{
	// str が指すオブジェクトは “生存期間外” かつ pointer が指す記憶域は “動的記憶域期間外”

	auto pointer = operator new(1024);              // ここで記憶域が確保される

	// 生存期間外 だが 動的記憶域期間内

	auto str = new (pointer) std::string{ "hoge" }; // ここで初期化が完了する

	// 生存期間内 かつ 動的記憶域期間内

	str->~basic_string();                           // デストラクタが呼び出される

	// 生存期間外 だが 動的記憶域期間内

	operator delete(pointer);                       // ここで記憶域が解放される

	// 生存期間外 かつ 動的記憶域期間外
}

再利用時に気を付けるべきポイント

記憶域のアライメント

先ほど書いたように、記憶域のアライメントが適切であることは、オブジェクトの生存期間を開始するための必要条件になります。
よってアライメントが適切ではない場合はオブジェクトの生存期間を開始することができず、その状態でオブジェクトにアクセスした場合は未定義の動作となります。

// 適切ではないアライメントの例
void misaligned()
{
	auto pointer = operator new(sizeof(int) * 2);

	// 以下のコードは int オブジェクトの記憶域のアライメントが適切ではなく生存期間を開始できないにもかかわらず
	// 初期化によって生存期間外のオブジェクトへのアクセスが起こるため未定義の動作となる
	auto i = new (static_cast<std::byte*>(pointer) + 1) int{};

	operator delete(pointer);
}

アライメントの異なる型で記憶域を再利用する場合は、利用する型の中で最大のアライメントで記憶域を確保するか、std::align 関数 を使って適切にアライメントされた記憶域のアドレスを得る必要があります。

初期化と代入の区別

私事でお恥ずかしい話ですが、特に非クラス型において初期化と代入を混同していた時期がありました。
代入はオブジェクトにアクセスする行為ですから、それ以前にオブジェクトの生存期間を開始していない場合は未定義の動作となります。

// 初期化と代入を混同した例
void assignment_before_initialization()
{
	auto pointer = operator new(sizeof(int));

	// 以下のコードは pointer が指す int オブジェクトを 0 で初期化したつもりになっているが
	// オブジェクトが初期化されていないため生存期間を開始しておらず未定義の動作となる
	// (実は C++20 からは未定義の動作にならない件は後述)
	*static_cast<int*>(pointer) = 0;

	operator delete(pointer);
}

と、ここまで書いておいてアレなのですが、実は C++20 からは 一部の操作が implicit-lifetime types に該当する型のオブジェクトを暗黙的に生成することが規定されました (6.7.2[intro.object]/10, 11, 12 および 13)。
この “一部の操作” に operator new が含まれ、かつ int は implicit-lifetime types に該当することから、上記のコードは C++20 からは未定義の動作にはならず、正しく動作するコードとなります。

とはいえ、これはラッキーな事例ととらえるべきで、オブジェクトの生存期間を意識するためにも、やはり初期化と代入は明確に区別したほうがよいと私は思います。
もし非クラス型の初期値が決まっていなかったとしても、デフォルト初期化 (default-initialization) (9.4[dcl.init]/7) を使えば初期化自体は行うことができます。

ポインタ等の再利用に対する制限

生存期間が終了したオブジェクトを指していたポインタや参照、名前を新しいオブジェクトを指すために使うことには強い制限があります。
そのようなことが可能な新しいオブジェクト B は 古いオブジェクト A を 透過的に置き換え可能 (transparently replaceable) であるといい、6.7.3[basic.life]/8 によれば、以下のすべての条件を満たさなければなりません。

  • B が占有する記憶域は、A が占有していた記憶域と正確に重なっている。
  • B の型は A の型と同じである (ただしトップレベルの cv 修飾子は無視する)。
  • A が 完全な const オブジェクト (complete const object) ではない。
    • 他のオブジェクトに含まれるオブジェクトを サブオブジェクト (subobject) と呼び、サブオブジェクトではないオブジェクトを 完全オブジェクト (complete object) と呼ぶ (6.7.2[intro.object]/2)。
  • A も B も 潜在的に重なるサブオブジェクト (potentially-overlapping subobject) (6.7.2[intro.object]/7) ではない。
  • A と B の両方が完全オブジェクトであるか、A と B を直接的なサブオブジェクトに持つオブジェクト A' と B' があったときに A' が B' によって透過的に置き換え可能である。

上記の条件は、今回のように記憶域を別の型で再利用する場合はまず満たすことができないと考えるべきです。

// ポインタを再利用できない例
void nonreusable_pointer()
{
	// int と float は同一サイズ・同一アライメントとする
	auto pointer = operator new(sizeof(int));

	// pointer の記憶域を利用して int オブジェクトを生成する
	// p は int オブジェクトを指している
	auto p = new (pointer) int{};

	// pointer の記憶域を再利用して float オブジェクトを生成する
	// 記憶域が再利用されたことで int オブジェクトの生存期間は終了し float オブジェクトの生存期間が開始する
	new (p) float{};

	// int は float によって透過的に置き換え可能ではないため p は新しいオブジェクトを指さない
	// 生存期間外の int オブジェクトにアクセスする結果となり未定義の動作となる
	*reinterpret_cast<float*>(p) = 1.0f;

	operator delete(pointer);
}

この問題を解決するには、新しいオブジェクトを生成したときに得られるポインタを利用することが一般的ですが、常に記憶域の先頭にオブジェクトを作ると約束することで、先頭へのポインタをオブジェクトの生成やアクセスに流用したいときがあります。
そのような場合には、C++17 以降の機能ではありますが、ポインタが指すオブジェクトの生存期間に関する最適化を抑制する std::launder 関数 を使うことができます。
std::launder 関数を使って得られるポインタの型にも条件がありますが、記憶域を再利用している型を正しく指定していれば問題ありません。

// ポインタを再利用できない例を修正
void nonreusable_pointer_fixed()
{
	// int と float は同一サイズ・同一アライメントとする
	auto pointer = operator new(sizeof(int));

	// pointer の記憶域を利用して int オブジェクトを生成する
	// p は int オブジェクトを指している
	auto p = new (pointer) int{};

	// pointer の記憶域を再利用して float オブジェクトを生成する
	// 記憶域が再利用されたことで int オブジェクトの生存期間は終了し float オブジェクトの生存期間が開始する
	auto q = new (p) float{};

	// q は float オブジェクトを指しているため安全にアクセスできる
	*q = 1.0f;

	// あるいは std::launder 関数を通すことでも安全にアクセスできる
	*std::launder(static_cast<float*>(pointer)) = 2.0f;
	*std::launder(reinterpret_cast<float*>(p))  = 3.0f;

	operator delete(pointer);
}

おわりに

今回は、メモリ領域を記憶域として再利用するときは結構気を付けたほうがいいという件について書きました。
今でこそ cppreference.comcpprefjp というありがたいサイトに加えて C++ の規格書を確認する癖が付いたからよいですけども、以前の、言ってしまえばフィーリングで C++ プログラムを書いていた時代のコードを見ていると、まぁツッコミどころ満載と言いますか・・・。
それでも動いてくれちゃう処理系に一定の責任をなすり付けたい衝動に駆られながら、自分への戒めのために記事をしたためた次第ですので、少しでも皆さんの役に立てば嬉しいです。

最後に、記事には自分なりにしっかりと目を通してはいますが、誤字脱字、表現の誤りなどがありましたら、コメント欄にてご指摘くださると幸いです。

  • 初稿「メモリ領域の再利用は結構気を付けたほうがよい件」
    • 2023/12/13
  1. 原文は minimum potential lifetime of the storage containing the object であり、ちょっと強引に訳してしまいました。少なくともこの記事では 記憶域の有効期間 くらいの理解でよいかと思います。

33
31
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
33
31