2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【C++】初心者のためのクラス設計基礎④ ~コンストラクタとデストラクタ~

Last updated at Posted at 2021-04-02

はじめに

 前回の続きです。超初心者向けです。今回はコンストラクタとデストラクタについて解説していこうと思います。前回の記事は流石にちょっと長すぎたので、今回こそは短めにしました。といってもまあまあ長いですが。

前回までのあらすじ

 前回まで配列クラスを例にして、クラス設計について考えてきました。

#include <cassert>
class Array {
  int length;       // 要素数
  int* array_ptr;   // 配列のポインタ
public:
  /* 他のメンバ関数は省略 */

  int& operator[](int n) {        // アクセス演算子
    assert(n < length);
    return array_ptr[n];
  }
  int operator[](int n) const {   // アクセス演算子 const版
    assert(n < length);
    return array_ptr[n];
  }
  Array& operator=(const Array& arr) {  // コピー代入
    allocate(arr.length);
    for (int i = 0; i < length; i++) {  // 全要素コピー
      array_ptr[i] = arr.array_ptr[i];
    }
    return *this;
  }
  Array& operator=(Array&& arr) {       // ムーブ代入
    length = arr.length;
    array_ptr = arr.array_ptr;          // 直接ポインタをコピー
    return *this;
  }
};

 前回はアクセス演算子operator[]や代入演算子operator=をオーバーロードしましたね。コピー代入は左辺値からの代入であり、代入元の情報を保持しなければならないので低速ですが、ムーブ代入は右辺値からの代入で、代入元の情報を奪ってよいので高速なんでしたね。

クラス設計とコンストラクタとデストラクタ

 今回も前回に引き続き配列クラスを設計しながら、コンストラクタとデストラクタを紹介します。

動機

 前回operator=を追加したところ、以下のコードがコンパイルできなくなってしまいました。

int main() {
  Array arr1;
  Array arr2 = arr1;
  return 0;
}

 operator=を追加する前はこれはコンパイルできました。今回はなぜこれがコンパイルできなくなったのかと、その解決法について解説していきます。

コンストラクタとデストラクタ

 コンストラクタとデストラクタを解説しますが、まずはコンストラクタの方から行きましょう。

コンストラクタ

 実は宣言時に書く=とそれ以外で書く=は動く関数が違います。クラスオブジェクトの宣言時には必ずコンストラクタという関数が呼ばれるのです。

int main() {
  Array arr1;
  Array arr2 = arr1;   // 宣言時なので、コンストラクタが呼ばれる
  arr2 = arr1;         // 宣言時ではないので、operator=が呼ばれる
  return 0;
}

 コンストラクタとは、クラスオブジェクトを生成し初期化する関数です。operator=を追加する前はいくつかのコンストラクタが自動的に生成されていましたが、operator=を追加したことで自動生成されなくなったためにコンパイルエラーとなるのです。自動生成の詳細については、後で解説します。今はとりあえずそういうものなのだと思っていてください。
 さて、コンストラクタは独自に定義することができます。一旦Arrayから離れて、Cというクラスで例を見ていきましょう。

#include <iostream>

class C {
  int x, y;
  double z;
public:
  C()                       // 引数無しのコンストラクタ (デフォルトコンストラクタ)
    : x(0), y(0), z(0.) {   // コロンで区切った後に各メンバ変数を初期化できる。(任意)
  }
  C(int x_, int y_) : x(x_), y(y_) {                       // 2引数のコンストラクタ
    std::cout << "x is " << x << "\ny is " << y << "\n";   // 中に好きな処理を書ける
    z = x_ + y_;
  }
  C(int x_, int y_, double z_) : x(x_), y(y_), z(z_) {     // 3引数のコンストラクタ
  }
};

 このように、コンストラクタはクラス名に括弧をつけて定義し、またいくらでもオーバーロードすることができます。戻り値型はどうせそのクラスの型なので書きません。宣言時には引数に合わせて対応するコンストラクタが自動的に呼ばれます。呼び出すときは引数を丸括弧か波括弧で与えます。デフォルトコンストラクタ (引数無しのコンストラクタのこと) だけは丸括弧は不可ですが、その代わり何も書かないのはOKです。また、宣言時でなくとも直接呼び出すことができます。

int main() {
  C c1;           // OK: C() が呼ばれる

  // 丸括弧
  // C c2();      // NG: これはダメ。引数無しでCを返す関数の宣言と間違われるから。
  C c2(0, 2);     // OK: C(int, int) が呼ばれる
  C c3(0, 2, 4.); // OK: C(int, int, double) が呼ばれる

  // 波括弧
  C c4{};         // OK: C() が呼ばれる
  C c5{0, 2};     // OK: C(int, int) が呼ばれる
  C c6{0, 2, 4.}; // OK: C(int, int, double) が呼ばれる

  // 宣言以外で直接呼ぶ
  C();            // OK: C() が呼ばれる
  C(0, 2);        // OK: C(int, int) が呼ばれる
  C{};            // OK: C() が呼ばれる
  C{0, 2};        // OK: C(int, int) が呼ばれる
  
  // new式の内部で
  C* p1 = new C;       // OK: C() がnewの内部で呼ばれる
  C* p2 = new C(0, 2); // OK: C(int, int) がnewの内部で呼ばれる
  delete p1;
  delete p2;
  return 0;
}

 宣言時以外で直接コンストラクタを呼ぶと、生成されたオブジェクトはどこかに代入したりしない限り、その行で消えてしまいます。つまりそのオブジェクトは右辺値であるということです。
 丸括弧と波括弧の違いは縮小変換を許可するかしないかです。縮小変換とは整数型や浮動小数点数型などにおいて、情報落ちの危険性のある変換のことです。一般にビット長の大きい型から小さい型への変換は情報落ちを起こす可能性があるので危険と言われています。また浮動小数点数から整数、符号付き整数から符号なし整数も同様に情報落ちの危険があります。

int main() {
  long long ll = 100000000000LL;
  C(ll, ll);     // OK: C(int, int) が呼ばれる。long long -> int に変換される
  // C{ll, ll};  // error: long long -> int は (大抵は) 縮小変換
  C{static_cast<int>(ll), static_cast<int>(ll)};  // OK: キャストしてから渡す。情報落ちの危険性は依然残る
  return 0;
}

 コンストラクタは丸括弧か波括弧で引数を渡すと言いましたが、宣言時では1引数のコンストラクタに限り=で引数を渡すことができます。

class C {
  int x, y;
  double z;
public:
  C(int x_) : x(x_) {   // 1引数
  }
};

int main() {
  C c1(0);   // OK: C(int) が呼ばれる
  C c2 = 0;  // OK: C(int) が呼ばれる。上と全く同じ意味
  return 0;
}

 ちなみに、int型やdouble型などの組み込み型もコンストラクタと同じように丸括弧や波括弧での初期化ができます。ただし、引数無しの宣言では注意が必要です。というのも波括弧を使うときと使わない時で挙動が違うからです。

int main() {
  int i(42);   // int i = 42; と同じ
  int i{42};   // int i = 42; と同じ
  int i{};     // 0 に初期化される
  int i;       // 注意: 0 に初期化されるとは限らない。大抵はされない
  return 0;
}

 さて、それではArrayクラスの方にもコンストラクタを追加しましょう。Array arr2 = arr1をコンパイルできるようにするためには、Arrayを引数に取るコンストラクタを書けば良さそうですが、前回コピー代入とムーブ代入の話でやった時と同様に、コンストラクタの引数が右辺値の時と左辺値の時で分けてやった方が良さそうです。

class Array {
  /* 略 */
public:
  Array(const Array& arr)                 // 引数が左辺値 (コピーコンストラクタ)
    : length(arr.length), array_ptr(new int[arr.length]) {
    for (int i = 0; i < length; i++) {    // 全要素コピー
      array_ptr[i] = arr.array_ptr[i];
    }
  }
  Array(Array&& arr)                      // 引数が右辺値 (ムーブコンストラクタ)
    : length(arr.length), array_ptr(arr.array_ptr) {
  }
  Array()                                 // デフォルトコンストラクタ 
    : length{}, array_ptr{nullptr} {      // ちなみにメンバ変数の初期化には波括弧も使える
  }
  Array(int n) : length{n}, array_ptr{new int[n]} {  // おまけ: 初期化時にn要素分確保する
  }
};

int main() {
  Array arr1;                     // Array()
  Array arr2 = arr1;              // Array(const Array&)
  Array arr3 = std::move(arr2);   // Array(Array&&)
  Array arr4(42);                 // Array(int)
  return 0;
}

 これで、晴れてArray arr2 = arr1がコンパイルできるようになりましたね。コピー代入やムーブ代入の時と同様に、Array(const Array&)Array(Array&&)をそれぞれコピーコンストラクタ、ムーブコンストラクタと呼びます。

デストラクタ

 さて、次はデストラクタです。オブジェクトの生成及び初期化時に動く関数がコンストラクタならば、破棄時に動く関数がデストラクタです。より厳密には、コンストラクタはオブジェクトの生成も初期化も両方行いますが、デストラクタはオブジェクトを破棄する直前に後始末を行うだけで、破棄自体は行わないと言った方が良いでしょう。デストラクタはコンストラクタと違って、引数無しのものしか書けません。例を見てみましょう。

#include <iostream>

class C {
public:
  ~C() noexcept {  // デストラクタ
    std::cout << "~C()\n";
  }
};

 こんな感じになります。デストラクタは~にクラス名をつけて宣言します。また、基本的にデストラクタで例外を出すのはメモリリークの危険があるので避けるべきです。なので普通は例外を出さないことを明示するnoexcept指定子をつけます。デストラクタはオブジェクトの破棄時に自動的に呼ばれますが、直接呼び出すことも可能です。オブジェクトが破棄されるタイミングは、

  • グローバル変数ならプログラム終了時
  • ローカル変数ならスコープの終了時
  • 一時オブジェクト等は生成された行の終了時
  • クラスや構造体のメンバ変数はデストラクタの終了時
  • new式で動的に確保されたオブジェクトはdelete式の最中

です。スコープの終了時とは、言ってしまえば波括弧の終わりです。各変数が自分の属する波括弧の終わりに来ると変数が宣言された順と逆の順に破棄されていきます。

struct S {
  C c1;
  ~S() noexcept {
    std::cout << "~S()\n";
  }                  // このタイミングで c1.~C() が呼ばれる
};

C c2;
int main() {
  C c3;
  if (true) {
    C c4;
    for (int i = 0; i < 42; i++) {
      C c5;
      C c6;
    }                // このタイミングで c6.~C(), c5.~C() の順に呼ばれる
                     // ループのたびに生成されては破棄され、を繰り返す

  }                  // このタイミングで c4.~C() が呼ばれる

  C();               // オブジェクト生成直後に ~C() が呼ばれ破棄される

  c3.~C();           // 明示的に呼び出すのもOK
  c3;                // ただしデストラクタを明示的に呼び出しても c3 は依然使えるので注意
                     // 破棄のタイミングはデストラクタの呼び出しに関係なく、必ずスコープの終了時

  C* ptr = new C;
  delete ptr;        // delete式の内部で ptr->~C() が呼ばれる

  return 0; 
}                    // このタイミングで c3.~C() が呼ばれる
                     // プログラム終了時に c2.~C() が呼ばれる

 ちなみにデストラクタは書かなくとも自動的に生成されます。Arrayクラスにも自動生成されたデストラクタがあるので書かなくてもコンパイルはできますが、今回はデストラクタを書きましょう。というのも、破棄されると同時にメモリの解放をしてしまいたいからです。

class Array {
  /* 略 */
public:
  ~Array() noexcept {
    delete[] array_ptr;   // メモリ解放
  }
};

int main() {
  Array arr(42);    // 42個分の要素を確保
  return 0;
}                   // スコープ終了時にデストラクタが呼ばれ、自動的にメモリを解放できる
                    // わざわざ解放するコードを書かなくていいので嬉しい

 これで、メモリを自動的に解放できるようになりました。Arrayを利用する際には、わざわざメモリ解放のコードを書かなくていいので、何となく見ていてすっきりするコードになりましたね。また、これによりうっかり解放し忘れメモリリークを起こすということも無くなるでしょう。このようにデストラクタは確保したリソースを解放するのに使うことが多いです。
 ちなみに上の例のようにコンストラクタでリソース確保、デストラクタでリソース解放できるように、クラス設計する設計方針をRAIIなんて呼んだりします。Resource Acquisition Is Initializationの略です。

残った問題 (解決は次回以降)

 さて、デストラクタを書いてメモリを自動的に解放できるようになって嬉しいわけですが、デストラクタは一見どこで呼ばれているか見えづらいので注意が必要になってきます。特に気を付けなければいけないのが二重解放 (2回deleteすること) による実行時エラーで、以下の例を実行するとまさにそれが起きます。

int main() {
  Array arr1(42);
  Array arr2 = std::move(arr1);
  return 0;
}

 これはデストラクタが問題なのではなく、今回書いたムーブコンストラクタの方に問題があります。解決はそんなに難しくはないですが、次回以降にします。また、explicit指定子の話などもいつかする予定です。

コンストラクタとデストラクタ再考

 再考という感じでもありませんが、前回までに合わせてこの章題でいきます。ここでは、自動生成されるメンバ関数について話そうと思います。

メンバ関数の自動生成

 自動生成されるメンバ関数は全部で6つあります。コピー代入、ムーブ代入、コピーコンストラクタ、ムーブコンストラクタ、デフォルトコンストラクタ、デストラクタの6つです。自動生成される条件はそれぞれ、

  • コピー代入及びコピーコンストラクタは、それ自身を書いておらず、なおかつムーブ代入もムーブコンストラクタも書いていない時自動生成される
  • ムーブ代入及びムーブコンストラクタは、コピー代入もムーブ代入もコピーコンストラクタもムーブコンストラクタもデストラクタも書いていない時自動生成される
  • デフォルトコンストラクタは、任意のコンストラクタが書かれていない時自動生成される
  • デストラクタは、書かれなければ自動生成される

という具合になっています。また自動生成された時の各メンバ関数の挙動はそれぞれ、

  • 自動生成のコピー代入は、全ての非静的メンバ変数をコピー代入する
  • 自動生成のムーブ代入は、全ての非静的メンバ変数をムーブ代入する
  • 自動生成のコピーコンストラクタは、全ての非静的メンバ変数のコピーコンストラクタを呼ぶ
  • 自動生成のムーブコンストラクタは、全ての非静的メンバ変数のムーブコンストラクタを呼ぶ
  • デフォルトコンストラクタは、必要に応じて非静的メンバのコンストラクタを呼ぶ
  • デストラクタは何もしない

となっています。コードで表すと以下のようなイメージです。

#include <string>

struct S {
  int x;
  double* ptr = nullptr;      // デフォルトの初期化方法の指定
  std::string str{"Hello"};   // デフォルトの初期化方法の指定 

  /*
  // 自動生成されるコピー代入のイメージ
  S& operator=(const S& s) {
    x = s.x; ptr = s.ptr; str = s.str;                                   // 各メンバをコピー代入
    return *this;
  }
  
  // 自動生成されるムーブ代入のイメージ 
  S& operator=(S&& s) {
    x = std::move(s.x); ptr = std::move(s.ptr); str = std::move(s.str);  // 各メンバをムーブ代入
    return *this;
  }

  // 自動生成されるコピーコンストラクタのイメージ
  S(const S& s) : x(s.x), ptr(s.ptr), str(s.str) {  // 各メンバのコピーコンストラクタを呼ぶ
  }

  // 自動生成されるムーブコンストラクタのイメージ
  S(S&& s) 
    : x(std::move(s.x)), ptr(std::move(s.ptr)), str(std::move(s.str)) {
  }                                                 // 各メンバのムーブコンストラクタを呼ぶ

  // 自動生成されるデフォルトコンストラクタのイメージ
  S() : ptr(nullptr), str{"Hello"} {  // 指定されたデフォルトの初期化方法で初期化
  }

  // 自動生成されるデストラクタのイメージ
  ~S() {     // 何もしないように見えるが、
  }          // ここで各メンバ変数のデストラクタを呼び出している
  */
};

 また、noexcept指定子は、それぞれ自動生成されるメンバ関数の内部で呼ばれるすべての関数がnoexceptなら、自動的に付きます。自動生成されるデストラクタは、一見何の関数も呼び出していないように見えますが、実際には関数終了時にメンバ変数のデストラクタを呼び出している点に注意してください。組み込み型では、代入を含む任意の演算やコンストラクタ、デストラクタ等ほぼ全ての操作がもともとnoexceptなので、上の例だとstd::stringの代入やコンストラクタ、デストラクタがnoexceptかどうかに依ります。
 なお、組み込み型のムーブ代入、ムーブコンストラクタの挙動は、それぞれコピー代入、コピーコンストラクタの挙動と全く同じです。
 メンバ関数の自動生成はdefaultdeleteを指定することで制御することができます。

struct S {
  S& operator=(const S&) { return *this; }   // コピー代入
  // コピー代入があるので、デフォルトコンストラクタ、ムーブ代入、ムーブコンストラクタは本来作られない

  S() = default;         // = default と書くことで自動生成を強制
  S(const S&) = delete;  // = delete と書くことで自動生成を抑制
};

int main() {
  S s1{};     // OK: 自動生成された S() を使う
  S s2 = s1;  // error: S(const S&) はdeleteされていて生成されない
  return 0;
}

注意点

 さて、ここからが注意点ですが、自分で実装を書かなくとも自動生成で済むケースでは自動生成することを強く推奨します。どういうことかというと以下の例を見てみてください。

struct Manual {
  int x, y;
  Manual& operator=(const Manual& m) noexcept {  // 自動生成のものと同じ挙動だが、自分で書いてしまう
    x = m.x; y = m.y;
    return *this;
  }

  // 他の関数も同様に書く
  Manual(const Manual& m) noexcept : x(m.x), y(m.y) {}
  Manual() noexcept {}
};

struct Automatic {
  int x, y;

  // 全てdefaultにして自動生成に任せる (もちろん何も書かなくてもいい)
  Automatic& operator=(const Automatic& a) = default;
  Automatic(const Automatic&) = default;
  Automatic() = default; 
};

 上のコードはManualAutomaticも何ら変わらずに動作しそうに見えますね。しかし、これは全然違います。

#include <iostream>
#include <iomanip>
#include <type_traits>
int main() {
  std::cout << std::boolalpha 
    << std::is_trivially_copyable_v<Manual> << "\n"
    << std::is_trivially_copyable_v<Automatic> << "\n";
  return 0;
}

 このコードを実行すると、std::is_trivially_copyable_v<Manual>falseなのにstd::is_trivially_copyable_v<Automatic>trueになっていますね。std::is_trivially_copyable_vというのはbool型の変数の一種で、自動生成のみで成り立つ代入かコンストラクタを持ち、なおかつ自動生成のみで成り立つデストラクタを持つというものです。"自動生成のみで成り立つ"と言っている意味は、内部で動く全ての関数 (つまり非静的メンバ変数の代入やコンストラクタ、デストラクタ) も自動生成されたもの (あるいはもともと組み込まれているもの) でなければならないという意味です。つまり、以下の例のコピーコンストラクタは自動生成のみで成り立っているとは言いません。

struct S {
  S(const S&) {}   // 自動生成ではない
};

struct T {
  S s;   // 非静的メンバ変数にSを持つ
  T(const T&) = default;  // 自動生成されるが、Sのコピーコンストラクタが自動生成ではないので
                          // 自動生成のみで成り立っているとは言わない
};

 ちなみに紛らわしいですが、std::is_trivially_copyable_vはコピー代入やコピーコンストラクタが自動生成のみで成り立っている、という意味ではないので注意してください。それはそれでまた別にstd::is_trivially_copy_assignable_vstd::is_trivially_copy_constructible_vというのがあります。いや、正確にはstd::is_trivially_copy_constructible_vはデストラクタが自動生成のみで成り立っていることも必要です。ますます紛らわしいですね。
 さて、話を少々戻して、std::is_trivially_copyable_vの値が違うのは些細な違いに見えますが、これに何か問題があるのでしょうか? 実は、std::is_trivially_copyable_vtrueである方が、大量のデータコピーを行うときの速度が圧倒的に速いです。
 もともとC言語由来の関数ですがstd::memcpystd::memmoveという関数があります。

#include <cstring>

int main() {
  const int n = 10000;
  char c[n], d[n];

  std::memcpy(c, d, n);              // dからcへn要素分コピー
  // std::memcpy(c, c + 100, 1000);  // memcpyはコピー元とコピー先の領域が被っていると動作が保証されない

  std::memmove(c, d, n);             // dからcへn要素分コピー
  std::memmove(c, c + 100, 1000);    // memmoveはコピー元とコピー先の領域が被っていても正しく動く
  return 0;
}

 紛らわしいですが、この"コピー"、"ムーブ"は今まで話したコピー代入やムーブ代入とはまた違った意味で使われています。std::memcpyは型にかかわらず (すなわちコピー代入やコピーコンストラクタの内部の処理とは無関係に) メモリを丸ごとコピーするというものであり、当然何も考えずに使うとかなり危険です。std::memmoveはそれよりも若干安全で、コピー元とコピー先のメモリが被っていても正常に動きますが、依然危険性は高いです。その代わりこの2つの関数はめちゃくちゃ速いです。なので、危険性がないときはこれらの関数を使いたいのですが、その危険性無しに使えるかどうかを表すのがstd::is_trivially_copyable_vです。これがtrueの時はstd::memcpystd::memmoveが使える時であると言えます。
 <algorithm>にあるstd::copyという関数はstd::memcpystd::memmoveより安全にコピー代入するわけですが、大抵の標準ライブラリはstd::memcpy若しくはstd::memmoveが使える時はどちらか (おそらくはstd::memmoveの方) を使ってくれます。そのため、std::is_trivially_copyable_vtrueにしておくことは大事なのです。勿論、コンパイラの最適化によってはstd::is_trivially_copyable_vtrueでなくともstd::memcpystd::memmoveを使う場合もあるかもしれませんが。

#include <vector>
#include <algorithm>

int main() {
  const int n = 100000;
  std::vector<Manual> man1(n), man2(n);     // 100000要素のManualの配列
  std::vector<Automatic> aut1(n), aut2(n);  // 100000要素のAutomaticの配列
  
  // man1の全要素をman2へコピー
  // std::is_trivially_copyable_v<Manual> == false なので
  // 高速な std::memcpy / std::memmove を使ってくれるとは限らない
  std::copy(std::begin(man1), std::end(man1), std::begin(man2));
  
  // aut1の全要素をaut2へコピー
  // std::is_trivially_copyable_v<Automatic> == true なので
  // 高速な std::memcpy / std::memmove を使ってくれる可能性が極めて高い
  std::copy(std::begin(aut1), std::end(aut1), std::begin(aut2));

  return 0;
}

 std::copyは引数の順番に気をつけましょう。std::memcpystd::memmoveとはコピー元とコピー先の順番が逆です。
 実際ClangやGCCで実験してみると、Manualの方より、Automaticの方が断然速くなります。前回の記事でも言いましたが、C++において遅いのはバグです。というわけで最初に言った通り、自動生成で済むなら自動生成しましょう

終わりに

 C++では些細な違いに見えて結構大きい違いを生むことはたくさんあります。今回もその例を見ましたが、普通にプログラムを書いていてはなかなか気づきませんね。C++を理解したつもりになっても全然理解していなかったというのをよく聞きますが、こういうところが原因なんでしょうね。まあ、こういったC++の奥深さを楽しめる人はC++に向いていると思います。
 さて、次回は多分メモリ管理の話をします。お楽しみに。

次回: 【C++】初心者のためのクラス設計基礎⑤ ~メモリ管理~

おしまい

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?