C++ クラスの基礎:特殊メンバ関数とオブジェクトのライフサイクル

C++のクラスを設計する際、特定のメンバ関数は、ユーザーが明示的に定義しなくてもコンパイラによって自動生成されることがあります。これらをデフォルトメンバ関数と呼びます。C++のクラスには、以下の6つのデフォルトメンバ関数が存在します。C++11以降では、ムーブコンストラクタとムーブ代入演算子が追加され、合計8つとなりますが、本記事では特に重要な初期の6つに焦点を当てます。

  • コンストラクタ
  • デストラクタ
  • コピーコンストラクタ
  • コピー代入演算子 (operator=)
  • アドレス取得演算子 (operator&)
  • const用アドレス取得演算子 (operator& const)

これらのデフォルトメンバ関数を理解する上で重要なのは、以下の2点です。

  1. 明示的に定義しない場合、コンパイラが生成する関数の動作はどうなるか?それは我々の要件を満たすか?
  2. コンパイラが生成するデフォルトの動作が要件を満たさない場合、どのようにして自身で実装するか?

コンストラクタ

コンストラクタは、クラスの特殊なメンバ関数です。その名の通り「構築」を意味しますが、主な役割はオブジェクトのためにメモリ空間を確保することではなく、オブジェクトがインスタンス化される際に、その内部の状態を適切に初期化することです。例えば、ローカルオブジェクトのメモリはスタックフレームが生成される際に確保されます。コンストラクタの本質は、C言語でInit関数を明示的に呼び出す必要があった処理を、オブジェクト生成時に自動的に実行させることにあります。

コンストラクタの主な特徴

  • 関数名はクラス名と完全に一致します。
  • 戻り値の型を持ちません(voidも指定しません)。
  • オブジェクトがインスタンス化されると、対応するコンストラクタが自動的に呼び出されます。
  • 複数のコンストラクタを定義し、オーバーロードすることが可能です。
  • クラス内にコンストラクタが一つも明示的に定義されていない場合、C++コンパイラは引数なしのデフォルトコンストラクタを自動生成します。ただし、ユーザーが一つでもコンストラクタを定義すると、コンパイラは自動生成を行いません。
  • 引数なしコンストラクタ、全引数デフォルト値コンストラクタ、そしてコンパイラが自動生成するコンストラクタは、いずれも「デフォルトコンストラクタ」と総称されます。これらは通常、引数なしで呼び出し可能なコンストラクタを指します。ただし、引数なしコンストラクタと全引数デフォルト値コンストラクタを両方定義すると、呼び出し時に曖昧さ(ambiguity)が生じるため注意が必要です。
  • コンパイラが自動生成するコンストラクタは、組み込み型int, char, ポインタなど)のメンバ変数を初期化しません。これらの変数の値は不定となります。一方、ユーザー定義型(別のクラスのオブジェクトなど)のメンバ変数に対しては、その型のデフォルトコンストラクタを呼び出して初期化します。もしユーザー定義型のメンバ変数がデフォルトコンストラクタを持たない場合、コンパイルエラーとなります。

以下にDateクラスを例に、異なるコンストラクタの定義と使用法を示します。

#include <iostream>

class Date {
public:
    // 1. 引数なしコンストラクタ (Default Constructor)
    Date() {
        m_year = 1;
        m_month = 1;
        m_day = 1;
        std::cout << "Date() constructor called for " << m_year << "/" << m_month << "/" << m_day << std::endl;
    }

    // 2. 引数付きコンストラクタ
    Date(int year, int month, int day) {
        m_year = year;
        m_month = month;
        m_day = day;
        std::cout << "Date(int, int, int) constructor called for " << m_year << "/" << m_month << "/" << m_day << std::endl;
    }

    // 3. 全引数デフォルト値コンストラクタ (これも一種のDefault Constructor)
    /*
    // これをコメント解除すると、Date d1; の呼び出しで曖昧さが発生する
    Date(int year = 1, int month = 1, int day = 1) {
        m_year = year;
        m_month = month;
        m_day = day;
        std::cout << "Date(int=1, int=1, int=1) constructor called for " << m_year << "/" << m_month << "/" << m_day << std::endl;
    }
    */

    void display() const {
        std::cout << m_year << "/" << m_month << "/" << m_day << std::endl;
    }

private:
    int m_year;
    int m_month;
    int m_day;
};

int main() {
    // 引数なしコンストラクタの呼び出し
    Date d1; 
    
    // 引数付きコンストラクタの呼び出し
    Date d2(2025, 1, 1); 

    // 注意: 引数なしコンストラクタでオブジェクトを生成する際、
    // オブジェクト名の後ろに括弧を付けないでください。
    // Date d3(); と書くと、多くのコンパイラで引数を取らない関数d3の宣言と解釈されます。
    // Date d3; と記述するのが正しいオブジェクト生成です。

    // Date d3_func(); // これは関数宣言であり、オブジェクト生成ではない
    Date d3_obj; // 正しいオブジェクト生成 (引数なしコンストラクタが呼ばれる)

    d1.display();
    d2.display();
    d3_obj.display();
    return 0;
}

引数なしコンストラクタと全引数デフォルト値コンストラクタが同時に存在すると、Date d1; のような呼び出しでどちらを呼び出すか曖昧になるため、通常は片方のみを定義します。

次に、ユーザー定義型メンバ変数の初期化について見てみましょう。以下のStackクラスと、それを利用するMyQueueクラスを考えます。

#include <iostream>
#include <cstdlib> // for malloc, free, perror

typedef int ElementType;

class Stack {
public:
    Stack(int initialCapacity = 4) {
        m_buffer = (ElementType*)malloc(sizeof(ElementType) * initialCapacity);
        if (m_buffer == nullptr) {
            perror("malloc failed for Stack buffer");
            m_capacity = 0;
            m_size = 0;
            return;
        }
        m_capacity = initialCapacity;
        m_size = 0;
        std::cout << "Stack constructor called, capacity: " << m_capacity << std::endl;
    }
    // ... その他のStackメンバ関数 (Push, Popなど) は省略

private:
    ElementType* m_buffer;
    size_t m_capacity;
    size_t m_size;
};

class MyQueue {
public:
    // MyQueueのコンストラクタを明示的に定義。
    // イニシャライザリストでStackメンバのコンストラクタを呼び出す。
    MyQueue() : m_pushStack(8), m_popStack(8) { 
        std::cout << "MyQueue constructor called." << std::endl;
    }
private:
    Stack m_pushStack; // Stack型のメンバ変数
    Stack m_popStack;  // Stack型のメンバ変数
};

int main() {
    std::cout << "--- Creating MyQueue object ---" << std::endl;
    MyQueue mq; // MyQueueのコンストラクタが呼ばれ、その中でStackのコンストラクタが呼ばれる
    std::cout << "--- MyQueue object created ---" << std::endl;
    return 0;
}

MyQueueクラスのコンストラクタを明示的に定義しない場合でも、コンパイラが自動生成するコンストラクタは、m_pushStackm_popStackというStack型のメンバに対して、それぞれStackクラスのデフォルトコンストラクタを呼び出して初期化します。上記の例では、イニシャライザリストを使って明示的に引数付きコンストラクタを呼び出しています。

デストラクタ

デストラクタはコンストラクタとは逆の機能を提供します。デストラクタの役割はオブジェクト自体を破棄することではありません。例えば、ローカルオブジェクトは関数の終了時にスタックフレームが破棄されることで解放されます。C++では、オブジェクトがその寿命を終える際に、自動的にデストラクタが呼び出され、そのオブジェクトが保持していたリソース(例えば、mallocで確保したメモリやオープンしたファイルハンドルなど)を解放する処理を行います。Dateクラスのように特別なリソースを持たないクラスはデストラクタを明示的に定義する必要がありませんが、Stackクラスのように動的にメモリを確保するクラスでは、デストラクタでそのメモリを解放することが不可欠です。

デストラクタの主な特徴

  • 関数名はクラス名の前にチルダ (~) を付けたものです。
  • 引数を取らず、戻り値の型も持ちません。
  • 1つのクラスにつき、1つのデストラクタしか定義できません。明示的に定義しない場合、システムは自動的にデフォルトのデストラクタを生成します。
  • オブジェクトの寿命が終了する際、システムによって自動的に呼び出されます。
  • コンパイラが自動生成するデストラクタは、組み込み型のメンバ変数に対しては何も処理を行いません。一方、ユーザー定義型のメンバ変数に対しては、その型のデストラクタを呼び出してリソースを解放します。
  • 明示的にデストラクタを記述した場合でも、ユーザー定義型のメンバ変数のデストラクタは自動的に呼び出されます。
  • リソースを動的に確保しないクラス(例:Date)では、デストラクタを明示的に書く必要はなく、コンパイラ生成のデフォルトデストラクタで十分です。また、ユーザー定義型メンバのみを持つクラス(例:MyQueue)も、デフォルトデストラクタが各メンバのデストラクタを呼び出すため、通常は明示的な定義は不要です。しかし、動的にリソースを確保するクラス(例:Stack)では、リソースリークを防ぐためにデストラクタを必ず自身で実装する必要があります。
  • 同一スコープ内で複数のオブジェクトが定義されている場合、デストラクタは定義された順序とは逆の順序で呼び出されます(後から定義されたオブジェクトが先に破棄されます)。

Stackクラスにデストラクタを追加する例を示します。

#include <iostream>
#include <cstdlib> // for free

typedef int ElementType;

class Stack {
public:
    Stack(int initialCapacity = 4) {
        m_buffer = (ElementType*)malloc(sizeof(ElementType) * initialCapacity);
        if (m_buffer == nullptr) {
            perror("malloc failed for Stack buffer");
            m_capacity = 0;
            m_size = 0;
            return;
        }
        m_capacity = initialCapacity;
        m_size = 0;
        std::cout << "Stack constructor called, capacity: " << m_capacity << std::endl;
    }

    // デストラクタ
    ~Stack() {
        std::cout << "Stack destructor called. Freeing " << m_capacity * sizeof(ElementType) << " bytes." << std::endl;
        if (m_buffer != nullptr) {
            free(m_buffer);
            m_buffer = nullptr;
        }
        m_size = m_capacity = 0;
    }

    // 他のメンバ関数 (Push, Popなど) は省略

private:
    ElementType* m_buffer;
    size_t m_capacity;
    size_t m_size;
};

class MyQueue {
public:
    MyQueue() : m_pushStack(5), m_popStack(5) {
        std::cout << "MyQueue constructor called." << std::endl;
    }
    // MyQueueのデストラクタを明示的に定義しなくても、
    // コンパイラが自動生成するデストラクタは
    // メンバであるm_pushStackとm_popStackのデストラクタを呼び出す。
    // 明示的に定義する場合:
    /*
    ~MyQueue() { 
        std::cout << "MyQueue destructor called." << std::endl;
        // ここに固有の解放処理があれば記述
    }
    */

private:
    Stack m_pushStack;
    Stack m_popStack;
};

int main() {
    std::cout << "--- Main scope start ---" << std::endl;
    Stack s1; // s1が構築される
    {
        std::cout << "--- Inner scope start ---" << std::endl;
        MyQueue q1; // q1が構築される
        std::cout << "--- Inner scope end ---" << std::endl;
    } // q1のスコープ終了。q1のデストラクタが呼ばれ、その中でm_pushStackとm_popStackのデストラクタが呼ばれる
    std::cout << "--- Main scope end ---" << std::endl;
    // s1のスコープ終了 (main関数終了時)。s1のデストラクタが呼ばれる
    return 0;
}

上記実行結果から、MyQueueオブジェクトが破棄される際に、その内部のStackメンバのデストラクタが自動的に呼び出されていることが確認できます。これは、リソース管理 (Resource Acquisition Is Initialization - RAII) パターンにおけるC++の強力な機能の一つです。C言語のように手動でInitDestroyを呼び出す必要がなくなり、オブジェクトの寿命管理が簡潔になります。

コピーコンストラクタ

コピーコンストラクタは、既存のオブジェクトを用いて新しいオブジェクトを初期化する際に呼び出される特別なコンストラクタです。これは、自身のクラス型のconst参照を唯一の引数として受け取ります。

コピーコンストラクタの主な特徴

  • コンストラクタのオーバーロードの一種です。
  • 引数は自身のクラス型オブジェクトへのconst参照である必要があります。値を渡す方式(pass-by-value)にすると、引数をコピーするために再びコピーコンストラクタが呼び出され、無限再帰に陥るためコンパイルエラーとなります。
  • ユーザー定義型のオブジェクトが別のオブジェクトをコピーして初期化される際には、必ずコピーコンストラクタが呼び出されます。これは、関数の引数としてユーザー定義型オブジェクトを値渡しする場合や、関数の戻り値としてユーザー定義型オブジェクトを値で返す場合にも発生します。
  • 明示的にコピーコンストラクタを定義しない場合、コンパイラは自動的にデフォルトのコピーコンストラクタを生成します。この自動生成されるコピーコンストラクタは、組み込み型メンバに対しては「メンバごとの値コピー」(シャローコピー)を行います。ユーザー定義型メンバに対しては、そのメンバのコピーコンストラクタを呼び出します。
  • Dateクラスのように、全てのメンバ変数が組み込み型であり、動的にリソースを保持しないクラスの場合、コンパイラが自動生成するコピーコンストラクタ(シャローコピー)で十分な機能を提供します。したがって、明示的に定義する必要はありません。
  • しかし、Stackクラスのように、動的に確保されたメモリ(ポインタ)をメンバとして持つクラスの場合、自動生成されるシャローコピーでは問題が生じます(同じメモリ領域を指す複数のポインタが発生し、二重解放などの原因となる)。このようなケースでは、動的に確保されたリソースも新たにコピーする「ディープコピー」を行うコピーコンストラクタを自身で実装する必要があります。
  • MyQueueクラスのように、内部にStackのようなユーザー定義型メンバを持つクラスの場合、デフォルトのコピーコンストラクタは各Stackメンバのコピーコンストラクタを自動的に呼び出すため、通常はMyQueueクラス自体でコピーコンストラクタを明示的に定義する必要はありません。

代入演算子オーバーロード

演算子オーバーロードの基本

C++では、クラス型のオブジェクトに対して組み込み型と同様に演算子を使用できるように、演算子オーバーロードの機能を提供しています。これにより、特定の演算子がクラスオブジェクトに適用された際の動作をカスタマイズできます。演算子がクラス型のオブジェクトに使用されると、C++は対応するオーバーロードされた演算子関数を呼び出すように変換します。対応する関数が存在しない場合はコンパイルエラーとなります。

  • 演算子オーバーロード関数は、operatorキーワードの後にオーバーロードする演算子を続ける形式で名前が付けられます(例: operator+, operator=)。
  • 他の関数と同様に、戻り値の型、パラメータリスト、関数本体を持ちます。
  • オーバーロードされた演算子関数のパラメータ数は、その演算子のオペランド数と同じです。単項演算子には1つ、二項演算子には2つのパラメータが必要です。二項演算子の場合、左オペランドが最初のパラメータに、右オペランドが2番目のパラメータに渡されます。
  • メンバ関数としてオーバーロードされた場合、最初のオペランドは暗黙のthisポインタを通じて渡されるため、パラメータ数はオペランド数より1つ少なくなります。
  • オーバーロード後も、演算子の優先順位と結合性は、組み込み型の演算子と同じです。
  • 言語に存在しない新しい演算子(例: operator@)を作成することはできません。
  • 以下の5つの演算子はオーバーロードできません: . (メンバ選択), .* (メンバポインタ選択), :: (スコープ解決), sizeof (サイズ取得), ?: (条件演算子)。
  • オーバーロードされる演算子関数は、少なくとも1つのクラス型パラメータを持つ必要があります。組み込み型の演算子の意味を変更することはできません(例: int operator+(int x, int y)は無効です)。
  • どの演算子をオーバーロードするかは、その演算子がクラスにとって意味を持つかどうかに依存します。例えば、Dateクラスでoperator-をオーバーロードして日付の差を計算するのは意味がありますが、operator+は日付に対して一般的ではありません。
  • ++演算子のように前置インクリメントと後置インクリメントがある場合、どちらもoperator++という関数名になります。C++では、後置インクリメントのオーバーロード関数には、引数リストにint型のダミーパラメータを追加することで区別します。
  • << (出力ストリーム) および >> (入力ストリーム) 演算子をオーバーロードする場合、通常はグローバル関数として定義します。メンバ関数として定義すると、左側のオペランドがthisポインタとなり、オブジェクト << std::cout のような不自然な呼び出し方になってしまうためです。グローバル関数として定義し、最初の引数にストリームオブジェクト、2番目の引数にクラスオブジェクトを取る形が一般的です。

代入演算子オーバーロードの詳細

代入演算子オーバーロードは、既存のオブジェクト間で値をコピーする際に使用されるデフォルトメンバ関数です。これは、コピーコンストラクタとは異なり、既に構築されたオブジェクトに対して動作します。

代入演算子オーバーロードの主な特徴

  • これは演算子オーバーロードの一種であり、メンバ関数として定義することがC++の慣例で推奨されます。
  • パラメータはconst自身のクラス型参照にすることが推奨されます。値渡しにすると、引数のコピーにコピーコンストラクタが呼び出され、効率が低下します。
  • 戻り値の型は、参照を返す自身のクラス型(ClassType&)にすることが推奨されます。これにより、a = b = c; のような連続代入が可能になり、かつ不要なオブジェクトのコピーを防ぎ効率的です。
  • 明示的に定義しない場合、コンパイラは自動的にデフォルトの代入演算子オーバーロードを生成します。その動作はデフォルトのコピーコンストラクタに似ており、組み込み型メンバに対しては「メンバごとの値コピー」(シャローコピー)を行います。ユーザー定義型メンバに対しては、そのメンバの代入演算子オーバーロードを呼び出します。
  • Dateクラスのように、全てのメンバ変数が組み込み型であり、動的にリソースを保持しないクラスの場合、コンパイラが自動生成する代入演算子オーバーロード(シャローコピー)で十分です。したがって、明示的に定義する必要はありません。
  • Stackクラスのように、動的に確保されたメモリ(ポインタ)をメンバとして持つクラスの場合、自動生成されるシャローコピーでは問題が生じます。この場合も、リソースの適切な解放と新たなディープコピーを行う代入演算子オーバーロードを自身で実装する必要があります。
  • MyQueueクラスのように、内部にStackのようなユーザー定義型メンバを持つクラスの場合、デフォルトの代入演算子オーバーロードは各Stackメンバの代入演算子オーバーロードを自動的に呼び出すため、通常はMyQueueクラス自体で代入演算子オーバーロードを明示的に定義する必要はありません。
  • 経験則として、もしクラスがデストラクタを明示的に定義してリソースを解放している場合、そのクラスはコピーコンストラクタと代入演算子オーバーロードも明示的に定義してディープコピーを行う必要がある可能性が高いです(「Rule of Three/Five」)。

アドレス演算子オーバーロード

constメンバ関数

メンバ関数をconstで修飾すると、それをconstメンバ関数と呼びます。const修飾子は、メンバ関数のパラメータリストの後ろに配置します。

このconstは、そのメンバ関数の暗黙のthisポインタを修飾し、const ClassName* const this のようにします。これにより、constメンバ関数内では、クラスのメンバ変数を変更することができなくなります。これはオブジェクトの状態を変更しないことを保証するために重要です。

#include <iostream>

class Date {
public:
    Date(int year = 1, int month = 1, int day = 1)
        : m_year(year), m_month(month), m_day(day) {}

    // constメンバ関数
    // この関数内では、m_year, m_month, m_dayを変更できない
    void display() const {
        std::cout << m_year << "-" << m_month << "-" << m_day << std::endl;
    }

private:
    int m_year;
    int m_month;
    int m_day;
};

int main() {
    // 非constオブジェクトはconstメンバ関数を呼び出すことができる(権限の縮小)
    Date today(2024, 7, 5);
    today.display();

    // constオブジェクトはconstメンバ関数しか呼び出すことができない
    const Date anniversary(2024, 8, 5);
    anniversary.display();
    // anniversary.m_year = 2023; // エラー: constオブジェクトのメンバを変更しようとしている
    return 0;
}

アドレス取得演算子 (operator&) のオーバーロード

アドレス取得演算子 (&) のオーバーロードには、通常のバージョンとconstバージョンの2種類があります。これらもデフォルトメンバ関数であり、通常はコンパイラが自動生成するものが要件を満たすため、明示的に実装する必要はほとんどありません。

ただし、非常に特殊なシナリオ、例えばクラスオブジェクトのアドレスが外部から取得されるのを防ぎたい場合などには、自身でこれらの演算子をオーバーロードし、nullptrを返すようにしたり、あるいはアクセス制御を変更したりすることが考えられます。

#include <cstddef> // For nullptr
#include <iostream> // For std::cout

class Date {
public:
    Date(int year = 1, int month = 1, int day = 1)
        : m_year(year), m_month(month), m_day(day) {}

    // 通常のアドレス取得演算子オーバーロード
    // 非constオブジェクトに対して呼び出される
    Date* operator&() {
        std::cout << "Non-const operator&() called." << std::endl;
        return this; // 通常はthisポインタを返す
        // または、アドレス取得を禁止したい場合は return nullptr;
    }

    // constオブジェクト用のアドレス取得演算子オーバーロード
    // constオブジェクトに対して呼び出される
    const Date* operator&() const {
        std::cout << "Const operator&() called." << std::endl;
        return this; // 通常はconst thisポインタを返す
        // または、アドレス取得を禁止したい場合は return nullptr;
    }

private:
    int m_year;
    int m_month;
    int m_day;
};

int main() {
    Date d_obj(2024, 7, 10);
    const Date cd_obj(2024, 7, 11);

    Date* ptr_d = &d_obj; // 通常のoperator&() が呼ばれる
    const Date* ptr_cd = &cd_obj; // const operator&() が呼ばれる
    const Date* ptr_d_const = &d_obj; // 非constオブジェクトからconstポインタを取得する場合も、通常のoperator&()が呼ばれる

    std::cout << "Address of d_obj: " << ptr_d << std::endl;
    std::cout << "Address of cd_obj: " << ptr_cd << std::endl;

    return 0;
}

タグ: C++ クラス オブジェクト コンストラクタ デストラクタ

5月20日 10:44 投稿