T.TAO
ブログへ戻る
/9 min read/Programming

C++ #7 コピー制御と演算子オーバーロード

#C++#Programming#ComputerSystems

このノートでは、オブジェクトがコピー・ムーブ・破棄されるときに何が起きるか、そして演算子オーバーロードを扱います。C++ で「動くが間違っている」コードをもっとも書きやすいのが、この領域です。

コンパイラが書いてくれる関数

どのクラスに対しても、コンパイラは必要に応じて六つの特別メンバ関数を生成します。

C++class Widget {
    Widget();                                // デフォルトコンストラクタ
    ~Widget();                               // デストラクタ
    Widget(const Widget&);                   // コピーコンストラクタ
    Widget& operator=(const Widget&);        // コピー代入
    Widget(Widget&&) noexcept;               // ムーブコンストラクタ
    Widget& operator=(Widget&&) noexcept;    // ムーブ代入
};

生成される版はすべてメンバごとに動作します。各メンバをコピーするか、各メンバをムーブするか。intstd::stringstd::vector だけを持つクラスならこれで完全に正しく、何も書く必要はありません。

問題が起きるのは、クラスが生の資源を持つときです。

浅いコピーによる典型的な事故

C++class Buffer {
public:
    Buffer(size_t n) : size_(n), data_(new char[n]) {}
    ~Buffer() { delete[] data_; }
private:
    char*  data_;
    size_t size_;
};

Buffer a(1024);
Buffer b = a;       // コンパイラ生成のコピーコンストラクタ:ポインタ値をコピーするだけ

これで a.data_b.data_ は同じ領域を指します。スコープの終わりで両方のデストラクタが delete[] を呼び、二重解放でクラッシュします。クラッシュしなくても、a を変更すれば b も変わります。

これが浅いコピー深いコピーの違いです。前者はポインタを、後者はポインタの指す中身をコピーします。

三の規則と五の規則

三の規則(Rule of Three)。デストラクタ、コピーコンストラクタ、コピー代入演算子のいずれかを自分で書く必要があるなら、ほぼ確実に三つとも必要です。

理由は単純です。デストラクタが必要なのはクラスが資源を管理しているからであり、資源を管理しているなら既定のメンバごとコピーは必然的に誤りだからです。

C++11 のムーブセマンティクスにより、これはムーブコンストラクタとムーブ代入を加えた五の規則になりました。

ただし本当に覚えるべきは Rule of Zero です。どれも書かずに済むクラスを設計する。 new[] の代わりに std::vector、生ポインタの代わりに std::unique_ptr を使い、六つの関数はすべてコンパイラに返す。上の Bufferstd::vector<char> で書き直せば、問題はすべて消え、コードも短くなります。

これらを手書きすべきなのは資源管理クラス自身だけであり、その種のものは標準ライブラリが既に書いてくれていることがほとんどです。

コピー代入と copy-and-swap

コピー代入を手書きするとき見落としやすいのは三つ。自己代入、例外安全、参照を返すこと。

C++// 壊れている版
Buffer& operator=(const Buffer& other) {
    delete[] data_;                       // &other == this なら、いま元データを破壊した
    data_ = new char[other.size_];        // ここで例外が出たらオブジェクトは半壊
    std::memcpy(data_, other.data_, other.size_);
    size_ = other.size_;
    return *this;
}

copy-and-swap イディオムは三つを一度に解決します。

C++Buffer& operator=(Buffer other) {   // 値渡し:コピーはここで起きる
    swap(*this, other);             // 中身を交換
    return *this;                   // other のデストラクタが旧データを持ち去る
}

friend void swap(Buffer& a, Buffer& b) noexcept {
    using std::swap;
    swap(a.data_, b.data_);
    swap(a.size_, b.size_);
}

なぜ正しいのか。

  • 自己代入に安全 — 引数は値で取ったコピーであり、*this とは無関係。
  • 例外安全 — 投げうるのはそのコピーの構築だけで、その時点では *this に触れていない。これが強い保証です。成功するか、オブジェクトが元のままか、どちらか。
  • ロジックの重複なし — コピー処理はコピーコンストラクタ一か所にしかない。
  • おまけ — この一つの関数がコピー代入とムーブ代入の両方を兼ねます。実引数が右値ならコピーはムーブコンストラクタで作られるからです。

代償はムーブ一回ぶんの可能性があることだけで、ほとんどの場合これは考えるに値しません。

ムーブセマンティクス

ムーブの発想はこうです。元のオブジェクトがどうせすぐ破棄されるなら、資源を複製する必要はない。奪えばよい。

C++Buffer(Buffer&& other) noexcept
    : data_(other.data_), size_(other.size_)
{
    other.data_ = nullptr;   // 重要:元を安全に破棄できる状態にする
    other.size_ = 0;
}

注意すべき点が二つあります。

ムーブコンストラクタには noexcept を付ける。 std::vector は拡張時に要素を新しい領域へ移します。要素のムーブコンストラクタが例外を投げうるなら、vector は強い保証を維持できないため、代わりにコピーへ退避します。noexcept の付け忘れは、vector の再確保を O(n) 回のムーブから O(n) 回のコピーへ変えてしまいます。数十倍の差になりうるうえ、完全に無言です。

ムーブ後のオブジェクトは「有効だが未規定」の状態でなければならない。 唯一の必須条件は、安全に破棄でき、代入できることです。ムーブした後にその値を読んではいけません。

std::move は何もムーブしない

std::move はキャストにすぎません。左辺値を右辺値参照へ変え、オーバーロード解決がムーブ版を選べるようにするだけです。それ自体はコードを生成しません。

C++std::string a = "hello";
std::string b = std::move(a);   // 実際のムーブはここ、ムーブコンストラクタの中で起きる

演算子オーバーロード

オーバーロードされた演算子は、名前がたまたま operator+ などである関数にすぎません。

C++class Vec2 {
public:
    Vec2& operator+=(const Vec2& r) { x += r.x; y += r.y; return *this; }
    float x, y;
};

// 対称な二項演算子はクラス外へ。左オペランドも変換の対象になる
inline Vec2 operator+(Vec2 l, const Vec2& r) { l += r; return l; }

経験則をいくつか。

  1. まず複合代入(+=)を書き、二項版(+)はそれを使って実装する。 ロジックは一か所に。
  2. 二項算術演算子は非メンバにする。 メンバ版では左オペランドの変換が効かないため、vec * 2 は通るのに 2 * vec が通らないという非対称が生じ、読み味が悪くなります。
  3. 演算子本来の意味を保つ。 + は可換でオペランドを変更しない、== は同値関係である、といった期待を裏切るオーバーロードは、オーバーロードしないより悪い結果になります。出力ストリームの << は公認の例外です。
  4. C++20 では比較は一行で済みます。 auto operator<=>(const Vec2&) const = default; でコンパイラが六つの比較演算子すべてを生成します。

C++11 が導入したムーブセマンティクス、右辺値参照、完全転送についてのさらなる話は《C++ #9 C++11 の新機能》を参照してください。

このシリーズの記事

C++ プログラミング
  1. 01C++ #1 データとメモリ
  2. 02C++ #2 Struct と Union
  3. 03C++ #3 ポインタと配列
  4. 04C++ #4 関数
  5. 05C++ #5 オブジェクトとクラス
  6. 06C++ #6 継承
  7. 07C++ #7 コピー制御と演算子オーバーロード
  8. 08C++ #8 ポリモーフィズム
  9. 09C++ #9 C++11 の新機能
  10. 10C++ #10 C++14 の新機能
  11. 11C++ #11 C++17 の新機能