見出し画像

C++プログラミング入門―初心者からその先へ: 初期化リスト、委譲コンストラクタ、深いコピー (セクション13-3/25)

  • 初期化リストと委譲コンストラクタを活用することで、オブジェクトのメンバーを効率的に初期化し、重複するコードを削減できる。

  • デフォルト引数付きコンストラクタを用いることで、複数のオーバーロードを1つにまとめ、柔軟な初期化が可能になる。

  • コピーコンストラクタでは、生のポインタを持つ場合に浅いコピーの問題を回避するため、深いコピーを実装することが重要である。


このセグメントでは、C++ のオブジェクト指向プログラミングにおいて、特にコンストラクタの最適化(初期化リスト と コンストラクタ委譲 を活用する方法)と、生のポインタを含むオブジェクトのコピー時に陥りがちな落とし穴を回避する方法について詳しく解説します。以下、段階的にその内容を分解して説明していきます。


1. コンストラクタ初期化リスト

なぜ重要なのか

  • 従来の方法:
    コンストラクタの本体内で値を代入する方法をこれまで使用してきました。

  • その欠点:
    コンストラクタの本体が実行されるまでに、クラスのメンバーは既にデフォルト初期化されているため、「初期化」とは呼べず、単に代入しているだけになります。

  • 解決策:
    コンストラクタ初期化リスト を使用して、オブジェクトが作成される際にメンバーが効率的に、そして直接指定した値で初期化されるようにします。

例:

class Player {
private:
    std::string name;
    int health;
    int xp;

public:
    // 古い方法(効率が悪い):
    // Player() {
    //     name = "None";   // 後から代入される
    //     health = 0;
    //     xp = 0;
    // }

    // より良い方法:
    Player()
        : name{"None"}, health{0}, xp{0} {
        // 必要ならここにコンストラクタ本体のコードを書く
    }

    Player(std::string name_val)
        : name{name_val}, health{0}, xp{0} {
    }

    Player(std::string name_val, int health_val, int xp_val)
        : name{name_val}, health{health_val}, xp{xp_val} {
    }
};

初期化リストを使用することで、例えば name は "None" に直接初期化されるため、一度デフォルト初期化された後に代入するという無駄な処理がなくなります。


2. コンストラクタ委譲

重複するコードを削減する

C++ では、同じクラス内の別のコンストラクタを呼び出す(委譲する)ことができ、複数のコンストラクタで共通の初期化処理を簡略化できます。

例:
引数なしコンストラクタと1引数コンストラクタで、3引数コンストラクタに委譲することで、重複する初期化ロジックを省くことができます。

class Player {
private:
    std::string name;
    int health;
    int xp;

public:
    Player() 
        : Player{"None", 0, 0} {               // 委譲
        std::cout << "引数なしコンストラクタ\n";
    }
    
    Player(std::string name_val) 
        : Player{name_val, 0, 0} {             // 委譲
        std::cout << "1引数コンストラクタ\n";
    }

    Player(std::string name_val, int health_val, int xp_val)
        : name{name_val}, health{health_val}, xp{xp_val} {
        std::cout << "3引数コンストラクタ\n";
    }
};

委譲コンストラクタは、初期化リスト内で直ちに対象のコンストラクタを呼び出し、その後に自分自身のコンストラクタ本体が実行されます。


3. コンストラクタのパラメータとデフォルト値

オーバーロードを減らし、利便性を向上させる

複数のコンストラクタをデフォルト引数を使って1つにまとめることが可能です。

例:

class Player {
private:
    std::string name;
    int health;
    int xp;

public:
    // すべてのシナリオに対応する単一のコンストラクタ
    Player(std::string name_val = "None", 
           int health_val = 0, 
           int xp_val = 0)
        : name{name_val}, health{health_val}, xp{xp_val} {
        std::cout << "3引数コンストラクタ\n";
    }
};

この単一のコンストラクタを使うと、以下のようにさまざまな方法でオブジェクトを生成できます。

Player empty;                        // name="None", health=0, xp=0
Player frank{"Frank"};              // name="Frank", health=0, xp=0
Player hero{"Hero", 100};           // name="Hero", health=100, xp=0
Player villain{"Villain", 100, 55}; // name="Villain", health=100, xp=55

注意点:
デフォルトパラメータ付きのコンストラクタと、引数なしのコンストラクタを別々に定義すると、呼び出し時に曖昧性が発生する可能性があるため、重複定義しないように注意してください。


4. コピーコンストラクタの基本

オブジェクトをコピーする必要性

オブジェクトのコピーが必要になる状況は主に次のとおりです:

  1. オブジェクトを値渡しで関数に渡す場合。

  2. オブジェクトを値渡しで関数から返す場合。

  3. 既存のオブジェクトから新しいオブジェクトを作成する場合(例:Player p2{p1};)。

コピーコンストラクタを自分で定義しなければ、C++ はデフォルトで「メンバーごとのコピー」(浅いコピー)を行うコピーコンストラクタを生成します。しかし、生のポインタをメンバーとして持つ場合、浅いコピーは危険です。

典型的なコピーコンストラクタのシグネチャ:

class Player {
public:
    Player(const Player &source); 
    // ...
};
  • コピー元は const 参照 で受け取ることで、無限再帰の防止とソースオブジェクトの変更を防ぎます。

  • コンストラクタ内では、各メンバーをどのようにコピーするか(浅いコピーか深いコピーか)を決定します。

例:シンプルなコピーコンストラクタ

Player::Player(const Player &source)
    : name{source.name}, health{source.health}, xp{source.xp} {
    std::cout << "コピーコンストラクタ - コピー元: " 
              << source.name << std::endl;
}

標準ライブラリのデータ型(例えば std::string や std::vector)の場合、浅いコピーで問題ないことが多いですが、生のポインタを使う場合は注意が必要です。


5. 浅いコピーと深いコピー

浅いコピーの問題点

クラスに生のポインタをメンバーとして持つ場合、デフォルトまたは単純なコピーコンストラクタは、ポインタ自体だけをコピーし、ポインタが指すデータはコピーしません。結果として:

  • 2 つのオブジェクト が同じメモリアドレスを指すようになり、

  • 両方のオブジェクトのデストラクタが同じメモリを解放しようとして、二重解放やクラッシュが発生します。

  • どちらか一方でデータが変更されると、もう一方のオブジェクトにも影響を及ぼします。

浅いコピーの例(クラッシュの可能性):

class Shallow {
private:
    int *data;
public:
    Shallow(int d);
    Shallow(const Shallow &source); // 浅いコピー
    ~Shallow();

    void set_data_value(int d) { *data = d; }
    int get_data_value() { return *data; }
};

// コンストラクタで新しい int を動的に確保
Shallow::Shallow(int d) {
    data = new int;
    *data = d;
}

// 浅いコピー:ポインタをそのままコピーするだけ
Shallow::Shallow(const Shallow &source)
    : data(source.data) {
    std::cout << "コピーコンストラクタ - 浅いコピー\n";
}

// デストラクタでメモリを解放
Shallow::~Shallow() {
    delete data;
    std::cout << "デストラクタがデータを解放\n";
}

この場合、コピーされたオブジェクトと元のオブジェクトが同じメモリアドレスを指すため、一方がスコープを抜けるとメモリが解放され、もう一方のオブジェクトが不正なメモリにアクセスする可能性があります。

深いコピーで解決する

深いコピー では、各オブジェクトが独自のデータコピーを持つように、新たにメモリを割り当て、その内容をコピーします。

class Deep {
private:
    int *data;
public:
    Deep(int d);
    Deep(const Deep &source);  // 深いコピー
    ~Deep();

    void set_data_value(int d) { *data = d; }
    int get_data_value() { return *data; }
};

Deep::Deep(int d) {
    data = new int;
    *data = d;
}

// 深いコピー:新たにメモリを割り当て、値をコピーする
Deep::Deep(const Deep &source) {
    data = new int;
    *data = *source.data;  // ポインタではなく、データそのものをコピー
    std::cout << "コピーコンストラクタ - 深いコピー\n";
}

Deep::~Deep() {
    delete data;
    std::cout << "デストラクタがデータを解放\n";
}

これにより、各インスタンスは独自のメモリを持つため、一方のオブジェクトが解放されても、もう一方に影響はありません。

委譲を利用した深いコピー

場合によっては、コピーコンストラクタ内でコンストラクタ委譲を利用することもできます:

Deep::Deep(const Deep &source)
    : Deep{*source.data} { // Deep(int) コンストラクタに委譲
    std::cout << "コピーコンストラクタ - 深いコピー\n";
}

これにより、*source.data の値を使って新しいメモリを確保し、正しくコピーすることができます。


まとめ

  1. 初期化リスト

    • コンストラクタ本体内での代入よりも効率的です。

    • データメンバーはクラス宣言の順序に従って初期化されます(初期化リストの順序ではなく)。

  2. 委譲コンストラクタ

    • 複数のコンストラクタ間で重複する初期化コードを減らすために利用します。

  3. デフォルト引数付きコンストラクタ

    • 複数のオーバーロードを1つにまとめ、コードをシンプルにします。

    • ただし、あいまいさを避けるため、重複定義に注意してください。

  4. コピーコンストラクタ

    • オブジェクトを値渡しで関数に渡す場合や、関数から返す場合、または既存のオブジェクトから新しいオブジェクトを作る際に呼ばれます。

    • 生のポインタをメンバーに持つ場合は、深いコピーを実装する必要があります。

  5. 浅いコピーと深いコピー

    • 浅いコピー: ポインタのみをコピーし、同じメモリアドレスを共有するため、デストラクタで二重解放の危険があります。

    • 深いコピー: 新たにメモリを割り当て、元のデータをコピーするため、各オブジェクトが独自のメモリを持ち、より安全です。


これらの概念を習得することで、効率的で保守性の高いクラス設計が可能になります。

  • 必要な場合は常に 初期化リスト を使用して、コンストラクタのコードをクリーンかつ効率的にしましょう。

  • 複数のコンストラクタ間で共通の初期化ロジックがある場合は、委譲コンストラクタ を活用してください。

  • 生のポインタを扱う場合は、必ず 深いコピー を実装し、浅いコピーによる問題を回避しましょう。

次回は、さらに進んだトピックとしてムーブセマンティクス、演算子オーバーロードなどについて学んでいきます。
引き続き、これらのコンストラクタパターンを試してみて、ポインタ操作には十分に注意してください!


「超温和なパイソン」へ

いいなと思ったら応援しよう!