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

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

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

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

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

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()
    {}
};

C++ではコンストラクタにおいて、:に続く初期化子リストとして各メンバーの初期化を実行することができます。この構文は「member initializer list」と呼ばれています(日本語では「メンバー初期化子リスト」)。ここで、各メンバーの初期化子として空のカッコを指定する形式「memberVariableName()」にて記述することにより、組込の値型、enum型やポインタ型だけでなく、固定長配列もPOD型構造体もすべてゼロクリアできます(ただしPOD型というのはデフォルトコンストラクタが定義されていない型、つまりコンパイラがデフォルトコンストラクタを自動生成する型に限る)。

コンストラクタのメンバー初期化子リストでは、最初に基底クラスのコンストラクタ呼び出しを明示的に記述することもできます。明示的に記述しなかった場合は基底クラスのデフォルトコンストラクタが暗黙的に呼ばれ、その後で自クラスのメンバー初期化が実行されます。C++11以降では基底クラスのコンストラクタだけでなく、自クラスの別のコンストラクタを呼び出すこともできるようになっています(委譲コンストラクタ)。

C++ではC同様、明示的に初期化しない自動変数(スタック変数)の内容は不定という危険極まりない邪悪な仕様*1になっており、ヒープに確保されたメモリブロックもデフォルトでは不定値となるので、きっちりとゼロ(ポインタの場合はNULL or nullptr)などの無効値でいったんクリアしてから使い始めるのが鉄則です。なお、ゼロ以外の数で初期化したければ、各メンバーの初期化子として引数付きコンストラクタに実引数を渡す要領で値を指定する形式「memberVariableName(val)」にて記述します。ただしメンバー初期化子リストにて配列やPOD構造体のメンバーをゼロ以外の数で個別に初期化するには、後述するようにC++11のinitializer list/uniform initializationにフル対応したコンパイラが必要になります。

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

T val = T();
auto val = T(); // C++11 以降。
T val = {}; // C++03 までは集成体にのみ使用可能。C++11 以降はスカラーにも使用可能。
T val {}; // C++11 以降。
T ary[10] = {};
T ary[10] {}; // C++11 以降。

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

ちなみにJavaC#では、ローカル変数には自動的にデフォルト値が割り当てられないので、初期化されていないローカル変数を使おうとした場合はコンパイルエラーになりますが、クラスや構造体のフィールド(メンバー変数)はstaticであるか否かを問わず、デフォルトでゼロやnullに必ず初期化される仕様になっています。そのため、ゼロやnullで十分な場合は、フィールドについては特に初期値を指定する必要はありません。動的に確保した配列の要素も、同様にデフォルト値で適切に初期化されます。

性能的なオーバーヘッドを嫌ったC++では、C同様に未初期化のスタック変数や未初期化のヒープは不定値となる仕様になってしまったのですが、後発のJavaC#は些細なオーバーヘッド低減よりも安全性が優先されています。結果として、JavaC#は言語仕様レベルでプログラマーに優しいものになっています。C/C++が登場した頃のコンピュータ性能は極めて貧弱であり、時代的背景を考えれば仕方ない面もあるのですが、こういったC/C++の不親切で危険な部分が入門者殺しでもあると言えます。

メンバー変数を初期化しないコンストラク

ひとつ注意点があるとすれば、Tがいわゆる「怠惰なデフォルトコンストラク」(メンバー変数をきちんと初期化しないユーザー定義のコンストラクタ)を持つ場合、上記の初期化構文を使ったとしても不定値となり、ゴミデータが入ったままになります。

#include <iostream>

// どのような初期化構文を使っても不定値になる怠惰なデフォルトコンストラクタを持つクラス。
struct MyLazyType
{
    long long m_x, m_y;
    // ユーザー定義の空のデフォルトコンストラクタ。POD 型のメンバー変数を初期化しない。
    MyLazyType() {}
};

struct MyPodType
{
    long long m_x, m_y;
    // デフォルトコンストラクタの定義はコンパイラに任せる。
    // C++11 以降では以下のように書いても同様にユーザー定義ではなくコンパイラ任せとなる。
    //MyPodType() = default;
};

int main()
{
    //MyLazyType obj1{};
    auto obj1 = MyLazyType();
    std::cout << "obj1.x = " << obj1.m_x << std::endl; // 不定値。
    std::cout << "obj1.y = " << obj1.m_y << std::endl; // 不定値。

    //MyPodType obj2{};
    auto obj2 = MyPodType();
    std::cout << "obj2.x = " << obj2.m_x << std::endl; // 必ず 0 になる。
    std::cout << "obj2.y = " << obj2.m_y << std::endl; // 必ず 0 になる。
}

メンバー変数の型がstd::stringのように適切なユーザー定義のデフォルトコンストラクタを持つ型であれば、コンストラクタで特に初期値を与えずとも問題ありませんが、POD型の場合は不定値になります。

例えばC++11非対応コンパイラ環境におけるBoost.Atomicや、VC2012/VC2013における標準ライブラリのstd::atomic*2が該当します。Windowsプログラミングで言うと、古いバージョンのATL/MFCライブラリのCPoint, CSize, CRectや、D3DXライブラリのD3DXVECTOR2, D3DXVECTOR3, D3DXVECTOR4などは怠惰なデフォルトコンストラクタを持ちます。これらをローカル変数やクラスのメンバー変数にするときは必ず初期化子リストで引数付きコンストラクタを使うなりして明示的に初期化するようにしておきましょう。とはいえ、そもそもこういう邪悪な空のコンストラクをユーザー定義しようとすること自体がおかしいのですが*3

class MyClass
{
    std::atomic<int> m_counter1;
    std::atomic<int> m_counter2;
    std::atomic<int> m_counter3;
public:
    MyClass()
    : m_counter2()
    , m_counter3(0)
    {
        // m_counter1 は、C++17 までは不定値になる。C++20 準拠であればゼロに初期化される。
        // m_counter2 は、std::atomic のデフォルトコンストラクタが C++11 準拠で default 指定されていればゼロに初期化されるが、
        // C++11 に正しく準拠しておらず空のユーザー定義デフォルトコンストラクタを持つ VC2012/VC2013 では不定値になる。
    }
};

同様に、std::vector<T>::vector(size_type n)のコンストラクタ呼び出しでは、各要素の初期化にT()の構文が使われるので、例えば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以降に付属する新しいATL/MFC 10.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;
}

コンストラクタにおけるメンバー初期化子リストは、基底クラスのメンバーに対して使用することはできません。派生クラスのコンストラクタで基底クラスのメンバーを初期化するには、メンバー初期化子リストで基底クラスのコンストラクタを明示的に呼び出すか、あるいは代入文などを使う必要があります。ただし、こういったC互換の構造体(デストラクタが非仮想の型)は、ポリモーフィックなdeleteができないので、そもそも継承するのは避けるべきです。継承を前提としたクラスのメンバー変数はprivateとし、protectedやpublicは避けるべきです。ATL/MFCC++の言語仕様が標準化される前の黎明期から存在する古いライブラリということもあり、設計がおかしい部分が多々あります。C++ではstructとclassに本質的な違いがなく紛らわしいのですが、C#のようにstructキーワードを使った場合は少なくとも派生型を定義できないように制限してくれればよかったのに……

一方、JavaのfinalフィールドやC#のreadonlyフィールドに相当するような、コンストラクタで一度しか代入しないconstメンバー変数を初期化するには、代入ではなくメンバー初期化子リストを使う必要があります。参照型変数も未初期化の状態にすることはできないので、参照型のメンバー変数を初期化するには、最初は代入ではなくメンバー初期化子リストを使う必要があります(後で再代入することは可能)。

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

コーディング時の注意点として、コンストラクタのメンバー初期化子リストを使う場合は、
メンバーの定義順に初期化子を記述していく
必要があるんですが、詳しい話は書籍『C++ Coding Standards』に載っています。順序が異なっていると未定義動作を引き起こします。
MSVCでは定義順と初期化子の記述順が異なっても警告が出ないけれど、Intel C++やg++ではきちんと警告が出ます。
Clangにも互換警告オプション-Wreorderがあります。

2023-01追記:
VC++ 2017以降で警告C5038が追加されたようです(ただしデフォルトでOFF)。

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

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()に相当します。これは「default-initialization」と呼ばれています(日本語では「デフォルト初期化」)。

一方、()を付ける後者のコードは、newするタイミングでゼロクリアされます。C言語関数のcalloc()に相当します。
可変長配列をヒープした後、すぐにゼロクリアするとき、malloc() + memset()new[N] + memset()を使う人が非常に多いのですが、calloc()new[N]()を使えば余計なmemsetを削除できます。環境・処理系によってはmalloc()/new[N] + memset()よりもcalloc()/new[N]()だけのほうが高速なコードを出力してくれるので、積極的に使いましょう。

注意点として、ヒープする型が前述の怠惰なデフォルトコンストラクを持つ場合、new[N]()を使ったとしても不定値となり、やはりゴミデータが入ったままになります。コンストラクタをユーザー定義するのであれば、メンバー変数は必ずメンバー初期化子リストで適切に初期化するべきです。

なお、前述のコンストラクタにおける空のメンバー初期化子リストによるゼロクリア構文と併せて、これらのC++言語機能は「value-initialization」と呼ばれています(日本語では「値初期化」)。

また、値初期化の概念が導入されたのはC++03以降なので、C++03に準拠していない古いコンパイラでは、new T()が値初期化ではなくデフォルト初期化になってしまう可能性があります(ただしC++98でもnew T()は一応ゼロ初期化には行き着くらしい?)。

C++98の規格(ISO/IEC 14882:1998)では、値初期化という用語は登場しません*4C++03の規格(ISO/IEC 14882:2003)以降では「8.5 Initializers」にて「value-initialize」という用語が登場します。「12.6.2 Initializing bases and members」と併せて読めば、メンバー初期化子リストで空のカッコ()を使ってゼロクリアできる理由が分かります。

ちなみに上記のPDFは、C++98以外はすべてワーキングドラフトです。最終的に確定した正式な規格のPDFぐらいは誰でも無料で読めるようにして欲しいですね。言語仕様書を読むのに金を取るとかアホなのか。完全に時代遅れ。こんな体たらくだからC/C++はいつまで経っても邪悪な言語という誹りを免れないんです。そして所詮 cppreference.com は有志による非公式リファレンスにすぎないので、英語版/日本語版問わず間違いが含まれている可能性もあることに注意してください。ご利用は自己責任で。

C++11

C++11ではstd::initializer_listやリスト初期化機能などのuniform 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');
}

初期化子リストのパフォーマンス

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

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

あとで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()に任せましょう。

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

*1:厳密に言うと、C/C++では未初期化変数を使用しても構文上は合法であり、未定義動作ではあるもののコンパイルエラーにならないことが邪悪です。JavaC#では未初期化のローカル変数を使おうとすると必ずコンパイルエラーになります。例えばバイト配列を確保した直後にmemset()を使ってゼロ以外の値で初期化するといった場面では、二重初期化のコストを避けるために仕方がないという理屈は理解できなくもないのですが、速度を優先しすぎたあまり安全性が犠牲になっています。現代では、貧弱な組み込み環境を除いて、一般的にローカル変数の二重初期化のコストが問題になるケースは少ないので、まずは問答無用でゼロ初期化する習慣をつけるべきだと思います。ほとんどのC/C++コンパイラは未初期化変数を使用すると警告を出してくれるので、コンパイラオプションにより警告ではなくエラー扱いにしておくことをお勧めします。

*2:C++17以前ではstd::atomicのデフォルトコンストラクタがtrivialであることが規定されており、つまりデフォルトコンストラクタはユーザー定義されないことが規定されています。そのため、VC2012/VC2013の実装は規格に正しく準拠していません。ちなみにC++20ではstd::atomicのデフォルトコンストラクタが基底オブジェクト(テンプレート内部で管理されるオブジェクト)の値初期化を保証するようになりました。ただし、VC2019ではこの修正が無効になっているらしく。C++20規格に準拠していません。Visual Studio 2019 での C++ 準拠の強化 | Microsoft Learn

*3:個人的には、単純にpublicなデータを束ねるだけの構造体や共用体は、そもそもコンストラクタをユーザー定義するべきではないと思います。コンストラクタをユーザー定義したければ、最初からデータメンバーを隠蔽したクラスにするべきです。大半の人間はC++のゼロ初期化の構文を知らないから、構造体にもユーザー定義のコンストラクタをわざわざ書いて初期化しようとするのだと思います。

*4:厳密に言うと、C++98リリース後からC++03リリース前の間に、Andrew KoenigによってIssue 178の欠陥報告 (defect report, DR) がなされ、C++98仕様にもvalue initializationが後出しで導入されたらしい(?)のですが、肝心の正誤表 (technical corrigendum, TC) が適用された規格書の改訂版を読んだことがないので実際のところどうなっているのかは分かりません。というより、TCの承認後、適用結果として生まれた改訂規格がC++03なのではないかと。つまり、C++03に準拠していない古い処理系は、値初期化が実装されていないものとみなしたほうがよいと思います。だから無料で正式な仕様書を読めるようにしろっつってんだろうが……相変わらずC++標準化委員会のダボハゼ老害どもは時代を読めてねーな。