前言
「变量初始化有哪几种写法」这个问题,答案取决于你按什么维度分类。按语法形式分,最常说的是三种:复制初始化(copy-initialization,int a = 1;)、直接初始化(direct-initialization,int a(1);)、列表初始化(list-initialization,int a{1};,C++11 起)。标准里还有默认初始化、值初始化、聚合初始化、引用初始化等分类,它们描述的是「初始化的语义从哪来」,和这三种语法形式是交叉的。
初学时有两个典型误解。其一,把int a = 1;当成赋值——它是初始化,=在这里只是语法记号,不会先构造再赋值,对类类型也不会调用operator=。其二,以为花括号「总是更安全所以到处用」——花括号确实禁止窄化转换、能避开最令人烦恼的解析(most vexing parse),但它会优先匹配std::initializer_list构造函数,写容器时容易得到和预期不同的结果。
本文按三种语法形式展开,说明每种写法允许什么、禁止什么,再补充默认初始化与值初始化的区别,最后用一个完整例子把容易混淆的点串起来。基准是 C++17,涉及更早标准的差异会单独注明。
一、复制初始化:T x = expr;
复制初始化用等号引入初始值。对内置类型,它就是「把值放进变量」;对类类型,它要求编译器把expr隐式转换到T,再用转换结果初始化对象。
关键点是:复制初始化不允许使用explicit构造函数。因为explicit的语义就是「禁止隐式转换」,而复制初始化正要执行一次隐式转换。
// 适用于 C++17
class Widget {
public:
explicit Widget(int id) : id_(id) {}
int id() const { return id_; }
private:
int id_;
};
Widget a(7); // 直接初始化,允许 explicit
Widget b = 7; // 错误:复制初始化不能调用 explicit 构造函数对内置类型,int a = 3.9;是合法的:3.9隐式转换为int,行为是截断(向零取整),编译器通常会给出警告但不会拒绝。这一点和列表初始化形成鲜明对比。
复制初始化还用于函数传参、函数返回、异常对象的抛出与捕获。写void f(Widget w); f(7);时,实参7到形参Widget的转换同样是复制初始化,因此explicit构造函数同样不参与。
二、直接初始化:T x(expr);
直接初始化用圆括号提供初值。它允许调用explicit构造函数,也允许普通的转换构造函数;对内置类型,效果与复制初始化相同。
// 适用于 C++17
#include <string>
#include <vector>
int a(42);
double b(3.5);
std::string s(5, 'x'); // 5 个 'x'
std::vector<int> v(3, 7); // 3 个元素,每个都是 7圆括号写法有一个著名的陷阱:最令人烦恼的解析。T x();看起来像「用空参数构造一个T」,但 C++ 的语法规则把它解析成「声明一个名为x、返回T、不接受参数的函数」。所以想要值初始化一个对象,不能写空圆括号:
// 适用于 C++17
struct Point { int x; int y; };
Point p(); // 声明了一个函数,不是对象
Point q{}; // 正确:值初始化一个 Point同理,std::vector<int> v(std::istream_iterator<int>(cin), std::istream_iterator<int>());会被解析成函数声明,而不是用两个迭代器构造容器。这类问题在 C++11 之后有了统一的解法:用花括号。
直接初始化还有一个容易忽略的细节:当实参是另一个同类型对象时,T x(y)调用的是拷贝构造函数(或移动构造函数),而不是某种特殊的「直接赋值」。对类类型,T x(y)与T x = y最终都可能调用拷贝/移动构造,区别只在于是否允许explicit以及是否考虑转换序列。
三、列表初始化:T x{expr};
列表初始化是 C++11 引入的统一初始化语法(uniform initialization),用花括号提供初值。它有几个明确的行为:
- 禁止窄化转换(narrowing conversion)。
int x{3.9}、char c{1000}这类写法在编译期就是错误的,不是警告。 - 优先匹配
std::initializer_list构造函数。如果类型有接收std::initializer_list的构造函数,花括号里的元素会先尝试匹配它。 - 可以用于聚合初始化。对聚合类型(没有用户提供的构造函数、没有私有/保护的非静态数据成员、没有虚函数等),花括号按成员声明顺序初始化。
- 空花括号
{}执行值初始化,对内置类型得到 0,对类类型调用默认构造函数(如果存在)。
// 适用于 C++17
#include <string>
#include <vector>
int a{42};
int b{}; // 值初始化,b == 0
double c{3.5};
struct Config {
int retries;
double timeout;
std::string name;
};
Config cfg{3, 1.5, "worker"}; // 聚合初始化,按声明顺序
std::vector<int> v1(3, 7); // 3 个元素,每个是 7
std::vector<int> v2{3, 7}; // 2 个元素:3 和 7最后两行是最典型的「花括号不是万能」的例子。v1(3, 7)匹配的是vector(size_type count, const T& value);v2{3, 7}匹配的是vector(std::initializer_list<int>)。两个写法只差一对括号,元素个数和内容完全不同。要写「用 n 个 value 填充」时,必须用圆括号。
窄化转换的判定也值得说清楚。标准把以下几类视为窄化:浮点到整数;long double到double/float、double到float(常量表达式且值在目标范围内时例外);整数到浮点(常量表达式且能精确还原时例外);整数到无法表示原类型全部值的整数类型(常量表达式且值能放下时例外)。实际写代码时不必背条文,记住「花括号里放常量也可能因为精度或范围被拒绝」即可,编译器会明确指出是哪一处。
列表初始化还有T x = {1, 2};的写法,叫复制列表初始化(copy-list-initialization)。它和T x{1, 2};的区别在于:如果选中的构造函数是explicit,复制列表初始化是 ill-formed。其余规则相同,同样禁止窄化、同样优先initializer_list。
四、补充:默认初始化与值初始化不是一回事
三种语法之外,最容易出错的是「不写初始化器」的情况。
// 适用于 C++17
int a; // 默认初始化:a 的值不确定(indeterminate)
int b{}; // 值初始化:b == 0
int c = int(); // 值初始化:c == 0
int* p = new int; // 默认初始化:*p 的值不确定
int* q = new int(); // 值初始化:*q == 0
int* r = new int{}; // 值初始化:*r == 0对内置类型,默认初始化不把变量清零。在函数内部读一个未初始化的局部变量是 UB(未定义行为),不是「读到 0」或「读到随机数」这么温和。对类类型,默认初始化会调用默认构造函数(如果该类有用户提供的或隐式的默认构造函数);如果类没有默认构造函数,T x;无法通过编译。
值初始化的规则稍复杂:对内置类型清零;对类类型,如果有用户提供的默认构造函数就调用它,否则先零初始化再调用隐式的(或= default的)默认构造函数。空花括号{}在现代 C++ 里是最稳妥的「清零」写法。
类成员的初始化顺序也经常被误解。成员按声明顺序初始化,与构造函数初始化列表里的书写顺序无关。
// 适用于 C++17
class Buffer {
public:
Buffer(int n) : size_(n), data_(size_) {} // 危险:data_ 先于 size_ 初始化
private:
int* data_;
int size_;
};上面的初始化列表里data_(size_)读的是尚未初始化的size_,属于 UB。正确做法是按声明顺序书写,或者把依赖关系改成函数调用、把size_声明在前。开启-Wreorder一类警告可以让编译器提示顺序不一致。
五、完整示例
下面的程序把三种写法、窄化、initializer_list优先级和成员初始化顺序放在一起对照。代码只做演示,不依赖任何运行结果。
// 适用于 C++17
#include <iostream>
#include <string>
#include <vector>
struct Config {
int retries;
double timeout;
std::string name;
};
class Widget {
public:
explicit Widget(int id) : id_(id) {}
int id() const { return id_; }
private:
int id_;
};
int main() {
int a = 42; // 复制初始化
int b(42); // 直接初始化
int c{42}; // 列表初始化
int d{}; // 值初始化,d 为 0
// int bad{3.9}; // 错误:窄化转换,编译期拒绝
int trunc = 3.9; // 合法:隐式转换截断为 3
Config cfg{3, 1.5, "worker"}; // 聚合初始化
std::vector<int> filled(3, 7); // 3 个 7
std::vector<int> listed{3, 7}; // 2 个元素:3 和 7
Widget w1(7);
Widget w2{7};
// Widget w3 = 7; // 错误:explicit 构造函数不参与复制初始化
std::cout << a + b + c + d << '\n';
std::cout << trunc << ' ' << cfg.retries << ' ' << cfg.timeout << '\n';
std::cout << filled.size() << ' ' << listed.size() << '\n';
std::cout << w1.id() << ' ' << w2.id() << '\n';
return 0;
}读这段代码时注意三处:int trunc = 3.9;能通过编译但会丢失小数部分,编译器通常警告;filled和listed的差异来自重载决议,不是笔误;Widget w3 = 7;被注释掉是因为它确实无法通过编译,而不是运行期才失败。
常见坑点
1. 把初始化当成赋值
❌ 认为std::string s = "hi";先构造空串再赋值,担心性能。
✅ 这是复制初始化,直接构造s;=只是语法形式,不调用operator=。
2. 用空圆括号做值初始化
❌std::vector<int> v();,以为构造了空 vector。
✅ 写std::vector<int> v;或std::vector<int> v{};,避免最令人烦恼的解析。
3. 以为花括号永远等价于圆括号
❌std::vector<int> v{10, 20};以为得到 10 个值为 20 的元素。
✅ 需要「个数 + 值」时用v(10, 20);花括号会匹配initializer_list。
4. 在花括号里写窄化转换
❌int x{3.9};或char c{1000};,编译失败后不明所以。
✅ 明确写出转换:int x{static_cast<int>(3.9)};,并确认截断是预期行为。
5. 读未初始化的局部变量
❌int n; if (n > 0) { ... },值是 indeterminate,读取是 UB。
✅int n{};或int n = 0;,让初值明确。
6. 成员初始化列表顺序与声明顺序不一致
❌ 初始化列表里后面的成员依赖前面的成员,但声明顺序相反。
✅ 按声明顺序书写初始化列表;把被依赖的成员声明在前。
7. 对explicit构造函数使用复制初始化
❌Widget w = 7;,编译失败。
✅Widget w(7);或Widget w{7};。
8. 认为new int会清零
❌int* p = new int;之后直接读*p。
✅new int()或new int{};更好的做法是用std::make_unique<int>(0)之类的 RAII 手段。
总结
| 写法 | 语法 | 允许explicit | 窄化转换 | initializer_list优先 |
|---|
| 复制初始化 | T x = v; | 否 | 允许(可能截断) | 是(复制列表初始化) |
| 直接初始化 | T x(v); | 是 | 允许 | 否 |
| 列表初始化 | T x{v}; | 是 | 禁止 | 是 |
| 默认初始化 | T x; | — | — | 内置类型值不确定 |
| 值初始化 | T x{}; | 是 | 禁止 | 是 |
三种写法不是「新旧替代」关系,而是各有适用场景:需要显式构造、需要接受多个参数时用圆括号;需要禁止窄化、需要聚合初始化、需要避免解析歧义时用花括号;复制初始化则在语义上明确表达「这里发生一次隐式转换」。真正要养成的习惯是:内置类型一律给初值,容器区分()与{},类的成员按声明顺序初始化。