読者です 読者をやめる 読者になる 読者になる

syghの新フラグメント置き場

プログラミングTipsやコード断片の保管場所です。お絵描きもときどき載せます。

C++コンストラクタの初期化子リスト

プログラミングTips C++

(これは2010-09-24に書いた故OCNブログの記事を移植したものです)

ちょっとしたマイナーテクですが、クラスのメンバー変数をコンストラクタでゼロクリアする際、下記のように書けます。

class SomeClass
{
  struct Vector3
  {
    float x, y, z;
  };

  int m_intVal;
  double m_doubleVal;
  float m_floatArray[4];
  const char* m_charPtr;
  Vector3 m_vector;

public:
  SomeClass()
  : m_intVal()
  , m_doubleVal()
  , m_floatArray()
  , m_charPtr()
  , m_vector()
  {}
};

コンストラクタにおいて、初期化子リストとして各メンバーのデフォルトコンストラクタを呼び出す形式「memberVariableName()」で記述することにより、組込の値型、enum型やポインタ型だけでなく、固定長配列もPOD型構造体もすべてゼロクリアできます(ただしPOD型というのはデフォルト コンストラクタが定義されていない型、つまりコンパイラがデフォルト コンストラクタを自動生成する型に限る)。C++ではC同様、明示的に初期化しないスタック変数の内容は不定という仕様になっているので、きっちりとゼロ(NULL, nullptr)などの無効値でいったんクリアしてから使い始めるのが鉄則です。なお、ゼロ以外の数で初期化したければ、引数付きコンストラクタを呼び出す形式で記述します(ただし配列やPOD構造体のメンバーをゼロ以外の数で初期化するには、後述するようにC++11のinitializer list/uniform initializationにフル対応したコンパイラが必要になります)。

また、POD型Tや固定長配列T[]の変数を宣言時にゼロ初期化する場合、

T val = T();
auto val = T();
T val = {};
T ary[10] = {};

といった形で簡潔に記述できます。「T()」というコンストラクタ構文を使う方式は、空の初期化リスト「{}」と比べると、宣言時初期化以外でもいつでも使える構文なので、より汎用的です。C++11 (C++0x) のauto型推論と組み合わせるとさらに効果的でしょう。ちなみにC言語では空の初期化リストが使えず、少なくとも最初の要素は必ず指定しないといけない仕様になっているのが面倒だったのですが、C++では初期化構文がより洗練されています。

ひとつ注意点があるとすれば、Tがいわゆる「怠惰なデフォルト コンストラクタ」(メンバー変数をきちんと初期化しないユーザー定義のコンストラクタ)を持つ場合、T()という構文ではゴミデータが入ったままになります。Windowsプログラミングで言うと、古いATL/MFCのCPoint, CSize, CRectや、D3DXのD3DXVECTOR2, D3DXVECTOR3, D3DXVECTOR4とかは怠惰なデフォルト コンストラクタを持つため、これらをクラスのメンバー変数にするときは必ず初期化子リストで引数付きコンストラクタを使うなりして明示的に初期化するようにしておきましょう。同様にstd::vector<POINT>はゼロクリアされますが、std::vector<CPoint>は(古い ATL/MFC では)ゼロクリアされないので注意が必要です。

std::vector<POINT> pointsArray1(10); // ゼロクリアされる。
std::vector<CPoint> pointsArray2(10); // 古い ATL/MFC ではゼロクリアされない。
std::vector<CPoint> pointsArray3(10, CPoint()); // 古い ATL/MFC ではゼロクリアされない。
std::vector<CPoint> pointsArray4(10, CPoint(0, 0)); // ゼロクリアされる。

POINT型はwindef.hで定義されているWin32 APIの構造体ですが、定義およびCPoint型との関係は下記のようになっています。Visual Studio 2010/2012/2013付属の新しいATL/MFC 10.0/11.0/12.0では、CPoint, CSize, CRectはデフォルト コンストラクタでメンバーがゼロクリアされるように改変されているようです。

// Windows SDK 8.1 付属の windef.h より抜粋。
typedef struct tagPOINT
{
    LONG  x;
    LONG  y;
} POINT, *PPOINT, NEAR *NPPOINT, FAR *LPPOINT;
// Visual Studio 2008/2010/2012 付属の atltypes.h, atltypes.inl および
// Visual Studio 2013 付属の atltypes.h より抜粋。
// 宣言部。
class CPoint : public tagPOINT
{
public:
  CPoint() throw();
  CPoint(_In_ int initX, _In_ int initY) throw();
  // 以下略。
};
// 実装部。
inline CPoint::CPoint() throw()
#if (_MFC_VER < 0x0A00)
{} // ATL/MFC 9.0 までの実装。未初期化の不定値となる。
#else
{
  // ATL/MFC 10.0 からの実装。ゼロで初期化される。
  x = 0;
  y = 0;
}
#endif
inline CPoint::CPoint(_In_ int initX, _In_ int initY) throw()
{
  x = initX;
  y = initY;
}

コンストラクタにおける初期化子リストは、基底クラスのメンバーに対して使用することはできません。派生クラスのコンストラクタで基底クラスのメンバーを初期化するには、代入文などを使う必要があります。

なお、コンストラクタなどでmemset()関数を使って配列型のメンバーや構造体型のメンバーをゼロクリアしてるコードをよく見かけますが、初期化子リストやコンストラクタ構文を使う方がエレガントだし、つまんないバグも減ります(自分はC++を使うときはmemset()/memcpy()関数をめったに使いません)。ちなみに、VC++ 2008で/FAsを付けてReleaseビルドして、コンパイラが生成したアセンブリコードを見てみると、初期化子リストの場合でもmemset()の場合でも、xor命令によるゼロクリアに最適化されてました。Debugビルドの場合、memset()のほうはcall命令が埋め込まれますが、相当繰り返し呼び出したりしないかぎりトータル パフォーマンスには大差ありません。

ついでに固定長配列メンバーのゼロクリア速度の比較に関して、forループ版(笑)を試してみたら、Debugビルドは当然のことながらループが回るのですさまじいオーバーヘッドになりますが、Releaseビルドの場合はこれまたxor命令に最適化されて、ほとんど気にならないレベルになっていました。VC++デファクト スタンダードだけあって、それなりの最適化性能を持っていそう、ということが判明。まあ初期化子リストが一番記述が楽だとは思いますけど。わざわざループを回す人はさすがに居ないだろ……*1

あとでstd::fill()やstd::fill_n()によるゼロクリアも比較してみよう……

なお、MSVCのstd::fill(), std::fill_n()の実装ではオーバーロードにより、char/signed char/unsigned char型に関してはmemset()に置き換わるみたいです。xutilityヘッダーに実装されているコードを追いかければすぐに分かります。でもなんでテンプレートの特殊化じゃなくてオーバーロードなんでしょうか? それはそうとchar型の符号が処理系依存というのはひどい言語仕様だと思いませんか……

ちなみに、memset()によるゼロクリアが明らかに不正となってしまうパターンも存在します。具体的には、仮想メソッドを定義しているクラス(仮想関数テーブルを含むクラス)や、別のC++クラスをコンポジションとして含むクラスのオブジェクトを、memset()で乱暴にゼロクリアしようとしてはいけません。高速化のためにmemset()/memcpy()をどうしても使いたい場合でも、あくまでC言語互換のPOD型に対してのみ使用するようにして、C++クラスに対してはstd::fill()/std::fill_n()やstd::copy()/std::copy_n()に任せましょう。

初期化子リストの記述順序

コーディング時の注意点として、コンストラクタの初期化子リストを使う場合は、
「メンバーの定義順に初期化子を記述していく」
必要があるんだけれど、詳しい話はC++ Coding Standardsに載ってます。MSVCでは定義順と初期化子の記述順が異なっても警告が出ないけれど、Intel C++やg++ではきちんと警告が出ます。

C++ Coding Standards―101のルール、ガイドライン、ベストプラクティス (C++ in‐depth series)

C++ Coding Standards―101のルール、ガイドライン、ベストプラクティス (C++ in‐depth series)

  • 作者: ハーブサッター,アンドレイアレキサンドレスク,浜田光之,Herb Sutter,Andrei Alexandrescu,浜田真理
  • 出版社/メーカー: ピアソンエデュケーション
  • 発売日: 2005/10
  • メディア: 単行本
  • 購入: 20人 クリック: 383回
  • この商品を含むブログ (98件) を見る

なお、VC++ 2005よりも古いVC++処理系(VC++ 2003以前)では、初期化子リストでは固定長配列メンバーが正しくゼロクリアされないことがあるバグがあるらしいです。そのため、VC++ 2005以降では、確認のためのC4351の警告が出ますが、これに関しては(古い処理系とソースコードを共有したりしないかぎり)無視しても問題ないらしいです。

コンパイラの警告 (レベル 1) C4351

newとゼロクリア

これまたマイナーテクなんですが、POD型の可変長配列や構造体をnew演算子でヒープするとき、空のコンストラクタを呼び出す構文でゼロクリアできます。こちらは前述の初期化子リスト以上に知らない人が多いようです。

int* myArray1 = new int[1000]; // ゼロクリアされない。malloc()相当。
int* myArray2 = new int[1000](); // ゼロクリアされる。calloc()相当。

POINT* myPoint1 = new POINT; // ゼロクリアされない。malloc()相当。
POINT* myPoint2 = new POINT(); // ゼロクリアされる。calloc()相当。

よく見かけるサンプルコードでは、配列をnewするときに()を付けない前者のコードがよく使われていますが、()を付けない場合は配列の中身は不定値になります。C言語関数のmalloc()に相当します。
一方、()を付ける後者のコードは、newするタイミングでゼロクリアされます。C言語関数のcalloc()に相当します。
可変長配列をヒープした後、すぐにゼロクリアするとき、malloc() + memset()やnew[N] + memset()を使う人が非常に多いのですが、calloc()やnew[N]()を使えば余計なmemsetを削除できます。環境・処理系によってはmalloc/new[N] + memsetよりもcalloc/new[N]()だけのほうが高速なコードを出力してくれるので、積極的に使いましょう。なお注意点として、ヒープする型が前述の怠惰なデフォルト コンストラクタを持つ場合、new[N]()を使ったとしてもやはりゴミデータが入ったままになります。

なお、前述のコンストラクタ初期化子リストによるゼロクリア構文と併せて、これらのC++言語機能は「value-initialization」と呼ばれています。
new operator - C++: new call that behaves like calloc? - Stack Overflow

C++11

C++11ではinitializer list機能などのuniform initialization仕様が追加され、要素の初期化にブレース{}を使う構文も可能になっています。詳しくはcppreference.comなどを参照してください。
value initialization - cppreference.com

#include <cstdio>
#include <array>

#define FULL_CPLUSPLUS11_READY (__cplusplus >= 201103L)
#ifdef _MSC_VER
#define UNIFORM_INITIALIZATION_SUPPORTED (_MSC_VER >= 1900)
#else
#define UNIFORM_INITIALIZATION_SUPPORTED (FULL_CPLUSPLUS11_READY)
#endif

class MyVector2F
{
public:
  float x, y;
  MyVector2F() : x(), y() {}
  MyVector2F(float x, float y) : x(x), y(y) {}
};

class MyClass
{
public:
#if UNIFORM_INITIALIZATION_SUPPORTED
  double m_scalarArray[5];
  MyVector2F m_vectorArray[2];
#else
  std::array<double, 5> m_scalarArray;
  std::array<MyVector2F, 2> m_vectorArray;
#endif
public:
  MyClass()
#if UNIFORM_INITIALIZATION_SUPPORTED
    // C++11 を有効化した GCC 4.9.2 ではコンパイル可能。
    // Visual C++ 2015 でもいける模様。
    : m_scalarArray{ +0.5, -0.5, 0.0, +2.0, -1.5 }
    , m_vectorArray{ MyVector2F(+0.5f, +1.0f), MyVector2F(-0.5f, -1.0f) }
#else
    // C++11 にフル対応していない Visual C++ 2013 ではせいぜいこちらが限界。
    : m_scalarArray({ +0.5, -0.5, 0.0, +2.0, -1.5 })
    , m_vectorArray({ MyVector2F(+0.5f, +1.0f), MyVector2F(-0.5f, -1.0f) })
#endif
  {}
};

int main()
{
  printf("__cplusplus = %ld\n", __cplusplus);

  MyClass hoge;
  for (auto x : hoge.m_scalarArray)
  {
    printf("%f ", x);
  }
  putchar('\n');
  for (auto v : hoge.m_vectorArray)
  {
    printf("(%f, %f) ", v.x, v.y);
  }
  putchar('\n');
}

比較するときはアセンブルコードを

ちなみに、「初期化子リストを使うよりも、代入を使った方が速い」とかいう実験結果を示している人もいますが、このテストはそもそもやり方がむちゃくちゃです。中学生でも知っているような対照実験のセオリーを何も分かっていない。少なくともVC++ 2010では、この程度のコンストラクタであれば、初期化子リストを使っても代入を使っても、最適化を有効にしたReleaseビルドでは全く同じアセンブリコードが出力される(しかもインライン展開される)ことを確認できます。バイナリコードが同じであれば、理論的には全く同等の速度が出るはず。おそらく、上記の実験による差異は、(コンパイラに何を使っていたのか知りませんが)誤差範囲内もしくはキャッシュの影響が効いているかと(比較する場合は実行順序による影響を排除するべき)。あとよく見ると最適化が行なわれるリリースビルドでは呼び出し自体が消去されるような無意味な関数を定義しているのも実験コードとしては致命的です。単純にソフトウェアタイマー(しかも精度に信頼性のない)を使って、表面的な計測結果ばかり追いかけていると、こういったひとりよがりで誤った結論に到達することが多いです。なので、真のハッカーたる者は、必ずアセンブル結果を比較・確認し、さらにSoapboxに陥ることのない公正な分析を心がけるようにすべきでしょう。

*1:ただし素人は除く。残念ながら仕事で関わるプロジェクトでは実際ループを書いて配列をゼロクリアしようとする人がいっぱい居ます。