C++のコンストラクタ、デストラクタの呼び出し順序の研究(その1)

C++のコンストラクタ、デストラクタの呼び出し順序は非常に複雑なので、改めて復習・研究を行う。

コンパイラが自動生成する可能性がある特殊メンバ関数

C++のクラスのコンストラクタ等の関数は特にプログラマが記述しなくてもコンパイラによって自動生成される。自動生成される可能性がある特殊メンバ関数は以下の通りである。

名前 シグネチャ
コンストラクタ T()
デストラクタ ~T()
コピーコンストラクタ T(const T&)
コピー代入 T& operator=(const T&)
ムーブコンストラクタ(C++11以降) T(T&&)
ムーブ代入(C++11以降) T& operator=(T&&)

ここではコンストラクタ等がどのような呼び出し順序で行われるかを研究する。

例題

特殊関数が呼ばれた時に出力するクラス

コード(a.h)

#ifndef A_H_
#define A_H_
#include <iostream>
using namespace std;
class A{
public:
  A(){ cout << "A(" << this << ") ctor" << endl; }
  ~A(){ cout << "A(" << this << ") dtor" << endl; }
  A(const A&){ cout << "A(" << this << ") copy ctor" << endl; }
  A& operator=(const A&){ cout << "A(" << this << ") copy asgn" << endl; }
  A(A&&){ cout << "A(" << this << ") move ctor" << endl; }
  A& operator=(A&&){ cout << "A(" << this << ") move asgn" << endl; }
};
#endif

上記のクラスを作れば、どのオブジェクトのどの特殊関数がどのタイミングで呼ばれたかをトレースすることができる。
なお、コンパイル環境はMSYS2 gcc 5.3.0を使い、コマンドラインオプションは "g++ -std=c++11 -O0 " を使用する。

例1:コンストラクタ、デストラクタ

コード

#include "a.h"
int main() {
  A a; // ctor
}      // dtor

実行結果

A(0xffffcc0f) ctor
A(0xffffcc0f) dtor

例2:コピーコンストラクタ

コード

#include "a.h"
int main() {
  A a1;     // a1 ctor
  A a2(a1); // a2 copy
}           // a2/a1 dtor

実行結果

A(0xffffcbff) ctor
A(0xffffcbfe) copy ctor
A(0xffffcbfe) dtor
A(0xffffcbff) dtor

期待通り、"A a2(a1);"の行でコピーコンストラクタが呼ばれている。
なお、コンストラクタが呼ばれる順序はオブジェクトの宣言順で、デストラクタが呼ばれる順序はコンストラクタの順序の逆順のようだ。

例3:変数宣言と同時に代入を行う。

コード

#include "a.h"
int main() {
  A a1;       // a1 ctor
  A a2 = a1;  // a2 copy!!
}             // a2/a1 dtor

実行結果

A(0xffffcbff) ctor
A(0xffffcbfe) copy ctor
A(0xffffcbfe) dtor
A(0xffffcbff) dtor

驚くべきことに、コピー代入ではなく、コピーコンストラクタが呼ばれた。
変数宣言時に代入演算子を使って初期化する場合はコピーコンストラクタが呼ばれるらしい。

例4:変数宣言後に代入を行う。

コード

#include "a.h"
int main() {
  A a1;  // a1 ctor
  A a2;  // a2 ctor
  a2=a1; // a2 asgn
}        // a2/a1 dtor

実行結果

A(0xffffcbff) ctor
A(0xffffcbfe) ctor
A(0xffffcbfe) copy asgn
A(0xffffcbfe) dtor
A(0xffffcbff) dtor

期待通り、コピー代入が呼ばれた。

例5:関数の戻り値を使って初期化する

コード

#include "a.h"
A funcA() {
  cout << "funcA 1" << endl;
  A ret;
  cout << "funcA 2" << endl;
  return ret;
}
int main() {
  A a( funcA() ); // a ctor!!!
} // a dtor

実行結果

funcA 1
A(0xffffcc0f) ctor
funcA 2
A(0xffffcc0f) dtor

奇妙な実行結果となった。出力から考えると、funcA関数の中でmain関数内のローカル変数aのコンストラクタが呼ばれたと考えるのが妥当であると言える。

調べたところ、これはNRVO (Named Return Value Optimization)というコンパイラの最適化が働いた結果で、C++の言語仕様として規定されているらしい。
gccコンパイルオプションに-O0をつけていてもこの最適化機構は働くらしい。

例6:オブジェクト生成後に関数の戻り値を代入

コード

#include "a.h"
A funcA() {
  cout << "funcA 1" << endl;
  A ret;
  cout << "funcA 2" << endl;
  return ret;
}
int main() {
  A a; // a ctor
  a = funcA(); // ret ctor, a move, ret dtor
} // a dtor

実行結果

A(0xffffcbfe) ctor
funcA 1
A(0xffffcbff) ctor
funcA 2
A(0xffffcbfe) move asgn
A(0xffffcbff) dtor
A(0xffffcbfe) dtor

まず、"A a;" によってaのコンストラクタが呼ばれ、funcA関数内でretのコンストラクタが呼ばれる。
次に代入によってaのムーブ代入が呼ばれる。ムーブ代入後、retのデストラクタが呼ばれる。
最後に、aのデストラクタが呼ばれる。

つまりこういうことだと考えられる。
プログラマがムーブ演算やデストラクタを記述する際は、デストラクタ内ではムーブ演算によって所有権が他オブジェクトに移されたメンバ変数がある可能性も考慮しながら破棄しなければならない。
たとえば、ムーブ後はリソースを指すポインタはnullptrにしておき、デストラクタ内で誤って破棄されることがないようにする必要があるということだろう。