1. 値で返すこと:なぜ問題ないのか
学びはじめのとき、「オブジェクトを値で返す」という表現は「ドアから冷蔵庫を傷つけずに運び出す」みたいに聞こえるかもしれません。直感的に怖くて、コストが高く必ずコピーされるように思え、重いのでT* や T& を返す方がよいと感じるかもしれません。しかし実際には現代の C++ ではその考え方が、速いどころかむしろ危険なコードにつながることがよくあります。
値による返却は基本的で自然な契約です:関数は結果を作り、呼び出し側は独立したオブジェクトを受け取る。そのオブジェクトは関数の外側で生き続けます。これはオブジェクトのライフタイムと自然に整合します:ローカル変数は関数を抜けると消えますが、結果はその先も生きるべきです。だから「値で返す」は通常「これが結果、これであなたのものです」と正直かつ安全に言う方法です。
非常にシンプルな例(今回のリソース型は使わない)で感覚を思い出しましょう:
#include <string>
std::string make_title() {
return "Task Tracker"; // 値で返す
}
そう、std::string は小さい型ではありませんが、文字列を値で返すことは頻繁に行われており世界は崩れません。理由はまさに今日話す最適化、つまり copy elision と NRVO にあります。
2. return のときに起きること:コピー、ムーブ、それとも除去
外から見ると return は同じに見えますが、コンパイラの内部にはいくつかの振る舞いの選択肢があります。ここで現代 C++ の魔法が始まります:コンパイラは文字どおり「オブジェクトを作ってからコピーしてさらにコピーする」必要はありません。もっと賢く振る舞うことができ、実際そうすることが多いのです。
前の講義で扱ったように(型が copy/move 操作を持つ場合)、値で返すときに発生しうるシナリオは三つあります。時にはコピーされます(もし move が利用できない場合)、時にはムーブされます(copy が重く move がある場合)、そして時にはコンパイラが単に結果を最終配置に直接生成してコピーもムーブも行わないことがあります。
目安として次の表を頭に入れておいてください:
| return の状況 | コンパイラがやること | 意味的な見方 |
|---|---|---|
| 完全に独立したオブジェクトが必要で、move がない | コピー | 「もう一つ同じものを作る」 |
| move があり、コピーしたくない場合 | ムーブ | 「リソースを移す、元を無効化する」 |
| 最終配置に直接作れる場合 | Copy elision(コピー/ムーブの除去) | 「中間オブジェクトを全く作らない」 |
重要な点:現代の C++ は効率のために「出力パラメータ」を書くことをあなたに要求しない、ということが普通の多くの場合に当てはまります。値による返却は正しい慣習で、言語とコンパイラはそれを安くするために大きく努力します。
Copy elision:余計なことをしない
「怠惰な」コンパイラとは余計な仕事をしないコンパイラです。Copy elision はまさにコンパイラがコピーやムーブを(elide)除去する最適化群で、中間オブジェクトが独立した実体として不要であると判断したときに働きます。
初心者にとって有用なモデルはこうです:結果オブジェクトは呼び出し側のメモリに直接作られる、関数はそのオブジェクトをそこに「構築する」かのように振る舞います。つまり「ローカルを作って→外にコピーする」のではなく「最初から外側に構築する」のです。
わかりやすく図にすると(非常に単純化):
flowchart LR
A[関数] --> B[ローカルオブジェクト]
B --> C[コピー/Move を結果へ]
C --> D[呼び出し側のオブジェクト]
copy elision のとき、コンパイラはこうすることを目指します:
flowchart LR
A[関数] --> D[呼び出し側のオブジェクト]
A -->|直接構築| D
歴史的に C++ 標準はこの振る舞いを段階的に強化してきました。特に C++17 ではある種のシナリオでコピー除去が保証される変更が導入されました("guaranteed copy elision" に関連する議論)。
重要:copy elision は「オリンピア向けの小技」ではありません。これは日常的に使われる最適化であり、値で返すことを非常に便利にしています。
NRVO:名前付きローカル変数を返す場合
一般に copy elision が「中間コピーをしない」というアイデアなら、NRVO(Named Return Value Optimization)は実際のプログラムで最も頻繁に現れる「サブケース」です:ローカル変数を作って適切に詰めてからそれを return する場合です。
NRVO は名前が大げさに聞こえますが意味は単純です:コンパイラはローカル変数が将来の結果であると判断し、その変数を結果のメモリに直接作ることができます。つまり見た目はローカルですが、物理的には「返される値として構築される」ことがあります。
古典的なパターン例:
Buffer make_report() {
Buffer r{128};
// r にデータを詰める...
return r; // NRVO の候補
}
そして実際、NRVO はよく働きます。しかし注意点があります:一部の “guaranteed copy elision” のケースと違い、NRVO は歴史的にコンパイラが「行ってよい」最適化と見なされており(ほとんどの場合行われますが)あなたのコードの正しさがそれに依存してはいけません。
実務上のルールは単純です:コピー/ムーブ/エリジョンのいずれが起きても正しいようにコードを書きなさい、ということです。
ミニ実験:なぜ my copy/move が呼ばれないのか?
ときどき copy/move コンストラクタにコンソール出力を入れて「いくつコピーされるか」を見ようとすることがありますが、結果としてほとんど何も見えないことがあります。「コンパイラは俺のコードを無視しているのか?」と思うかもしれません。
いいえ、無視しているわけではありません。単にコンパイラは中間オブジェクトを作る義務がないため、あなたの copy/move が呼ばれる場所を除去してしまうのです。これは正常な状況であり、コピーの副作用に依存するような設計をしてはいけない理由の一つです。
挙動を観察したければコンストラクタに短いログを入れて実験することはできますが、それはデバッグ用の懐中電灯のように扱い、性能計測器として過信しないでください。
なぜ return std::move(x) はほとんど不要か
ここは習慣から来るミスが最も多い場所です。
一度 std::move を知ると、ついこう書きたくなります:
return std::move(r);
「返却を速める」ように見えますが、実際には多くの場合悪化させるか、少なくとも無意味です。
理由はこうです:std::move(r) は「r をムーブ元として扱え」という明示的な合図です。コンパイラはそもそもローカル変数を return 時に潜在的なムーブ元と見なす能力があります(もし elision が働かない場合)。しかしもっと重要なのは、std::move を付けることで NRVO を妨げる可能性がある ことです。返す式が「単なるローカル変数の名前」ではなく「std::move を使った式」になると、NRVO の候補でなくなってしまうことがあります。
ほとんどの場合、r がローカルで結果ならば return r; と書き、速度のために std::move を付けないでください。
将来どうしても std::move を return に書く必要があるときは、それは自動的にやることではなく、NRVO が適用されない状況を理解し、意図的にそうする場合だけです。授業レベルでは稀なケースです。
3. 学習用型 Buffer での実践:値で返して恐れない
ここからは簡単なコンソール向け学習ミニプロジェクトを続けます:仮の Task Tracker がレポートを印刷します。今回のテーマを前の講義とつなげるため、レポートは std::string ではなく学習用のリソース所有型 Buffer を使って構築します。
想定として、Buffer はすでに Rule of Five と move 操作についての講義で得た実装を持っているものとします:size と data を保持し、正しい deep copy とムーブ(ポインタの転送 + ソースは nullptr にする)を備えます。ここでは値返却のテーマに必要な小さな断片だけを示します。
まず、文字を書き込む簡単なメソッドを追加します(簡素化しており複雑なフォーマットロジックは省略):
#include <cstddef>
struct Buffer {
std::size_t size{};
char* data{nullptr};
// ... コンストラクタ/デストラクタ/copy/move は前述の通り実装済み
void set(std::size_t i, char ch) {
if (i < size) data[i] = ch;
}
};
ローカル結果を返す(NRVO の候補)
次に、ローカルオブジェクトを作成してそれを返す関数を作ります。これは NRVO の最も一般的なシナリオのひとつです:
#include <cstddef>
Buffer make_banner() {
Buffer b{6}; // 例えば char[6] を割り当てる
b.set(0, 'H');
b.set(1, 'i');
b.set(2, '!');
return b; // NRVO の候補
}
たとえ NRVO が働かなくても、我々には move コンストラクタがあるため、コンパイラはムーブによって結果を返せます。NRVO が働けばコピーもムーブも発生しない、という状況もありますが、それで問題ありません。
Copy elision の「純粋な」形:return Buffer{...};
さらに明快なスタイルとして、名前付き変数を作らず一時オブジェクトを直接返す方法があります:
Buffer make_small() {
return Buffer{3};
}
この形ではコンパイラの判断は非常に明快です:「わざわざ一時オブジェクトを作ってそれを移動するくらいなら、結果の場所に直接構築しよう」。これが copy elision が直感的に最も明らかなケースです。
歴史的に標準はこうしたコピー除去のケースを強化してきました。ドラフトや編集ノートの中には “mandatory copy elision” に関する記述や参照が見られます。
関数内に複数の return:なぜ NRVO が適用されないことがあるのか
現実のコードに近い例を見てみましょう:分岐がある場合です。例えばタスクが少ないときと多いときでテンプレートを分ける場合:
Buffer make_report_template(bool many_tasks) {
Buffer a{4};
Buffer b{4};
if (many_tasks) return a;
return b;
}
ここでのポイントは、関数が「a を返すことも b を返すこともある」という点です。コンパイラは単一の「この変数が結果だ」と判断しづらく(あるいは不可能で)なり、そのため NRVO を適用できないことが多くなります。この場合はムーブ(もし存在すれば)が使われることが多く、それでも deep copy と比べれば十分安価です。
重要なのは:NRVO が「壊れる」のではなく、単にコードパターンが異なるだけだということです。コンパイラはあなたの意図を当てにするわけではありません。複数の候補変数があると最適化が難しくなります。
実用的な帰結は単純です:NRVO に優しいスタイルにしたければ、結果用の一つの変数を作って条件に応じて中身を埋め、最後に一度だけ返すように書くことが多い、ということです。
例えば(簡略化):
Buffer make_report_template2(bool many_tasks) {
Buffer r{4};
if (many_tasks) r.set(0, 'M');
else r.set(0, 'S');
return r; // 再び NRVO の候補
}
コンソールアプリに組み込む:結果を出力する
ここまでを小さなアプリ片にまとめます。Task Tracker(まだ非常に原始的)はバナーを出力し「レポートが作成された」ことを知らせます。
簡単のために出力関数を追加します:バッファの先頭数文字を出力します。完全な文字列処理ではありませんが、所有と返却のメカニクスが重要です。
#include <cstddef>
#include <iostream>
void print_prefix(const Buffer& b, std::size_t n) {
for (std::size_t i = 0; i < n && i < b.size; ++i) {
std::cout << b.data[i];
}
std::cout << '\n';
}
では main を:
#include <iostream>
int main() {
Buffer banner = make_banner(); // 値で返す
print_prefix(banner, 3); // Hi! と改行
Buffer small = make_small(); // return Buffer{3};
std::cout << small.size << '\n'; // 3
return 0;
}
設計上の重要な点に注意してください:我々はバッファの内部を直接返したり、char* を返したり、ローカルオブジェクトへの参照を返したりしていません。所有者オブジェクトを値で返すだけです。これがまさに現代 C++ が得意とするシナリオです。
4. 性能にあわてない:シンプルに書く
この段階で多くの人が「この行で NRVO が必ず働くか」を厳密に知りたがります。実務的には別の習慣が健康的です:コードをどのパターン(コピー/ムーブ/エリジョン)でも正しく動くように書き、その上で一般的な振る舞いを理解する、ということです。
関数が所有者オブジェクトを値で返すなら、満たすべき条件が二つあります。第一に、型自体が正しく実装されていること:コピー可能なら deep copy、ムーブ可能ならムーブ、そしてリソース解放を行うデストラクタ。第二に、コードが「ムーブが何回呼ばれたか」などの副作用に依存していないこと。コンパイラは余計なステップを除去する権利を持ちます。
皮肉なことに、コードが「きれい」で直線的であるほどコンパイラは最適化しやすくなります。つまりオプティマイザを助ける最良の方法は、あちこちに無駄な std::move を置いて妨げないことです。
5. よくある間違い
間違い #1:コピーを避けるために参照を返す。
初心者は時に T make() を const T& make() に置き換えます。「参照ならコピーしない」と考えるためです。しかしローカル変数は関数を出ると破棄されます。ローカルへの参照を返すとダングリング参照になり危険です。値で返すことでこの問題は正しく解決されます。
間違い #2:習慣的に return std::move(x); と書く。
「ムーブは速いからムーブさせよう」という直感は分かりますが、現代の C++ では return x; が elision を誘導し、elision が働かない場合でもコンパイラはムーブを使う可能性があります。さらに std::move を付けると NRVO を妨げる場合があります。デフォルトでは単に return x; と書く方が良いです、特に x がローカル結果のときは。
間違い #3:コピー/ムーブ の呼び出し回数でプログラムの正しさを測ろうとする。
「コンソールに Move ctor が2回出たからプログラムは正しい」と判断していると、別のコンパイラや別の最適化オプションでは 0 回になり不安になるでしょう。Copy elision はこれらの呼び出しを除去することがあり得ます。プログラムの正しさはコピーやムーブの副作用に依存してはいけません。
間違い #4:最適化の邪魔をするコードを書いておき、C++ を責める。
コードが std::move をあちこちに置いたり、一時変数を不必要に作るような「マイクロトリック」の集合になると、コンパイラは単純な絵を描きづらくなります。その結果、コードは複雑になり必ずしも速くはなりません。現代の C++ では、単純で明快なコード(1つの結果オブジェクト、分かりやすい構築、return result;)がよく勝ちます。
間違い #5:ポインタや手動のライフタイム管理で「取り戻そう」とする。
性能の話を学んだあとで、new T(...) を返して「これなら絶対にコピーはない」と考える人がいます。しかし実際には手動メモリ管理、リークのリスク、所有権契約の不明瞭さ、そして delete の必要性が生じます。値による返却と正しい copy/move 実装は安全性と良好な性能の両方をもたらします。特に copy elision と組み合わせれば効果的です。
GO TO FULL VERSION