C++では変数の宣言と同時に初期化する書き方が複数あり、見た目が似ていても意味が異なります。
本記事では、=
、()
、{}
の3つの記法を中心に、デフォルト初期化や値初期化、ゼロ初期化の違い、narrowing変換の禁止、explicitコンストラクタとの関係まで、初心者がつまずきやすい点を丁寧に解説します。
C++の変数初期化の基礎
宣言と初期化の違い
C++では、変数の「宣言」と「初期化」は別概念です。
宣言は変数の存在をコンパイラに伝えますが、値は未定義のままです。
初期化はその瞬間に値を与えます。
また、記号=を使った初期化は「代入」ではなく「初期化」です。
代入はすでに生成済みのオブジェクトの値を後から変更する操作です。
サンプル
#include <iostream>
// どの操作が「初期化」で、どれが「代入」かを観察するサンプル
struct Tracer {
Tracer() { std::cout << "default ctor\n"; }
Tracer(int) { std::cout << "int ctor\n"; }
Tracer& operator=(int) {
std::cout << "assign from int\n";
return *this;
}
};
int main() {
int x; // 宣言のみ。xの値は未定義(使ってはいけない)
x = 10; // 代入。ここで初めてxに値を入れる
int y = 10; // 初期化。= を使っているが「代入」ではなく「初期化」
Tracer a = 5; // 初期化。int からのコンストラクタが呼ばれる(int ctor)
Tracer b(5); // 初期化。こちらもint ctor
Tracer c; // default ctor
c = 5; // 代入。operator= が呼ばれる(assign from int)
std::cout << "x=" << x << ", y=" << y << "\n";
}
int ctor
int ctor
default ctor
assign from int
x=10, y=10
デフォルト初期化と値初期化
デフォルト初期化(default-initialization): T x;
のように、引数なしの初期化式でオブジェクトを生成します。
組み込み型(intなど)の自動変数は未定義のごみ値になります。クラス型ならデフォルトコンストラクタが呼ばれます。
値初期化(value-initialization): T x{}
や T x()
など、値を指定しないものの「値を既定状態に整える」初期化です。
組み込み型なら0に、ポインタならヌルに、クラス型ならデフォルトコンストラクタが呼ばれます。
T x()
と書くスタイルは関数宣言と誤解されやすいため、近年は T x{}
が推奨されます。
ゼロ初期化の発生タイミング
ゼロ初期化(zero-initialization)とは、その型の「ゼロ相当値」にセットされることです。
- 静的記憶域期間のオブジェクト(グローバル変数、名前空間スコープの変数、static変数)は必ずゼロ初期化されます。
- 値初期化の一部として、組み込み型のローカル変数に
T{}
を使うと0になります。
サンプル
#include <iostream>
int g; // 静的記憶域期間。ゼロ初期化される
struct S { int n; };
int main() {
int a; // デフォルト初期化(自動変数)。未定義の値(使ってはいけない)
int b{}; // 値初期化 → 0
static int s; // 静的記憶域期間 → ゼロ初期化
S s1{}; // 集成体の値初期化 → メンバも0になる
std::cout << "g=" << g << ", b=" << b
<< ", s=" << s << ", s1.n=" << s1.n << "\n";
}
g=0, b=0, s=0, s1.n=0
C言語との違いと代入の誤解
Cでも int x = 0;
は初期化ですが、C++ではクラス型が加わり、初期化はコンストラクタ呼び出しを伴うことがあります。
T t = expr;
は「=」を使っていますが、初期化であり代入ではありません。
代入は t = expr;
の形です。
これを意識すると、コンストラクタが呼ばれるタイミングや、代入演算子が呼ばれるタイミングを正しく理解できます。
記法を比較: = と () と {}
ここでは3つの記法の意味の違いを、動く例とともに見ます。
= によるコピー初期化の挙動
コピー初期化(copy-initialization)は T x = expr;
の形です。
暗黙の型変換が比較的ゆるく許可され、クラス型では非explicitなコンストラクタが候補になります。
サンプル
#include <iostream>
#include <string>
int main() {
int i = 42; // OK
double d = 3.5; // OK
int j = d; // OK(暗黙の縮小変換)。jは3になる
std::string s = "abc"; // OK(文字列リテラルからの変換)
std::cout << "i=" << i << ", j=" << j << ", s=" << s << "\n";
}
i=42, j=3, s=abc
() による直接初期化の挙動
直接初期化(direct-initialization)は T x(arg1, arg2, ...)
の形です。
コンストラクタ呼び出しに近く、explicitコンストラクタも候補になります。
初期化子が空の T x();
は関数宣言に解釈されるため注意が必要です。
サンプル(文字列のさまざまな初期化)
#include <iostream>
#include <string>
int main() {
std::string a(5, 'x'); // 'x'を5個 → "xxxxx"
std::string b(3, 65); // 65は'A' → "AAA" ただしcharへの変換に依存
std::cout << "a=" << a << ", b=" << b << "\n";
}
a=xxxxx, b=AAA
{} によるリスト初期化とユニフォーム初期化
中かっこを使う書き方はC++11以降で導入されたリスト初期化(list-initialization)です。
型に std::initializer_list
コンストラクタがある場合、それが優先されます。
引数なしの {}
は値初期化の簡潔表記になり、組み込み型なら0、std::stringなら空文字列になります。
「ユニフォーム初期化」とも呼ばれ、型を問わず同じ書き方で初期化できる利点があります。
サンプル(ベクタと文字列の違いを観察)
#include <iostream>
#include <vector>
#include <string>
template <class T>
void print_vec(const std::vector<T>& v, const char* name) {
std::cout << name << " = [";
for (size_t i = 0; i < v.size(); ++i) {
std::cout << v[i] << (i + 1 < v.size() ? ", " : "");
}
std::cout << "]\n";
}
int main() {
std::vector<int> v1(3, 1); // 要素数3、すべて1 → [1, 1, 1]
std::vector<int> v2{3, 1}; // 初期化子リスト → 2要素 [3, 1]
std::string s1{}; // 空文字列
std::string s2{'h', 'i'}; // 初期化子リスト → "hi"
print_vec(v1, "v1");
print_vec(v2, "v2");
std::cout << "s1=\"" << s1 << "\", s2=\"" << s2 << "\"\n";
}
v1 = [1, 1, 1]
v2 = [3, 1]
s1="", s2="hi"
narrowing変換の禁止で型安全
リスト初期化ではnarrowing変換(情報が失われる可能性のある縮小変換)がコンパイルエラーとして禁止されます。
型安全性の向上に有効です。
// コンパイルエラーの例(実行はしません)
// int x{3.14}; // double から int への縮小変換 → エラー
// char c{1000}; // 値がcharに収まらない可能性 → エラー
// 明示的に安全を意図した変換はOK
int y{static_cast<int>(3.14)}; // OK。意図が明確
explicitコンストラクタの扱い
explicitが付いたコンストラクタは「暗黙変換を禁止」します。
コピー初期化 T x = expr;
やコピーリスト初期化 T x = {args};
では選ばれず、直接初期化 T x(expr);
や直接リスト初期化 T x{args};
でのみ使えます。
サンプル
#include <iostream>
struct Meter {
int value;
explicit Meter(int v) : value(v) {}
};
int main() {
Meter a(5); // OK(直接初期化)
Meter b{5}; // OK(直接リスト初期化)
Meter c = Meter{5}; // OK(一度直接リスト初期化した一時をコピー初期化)
// Meter d = 5; // エラー: explicitによりコピー初期化不可
// Meter e = {5}; // エラー: explicitによりコピーリスト初期化不可
std::cout << "a=" << a.value << ", b=" << b.value << ", c=" << c.value << "\n";
}
a=5, b=5, c=5
型別の初期化パターン
組み込み型の初期化
組み込み型(int、double、ポインタなど)は初期化の仕方で値が大きく変わります。
未初期化の読み出しは未定義動作になるため、基本的に {}
を使って安全に0初期化するのがおすすめです。
サンプル
#include <iostream>
int main() {
int a{}; // 0
double d{}; // 0.0
int* p{}; // ヌルポインタ(nullptrと等価)
std::cout << "a=" << a << ", d=" << d << ", p is "
<< (p == nullptr ? "null" : "not null") << "\n";
}
a=0, d=0, p is null
動的確保した組み込み型の初期化
#include <iostream>
int main() {
int* p1 = new int; // デフォルト初期化 → 未定義のごみ値(読み出し禁止)
int* p2 = new int(); // 値初期化 → 0
int* p3 = new int{}; // 値初期化 → 0
*p1 = 7; // 自分で値を入れれば安全に使える
std::cout << "*p1=" << *p1 << ", *p2=" << *p2 << ", *p3=" << *p3 << "\n";
delete p1;
delete p2;
delete p3;
}
*p1=7, *p2=0, *p3=0
ただし本記事では初期化の違いに焦点を当てています。
std::stringの初期化
std::string
はクラス型なので、初期化の形式に応じて呼ばれるコンストラクタが変わります。
#include <iostream>
#include <string>
int main() {
std::string s1; // デフォルト初期化 → 空文字列
std::string s2{}; // 値初期化 → 空文字列
std::string s3 = "hello"; // コピー初期化
std::string s4("hello"); // 直接初期化
std::string s5(3, 'x'); // 直接初期化 → "xxx"
std::string s6{'a','b','c'}; // リスト初期化 → "abc"
std::cout << s1 << "|" << s2 << "|" << s3 << "|" << s4
<< "|" << s5 << "|" << s6 << "\n";
}
||hello|hello|xxx|abc
配列とアグリゲート初期化
配列や集成体(アグリゲート: メンバがpublicの配列や単純なstructなど)は、{}
による初期化が基本です。
省略された要素は0で埋まります。
#include <iostream>
struct Point {
int x;
int y;
};
int main() {
int a[3] = {1, 2, 3}; // 完全指定
int b[5]{}; // すべて0
int c[5] = {}; // 同上
Point p1{1, 2}; // アグリゲート初期化
Point p2{}; // {0, 0}
std::cout << "a: " << a[0] << "," << a[1] << "," << a[2] << "\n";
std::cout << "b: " << b[0] << "," << b[1] << "," << b[2] << "," << b[3] << "," << b[4] << "\n";
std::cout << "p1: (" << p1.x << "," << p1.y << "), p2: (" << p2.x << "," << p2.y << ")\n";
}
a: 1,2,3
b: 0,0,0,0,0
p1: (1,2), p2: (0,0)
クラスのメンバ初期化とin-class初期化
C++11以降、メンバを宣言時に初期化できるようになりました(in-class初期化)。
コンストラクタのメンバ初期化子リストと組み合わせると安全かつ簡潔です。
#include <iostream>
#include <string>
struct Person {
std::string name{"noname"}; // in-class初期化
int age{0}; // in-class初期化
Person() = default;
// 必要に応じて初期化子リストで上書き
Person(std::string n, int a) : name(std::move(n)), age(a) {}
};
int main() {
Person p1; // "noname", 0
Person p2("Alice", 30); // "Alice", 30
std::cout << p1.name << " (" << p1.age << ")\n";
std::cout << p2.name << " (" << p2.age << ")\n";
}
noname (0)
Alice (30)
コンストラクタの初期化子リストの並び順ではありません。
依存関係のあるメンバは宣言順を意識して並べましょう。
const変数とconstexprの初期化
- const変数は必ず初期化が必要です。後から代入で値を入れることはできません。
- constexprはコンパイル時に評価可能な定数式で初期化します。
#include <iostream>
int main() {
const int a = 10; // OK
constexpr int b = 20; // OK(コンパイル時計算)
// const int c; // エラー: 初期化が必要
int arr[b]{}; // 配列サイズにconstexprが使える
std::cout << "a=" << a << ", b=" << b << ", arr_size=" << std::size(arr) << "\n";
}
a=10, b=20, arr_size=20
落とし穴とベストプラクティス
Most vexing parseを{}で回避
T x(Type());
のような書き方は、変数定義ではなく「関数宣言」に解釈されることがあります。
これが有名なMost vexing parseです。
{}
を使うと確実に変数定義になります。
// これは関数宣言に解釈される可能性があるパターン(例です、コンパイルしません)
// std::vector<int> v(std::vector<int>());
// {} を使えば明確にオブジェクト生成
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{}; // 空ベクタを生成
std::cout << "v.size()=" << v.size() << "\n";
}
v.size()=0
未初期化の未定義動作を防ぐ
ローカルな組み込み型を int x;
のように宣言だけして使うと、値は未定義です。
動いたり動かなかったりする厄介なバグになります。
必ず初期化しましょう。
特に {}
は安全な既定値(0など)を与えるため、迷ったら {}
を選ぶのが良策です。
#include <iostream>
int main() {
// int x; // これを使ってはいけない(未定義)
int x{}; // 安全: 0で初期化
std::cout << x << "\n";
}
0
使い分けの結論
- 原則: まず
{}
を使う。値なし{}
は0や空の安全な既定値になり、narrowing変換も防げます。 ()
は「初期化子リストを避けたいとき」や「特定のコンストラクタを確実に呼びたいとき」に使います。T x();
は関数宣言になりやすいので避ける。=
はコピー初期化。読みやすいが、暗黙変換が広く許可されるため、意図しない変換が入る余地があります。クラス型ではexplicitなコンストラクタが選ばれない点にも注意。T x = {args};
のコピーリスト初期化はあまり使われません。T x{args};
を優先します。- 動的確保では
new T{};
やnew T();
が値初期化、new T;
は未初期化です。
以下に3記法の違いを簡単にまとめます。
記法 | 名称 | 主な特徴 | 一例 | 注意点 |
---|---|---|---|---|
T x = expr; | コピー初期化 | 暗黙変換が広い、explicitは不可 | int i = 3.14; | narrowingが通る、意図しない切り捨てに注意 |
T x(args); | 直接初期化 | explicit可、コンストラクタ選択が明確 | std::string s(5,'x'); | T x(); は関数宣言の罠 |
T x{args}; | 直接リスト初期化 | initializer_list優先、narrowing禁止、値なしで0初期化 | int n{}; | 期待しないコンストラクタが選ばれる場合あり(vectorの例など) |
まとめ
C++の初期化は、見た目が似ていても意味と効果が大きく異なります。
特に、{}
は型を問わず一貫した書き方であり、ゼロ初期化やnarrowingの禁止といった安全性のメリットがあります。
一方で、()
はinitializer_listを避けて特定のコンストラクタを選びたい場合に有効で、=
は読みやすさを重視する場面で使えます。
ただし、=
は暗黙の縮小変換が許可されるため、数値型では特に注意が必要です。
クラス型ではexplicitの有無が初期化の可否に影響する点も重要です。
初期化はプログラムの健全性に直結します。
基本は {}
を軸に、安全に、そして意図を明確に書くことを心がけてください。