コピーセマンティクス

オブジェクトのコピーと代入。深いコピーと浅いコピー。

コピーとは

オブジェクトを別の変数に割り当てる操作をコピーといいます。 C++ではコピーコンストラクタコピー代入演算子が この動作を制御します。

提供しない場合、コンパイラが生成しますが、 ポインタを使用する場合は明示的に定義する必要があります。

浅いコピーの問題

コンパイラが生成するコピーは、メンバの浅いコピーを行います。 ポインタのメンバがある場合、危険です。

#include <iostream>
#include <cstring>

class String {
private:
    char* data;
    int size;
    
public:
    String(const char* s) {
        size = std::strlen(s);
        data = new char[size + 1];
        std::strcpy(data, s);
        std::cout << "コンストラクタ: " << data << std::endl;
    }
    
    ~String() {
        std::cout << "デストラクタ: " << data << std::endl;
        delete[] data;  // 危険! 同じポインタが2回削除される
    }
    
    void print() const {
        std::cout << data << std::endl;
    }
};

int main() {
    String s1("Hello");
    String s2 = s1;  // 浅いコピー (デフォルト)
    
    std::cout << "s1: ";
    s1.print();  // dataは両方で同じアドレス
    std::cout << "s2: ";
    s2.print();
    
    return 0;  // s1, s2の順でデストラクタが呼ばれ、同じポインタが2回削除される
}

コンストラクタ: Hello
s1: Hello
s2: Hello
デストラクタ: Hello
デストラクタ: (undefined - メモリはすでに解放)
エラー: double delete

コピーコンストラクタ

コピーコンストラクタは、別のオブジェクトから 初期化する際に呼ばれます。深いコピーを実装します。

#include <iostream>
#include <cstring>

class String {
private:
    char* data;
    int size;
    
public:
    // コンストラクタ
    String(const char* s) {
        size = std::strlen(s);
        data = new char[size + 1];
        std::strcpy(data, s);
        std::cout << "コンストラクタ: " << data << std::endl;
    }
    
    // コピーコンストラクタ: 別のStringから初期化
    String(const String& other) {
        size = other.size;
        data = new char[size + 1];  // 新しいメモリ確保
        std::strcpy(data, other.data);  // 内容コピー
        std::cout << "コピーコンストラクタ: " << data << std::endl;
    }
    
    ~String() {
        std::cout << "デストラクタ: " << data << std::endl;
        delete[] data;
    }
    
    void print() const {
        std::cout << data << std::endl;
    }
};

int main() {
    String s1("Hello");  // コンストラクタ
    String s2 = s1;     // コピーコンストラクタ
    
    std::cout << "s1: ";
    s1.print();
    std::cout << "s2: ";
    s2.print();
    
    std::cout << "アドレス s1: " << (void*)&s1 << std::endl;
    std::cout << "アドレス s2: " << (void*)&s2 << std::endl;
    
    return 0;  // 両方でdataの異なるポインタが削除される(OK)
}

コンストラクタ: Hello
コピーコンストラクタ: Hello
s1: Hello
s2: Hello
アドレス s1: 0x7ffc...
アドレス s2: 0x7ffc...
デストラクタ: Hello
デストラクタ: Hello

コピー代入演算子

コピー代入演算子(operator=)は、 既存のオブジェクトに別のオブジェクトを代入する際に呼ばれます。

注意: コンストラクタと異なり、左辺のメモリをまず解放する必要があります。

#include <iostream>
#include <cstring>

class String {
private:
    char* data;
    int size;
    
public:
    String(const char* s) {
        size = std::strlen(s);
        data = new char[size + 1];
        std::strcpy(data, s);
    }
    
    String(const String& other) {
        size = other.size;
        data = new char[size + 1];
        std::strcpy(data, other.data);
    }
    
    // コピー代入演算子
    String& operator=(const String& other) {
        // 自己代入ではないかチェック
        if (this == &other) {
            return *this;
        }
        
        // 古いメモリを解放
        delete[] data;
        
        // 新しいメモリを確保して中身をコピー
        size = other.size;
        data = new char[size + 1];
        std::strcpy(data, other.data);
        
        std::cout << "コピー代入演算子: " << data << std::endl;
        
        return *this;  // チェーン可能にするため *this を返す
    }
    
    ~String() {
        delete[] data;
    }
    
    void print() const {
        std::cout << data << std::endl;
    }
};

int main() {
    String s1("Hello");
    String s2("World");
    
    std::cout << "代入前:" << std::endl;
    std::cout << "s1: ";
    s1.print();
    std::cout << "s2: ";
    s2.print();
    
    s2 = s1;  // コピー代入演算子が呼ばれる
    
    std::cout << "代入後:" << std::endl;
    std::cout << "s1: ";
    s1.print();
    std::cout << "s2: ";
    s2.print();
    
    return 0;
}

代入前:
s1: Hello
s2: World
コピー代入演算子: Hello
代入後:
s1: Hello
s2: Hello

ムーブセマンティクス (C++11)

C++11ではムーブセマンティクスが導入されました。 一時オブジェクトから効率的にリソースを「移動」します。

ムーブコンストラクタとムーブ代入演算子を使用します。

#include <iostream>
#include <cstring>

class String {
private:
    char* data;
    int size;
    
public:
    String(const char* s) {
        size = std::strlen(s);
        data = new char[size + 1];
        std::strcpy(data, s);
        std::cout << "コンストラクタ: " << data << std::endl;
    }
    
    // ムーブコンストラクタ (rvalue reference)
    String(String&& other) noexcept {
        std::cout << "ムーブコンストラクタ: " << other.data << std::endl;
        data = other.data;
        size = other.size;
        other.data = nullptr;  // メモリ所有権を移動
        other.size = 0;
    }
    
    ~String() {
        if (data) delete[] data;
    }
    
    void print() const {
        if (data) std::cout << data << std::endl;
    }
};

// 一時オブジェクトを返す関数
String createString() {
    return String("Temporary");  // RValue
}

int main() {
    String s1 = createString();  // ムーブコンストラクタが呼ばれる
    std::cout << "s1: ";
    s1.print();
    
    return 0;
}

コンストラクタ: Temporary
ムーブコンストラクタ: Temporary
s1: Temporary

Rule of Five (C++11)

リソースを明示的に管理するクラスでは、 以下の5つのメンバを定義する必要があります。

  • デストラクタ:リソース解放
  • コピーコンストラクタ:そのコピーを初期化
  • コピー代入演算子:既存オブジェクトをコピーで上書き
  • ムーブコンストラクタ:一時オブジェクトから初期化
  • ムーブ代入演算子:一時オブジェクトで上書き
#include <iostream>

class Resource {
private:
    int* data;
    int size;
    
public:
    // コンストラクタ
    Resource(int s) : size(s) {
        data = new int[s];
        std::cout << "コンストラクタ" << std::endl;
    }
    
    // デストラクタ
    ~Resource() {
        delete[] data;
        std::cout << "デストラクタ" << std::endl;
    }
    
    // コピーコンストラクタ
    Resource(const Resource& other) : size(other.size) {
        data = new int[size];
        for (int i = 0; i < size; ++i) data[i] = other.data[i];
        std::cout << "コピーコンストラクタ" << std::endl;
    }
    
    // ムーブコンストラクタ
    Resource(Resource&& other) noexcept 
        : data(other.data), size(other.size) {
        other.data = nullptr;
        std::cout << "ムーブコンストラクタ" << std::endl;
    }
    
    // コピー代入演算子
    Resource& operator=(const Resource& other) {
        if (this != &other) {
            delete[] data;
            size = other.size;
            data = new int[size];
            for (int i = 0; i < size; ++i) data[i] = other.data[i];
        }
        std::cout << "コピー代入演算子" << std::endl;
        return *this;
    }
    
    // ムーブ代入演算子
    Resource& operator=(Resource&& other) noexcept {
        if (this != &other) {
            delete[] data;
            data = other.data;
            size = other.size;
            other.data = nullptr;
        }
        std::cout << "ムーブ代入演算子" << std::endl;
        return *this;
    }
};

int main() {
    Resource r1(5);  // コンストラクタ
    Resource r2 = r1;  // コピーコンストラクタ
    Resource r3 = std::move(r1);  // ムーブコンストラクタ
    
    return 0;
}

コンストラクタ
コピーコンストラクタ
ムーブコンストラクタ
デストラクタ
デストラクタ
デストラクタ

よくある誤り

誤り1: 自己代入チェックなし

String& operator=(const String& other) {
    delete[] data;  // other.data と data が同じ場合クラッシュ
    data = new char[...];
    std::strcpy(data, other.data);  // other.data はすでに削除されている
    return *this;
}

誤り2: ムーブ後に元のポインタをクリアしない

String(String&& other) {
    data = other.data;
    size = other.size;
    // other.data を nullptr にしないと、デストラクタで同じメモリが削除される
}

誤り3: ムーブコンストラクタで例外安全性なし

String(String&& other) {  // noexcept がない
    // 処理...
}

ポイント

  • 浅いコピー:ポインタのコピーは危険
  • コピーコンストラクタ:初期化時の深いコピー
  • コピー代入演算子:自己代入チェック必須
  • ムーブセマンティクス:一時オブジェクトから効率的に移動
  • Rule of Five:リソース管理では5つのメンバを定義

やってみよう

練習1: 配列を管理するクラスにコピーコンストラクタを追加

練習2: コピー代入演算子で自己代入チェックをテスト

練習3: ムーブコンストラクタの呼び出しをトレース

チャレンジ: shared_ptr の動作をシミュレート(参照カウント実装)

まとめ

  • ポインタメンバがある場合、コピーセマンティクスの定義が必須
  • C++11のムーブセマンティクスで効率的なリソース移動が可能
  • Rule of Fiveで5つのメンバを適切に定義