C++ Learning

第33回 ムーブセマンティクス ★目玉

C++11 の目玉機能。「コピーせずに中身を移す」ための仕組み。大きな vector や string を関数から返したり、コンテナに入れたりするとき、メモリの中身全部コピーはさすがに無駄です。ムーブは「所有権(ポインタ)を引っ越す」だけで済ませるので、大きなデータでも一瞬。右辺値参照 &&std::move の正体を可視化で理解します。

このページで押さえること
✅ 最低限ここだけ覚える
  • ムーブ = 所有しているリソースを横取りする操作
  • std::move(x) で x を「移動して良い」と印を付ける
  • ムーブされた側は空(有効だが未定義)になる
  • vector / string / unique_ptr などは勝手にムーブ対応
⭐ 余裕があれば読む
  • 左辺値 vs 右辺値
  • 右辺値参照 T&&
  • ムーブコンストラクタ/ムーブ代入の書き方
  • RVO(戻り値最適化)との関係

1. まず触ってみる ― コピー vs ムーブ

下のデモで、「コピー」と「ムーブ」でヒープがどう変わるかを比較してください。

▶ コピー vs ムーブの可視化
a
ptr → heap①
b
(未作成)
heap①: [1000000 要素のデータ]
ボタンを押すとコピー/ムーブの様子が見えます。

最小コード

first_move.cpp最小例
std::vector<int> make_big() { std::vector<int> v(1000000, 42); return v; // RVO か ムーブで返る(コピーされない) } std::vector<int> a = make_big(); // 100 万要素、一瞬で作られる // ムーブを明示的に発動 std::vector<int> b = std::move(a); // b は 100 万要素を持つ // a は空(でも有効な vector)
ここまでで覚えること(3 つ):
  • コピー = 中身を全部複製(遅い)
  • ムーブ = 所有権(ポインタ)だけ移動(速い)
  • std::move(x) で「x をムーブしていい」と明示

よくある素朴な疑問

Q. ムーブされた後の元オブジェクト(a)はどうなる?
有効だが「未定義の状態」。STL 型なら「空の状態」になります(空の vector、空の string)。アクセスは合法だが値に期待してはいけない。再代入するか、スコープを抜けて破棄されるのを待ちます。

Q. std::move は何か移動させるの?
実は何もしません。 ただ「この変数はムーブして構わない」とコンパイラに伝えるだけ(型を右辺値参照にキャストする)。実際に中身を移すのは、ムーブを受け取る側(ムーブコンストラクタなど)。

Q. 自分でムーブコンストラクタを書く必要は?
→ 多くの場合書きません。STL 型メンバだけなら自動生成されたムーブ版が完璧に動きます(Rule of Zero)。

2. コピーが遅い理由

巨大な std::vector を関数から返す場面を考えましょう。

関数から vector を返す典型シナリオ
std::vector<int> load_data() { std::vector<int> v; for (int i = 0; i < 10000000; ++i) v.push_back(i); return v; // 1000 万要素のコピーが必要? } std::vector<int> data = load_data();

C++11 以前、この戻り値は全要素のコピー(≒ 40MB を丸ごと複製)を伴う可能性がありました。だから C 時代は「出力引数」パターン(void load_data(std::vector<int>* out))が推奨されていました。

ムーブセマンティクスが入った C++11 以降は、戻り値は自動的にムーブされます。v が持っていたヒープ領域のポインタを data横流しするだけ。40MB のコピーがポインタ数個の移動になります。

C++11 以降の推奨: 「重いオブジェクトは値で返せ。コピーの心配は要らない(ムーブされるから)。」
C では避けていたイディオムが、C++ では推奨になります。

3. ムーブの仕組み

ムーブの本質は「ポインタの盗み取り」です。図で示します:

コピーO(n)
// 1. 新しいヒープ領域を確保 // 2. 元の中身を全部コピー // a.ptr → [data × N] // b.ptr → [data × N] (コピー済) // a, b ともに独立した中身を持つ
ムーブO(1)
// 1. a のポインタを b に移す // 2. a のポインタを nullptr に // a.ptr → nullptr // b.ptr → [data × N] (元 a の場所) // 中身は 1 つ、所有者が a → b に変わった

概念的なムーブコンストラクタ

ムーブの中身概念
// vector の簡略化されたムーブコンストラクタ vector(vector&& other) // && が右辺値参照 : data_(other.data_), // 1. ポインタを盗む size_(other.size_), cap_(other.cap_) { other.data_ = nullptr; // 2. 元を空に other.size_ = 0; other.cap_ = 0; // other のデストラクタは nullptr を delete しても OK(何もしない) }

ポイントは 2 点:

  1. 元のオブジェクト(other)のポインタを自分のメンバに横流し
  2. 元のオブジェクトを安全に破棄できる状態(空)にする

4. 左辺値 vs 右辺値

ムーブの仕組みを支えるのは、式の分類「左辺値右辺値」。ざっくり言うと:

分類説明
int x = 42;x左辺値名前があり後で使える
42(リテラル)右辺値一時的
x + 1右辺値計算結果は一時的
make_string() の戻り値右辺値一時的(PR値)
std::move(x)右辺値明示的に右辺値にキャスト
vec[0]左辺値名前あり(アドレスが取れる)

右辺値参照 T&&

C++11 で導入された右辺値にしか束縛できない参照。これが「この値はムーブしていい」のシグナル。

& vs &&参照の 2 種
void foo(int& x); // 左辺値参照(従来) void foo(int&& x); // 右辺値参照(C++11~) int a = 10; foo(a); // 左辺値 → foo(int&) foo(42); // 右辺値 → foo(int&&) foo(std::move(a)); // 明示的に右辺値化 → foo(int&&)
なぜ右辺値参照が必要? 右辺値(一時オブジェクト)は「すぐ消える」ので、中身を横取りしても構わない。左辺値はまだ後で使われるかもしれないので、横取りは危険。この区別を型レベルで作ったのが右辺値参照です。
ここまでで概念は OK
残りは自作ムーブの書き方と std::move の使いどころ。Rule of Zero 派の人は §6 だけでも。

5. ムーブコンストラクタの書き方

自分のクラスでも書けます。「ムーブ」と「ムーブ代入」の 2 つが必要。

自作ムーブ定型
class Buffer { int* data_ = nullptr; size_t size_ = 0; public: // ムーブコンストラクタ Buffer(Buffer&& other) noexcept : data_(other.data_), size_(other.size_) // 盗む { other.data_ = nullptr; // 空にする other.size_ = 0; } // ムーブ代入 Buffer& operator=(Buffer&& other) noexcept { if (this != &other) { delete[] data_; // 自分の古い中身を解放 data_ = other.data_; // 盗む size_ = other.size_; other.data_ = nullptr; other.size_ = 0; } return *this; } };

ポイント:

noexcept の重要性: std::vector は再確保時、要素のムーブコンストラクタが noexcept でないとコピーにフォールバックする(例外安全のため)。自作クラスでムーブを速くしたいなら noexcept を必ず付けましょう。

6. std::move の使いどころ

最も多いのは「オブジェクトを関数引数に渡してそのまま所有権を移す」場面。

パターン 1: コンテナに入れる

コピー重い
std::string s = "大きな文字列..."; std::vector<std::string> v; v.push_back(s); // s がコピーされる // s はまだ使える(中身健在)
ムーブ速い
std::string s = "大きな文字列..."; std::vector<std::string> v; v.push_back(std::move(s)); // s の中身を横取り // s は空になる(以降使わない約束)

パターン 2: コンストラクタへ渡す("by value + move" イディオム)

値渡し + move推奨
class User { std::string name_; public: // ★ 値渡しで受けて、メンバにムーブ explicit User(std::string name) : name_(std::move(name)) {} }; // 呼び出し側で好きに選べる: User u1{"Alice"}; // リテラルから直接 std::string s = ...; User u2{s}; // s はコピー User u3{std::move(s)}; // s をムーブ

値渡しで引数を受け取り、std::move でメンバに移動させるパターンは、コピーとムーブ両対応のメンバイニシャライザとして有名。

やってはいけないパターン

使い終わる変数以外にはムーブしない:
  • const 変数をムーブしようとしても意味がない(ムーブは書き換えを伴う)
  • ムーブ後の変数をその後も使おうとするのは NG(未定義状態)
  • 関数の戻り値に return std::move(local); と書くとRVO が働かなくなる(素直に return local; と書く)
広告スペース

確認クイズ

ムーブを 4 問で確認。

Q1. std::move(x) の実際の動作は?

x の中身を即座に消去する
x を別のオブジェクトにコピーする
x を右辺値参照型にキャストするだけ(実際の移動はしない)
x の所有権を移動し x を削除する
std::move は実は何も動かしません。ただ型を T&&(右辺値参照)に変えるだけで、「この値はムーブしていい」とコンパイラに伝えます。実際の中身の移動は、ムーブを受け取る側(ムーブコンストラクタ等)が行います。

Q2. ムーブされた後のオブジェクトは?

メモリから削除される
元の値を保持している
有効だが未定義の状態(STL 型なら通常は空)
自動でデストラクタが呼ばれる
ムーブされた側は有効だが未定義の状態。デストラクタは後で通常通り呼ばれます。STL 型は多くの場合「空の状態」になるので、アクセスはできるが値に意味はない。再代入するか、破棄を待つ使い方が安全。

Q3. 次のうち右辺値は?

int a = 5; の a
v[0](v は vector<int>)
make_string()(string を返す関数呼び出しの結果)
ptr(int* 型の変数)
関数の戻り値は一時的な値なので右辺値(prvalue)。他は名前があって後で使える左辺値。std::move(a) も右辺値の代表例。

Q4. ムーブコンストラクタに noexcept を付けるべき理由は?

コンパイルが速くなる
std::vector の再確保時にコピーへフォールバックしない
例外が全く投げられないことをコンパイラに保証させる
必須の文法規則
std::vector の再確保は例外安全性のため、ムーブコンストラクタが noexcept でないとコピーで移す挙動をします。自作型を vector に入れて高速化したいなら必ず noexcept を付けます。