ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++中变量进行初始化的3种方法

C++中变量进行初始化的3种方法

前言


「变量初始化有哪几种写法」这个问题,答案取决于你按什么维度分类。按语法形式分,最常说的是三种:复制初始化(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),用花括号提供初值。它有几个明确的行为:



  1. 禁止窄化转换(narrowing conversion)。int x{3.9}、char c{1000}这类写法在编译期就是错误的,不是警告。

  2. 优先匹配std::initializer_list构造函数。如果类型有接收std::initializer_list的构造函数,花括号里的元素会先尝试匹配它。

  3. 可以用于聚合初始化。对聚合类型(没有用户提供的构造函数、没有私有/保护的非静态数据成员、没有虚函数等),花括号按成员声明顺序初始化。

  4. 空花括号{}执行值初始化,对内置类型得到 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{};是禁止是



三种写法不是「新旧替代」关系,而是各有适用场景:需要显式构造、需要接受多个参数时用圆括号;需要禁止窄化、需要聚合初始化、需要避免解析歧义时用花括号;复制初始化则在语义上明确表达「这里发生一次隐式转换」。真正要养成的习惯是:内置类型一律给初值,容器区分()与{},类的成员按声明顺序初始化。




RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进