第33回 ムーブセマンティクス ★目玉
C++11 の目玉機能。「コピーせずに中身を移す」ための仕組み。大きな vector や string を関数から返したり、コンテナに入れたりするとき、メモリの中身全部コピーはさすがに無駄です。ムーブは「所有権(ポインタ)を引っ越す」だけで済ませるので、大きなデータでも一瞬。右辺値参照 && と std::move の正体を可視化で理解します。
このページで押さえること
✅ 最低限ここだけ覚える
- ムーブ = 所有しているリソースを横取りする操作
std::move(x) で x を「移動して良い」と印を付ける
- ムーブされた側は空(有効だが未定義)になる
- vector / string / unique_ptr などは勝手にムーブ対応
⭐ 余裕があれば読む
- 左辺値 vs 右辺値
- 右辺値参照
T&&
- ムーブコンストラクタ/ムーブ代入の書き方
- RVO(戻り値最適化)との関係
1. まず触ってみる ― コピー vs ムーブ
下のデモで、「コピー」と「ムーブ」でヒープがどう変わるかを比較してください。
▶ コピー vs ムーブの可視化
heap①: [1000000 要素のデータ]
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 点:
- 元のオブジェクト(
other)のポインタを自分のメンバに横流し
- 元のオブジェクトを安全に破棄できる状態(空)にする
4. 左辺値 vs 右辺値
ムーブの仕組みを支えるのは、式の分類「左辺値/右辺値」。ざっくり言うと:
- 左辺値 (lvalue):名前があって、その後も使える値(変数、
*p、a[i]、など)
- 右辺値 (rvalue):一時的で、その式が終わると消える値(リテラル、関数の戻り値、
std::move(x) の結果、など)
| 式 | 分類 | 説明 |
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;
}
};
ポイント:
- 引数は
T&&(右辺値参照)
noexcept を付ける(STL コンテナの最適化で重要)
- 元の中身は nullptr / 0 にして、その後のデストラクタが安全に走れるようにする
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 を付けます。