我很早就认识花括号。
那会儿还在写 C。
它的工作很单纯。
给数组塞初值。
给 struct 填字段。
int a[3] = {1, 2, 3};
struct Point { int x; int y; };
Point p = {10, 20};
那时候我对它的印象是。
朴素。
老实。
你给多少,它就装多少。
后来我开始写 C++。
花括号没变。
变的是初始化这件事。
它开始像一个老项目。
“能跑就行”的入口越开越多。
你想初始化一个 int。
你有好几种写法。
int x = 3;
int y(3);
再到 C++11。
又来了一个。
int z{3};
刚开始大家都觉得。
这不就是换个括号吗。
但工程里。
括号这东西从来不是装饰。
它牵扯的是语义。
而语义牵扯的是。
你半夜会不会被叫起来。
统一初始化:委员会也受够了
C++98/03 的初始化规则。
讲得很细。
细到你写着写着会开始怀疑人生。
有一次我在代码评审里看见这样一行。
Widget w();
我当时还夸了一句。
“写得挺规范,默认构造嘛。”
然后老同事抬头看我。
说。
“这是个函数声明。”
那一刻我就懂了。
这不是语法细节。
这是经典名场面。
most vexing parse。
你以为你在造对象。
编译器以为你在声明函数。
这类事情多了。
委员会也会烦。
所以 C++11 给了一个很诱人的承诺。
尽量用一套写法。
把各种初始化统一起来。
于是 {} 被扶正了。
你想要一个“肯定是对象”的默认构造。
你写。
Widget w{};
这就很像在说。
“别猜了。”
“我就是要一个对象。”
花括号带来的第一份福利:不许偷偷变窄
我个人最喜欢 {} 的地方。
不是好看。
是它更愿意当坏人。
它会拦住一些“看起来能跑”的代码。
int a = 3.14; // 可能给你一个警告,也可能什么都不说
int b(3.14); // 同样,很多编译器也就叹口气
你要是用 {}。
它就更硬。
int c{3.14}; // 直接不让过
这条规则叫。
narrowing conversion 禁止。
你可以把它理解成。
C++11 在提醒你。
“你确定要丢掉小数部分吗?”
如果你确定。
你就写显式转换。
让未来的你也别装作不知道。
int c{static_cast<int>(3.14)};
但花括号也有脾气:它会偏爱 std::initializer_list
故事从容器开始变得有趣。
我见过最多的一类 bug。
不是越界。
不是空指针。
是“我以为我写的是 A”。
“实际调用了 B”。
比如 std::vector。
你想要 10 个元素。
你写。
#include <vector>
std::vector<int> a(10); // 10 个 0
你想要一个元素。
值是 10。
你写。
std::vector<int> b{10}; // 1 个 10
看起来就差个括号。
但语义差了一整个宇宙。
当年我第一次踩这个坑。
是在做一个“预分配”的优化。
我把 () 改成了 {}。
心里还挺得意。
“统一初始化嘛。”
结果线上内存直接起飞。
因为我没预分配。
我变成了真的塞了一个元素。
你问。
为什么 {} 会这么“任性”。
因为它背后站着一个新类型。
std::initializer_list。
标准库为了让你能写。
std::vector<int> v{1, 2, 3};
就给容器加了一个构造函数。
大概长这样。
std::vector(std::initializer_list<int>);
然后重点来了。
只要候选里有 initializer_list。
而你的 {...} 又凑得上。
重载决议会“更喜欢”它。
这不是编译器叛逆。
这是标准写死的偏爱。
你可以理解成。
“花括号优先走列表语义。”
再来一个更阴的例子:std::string
std::string 也有类似的故事。
你想要 10 个 'x'。
老写法是这样的。
#include <string>
std::string s1(10, 'x');
很直白。
长度 10。
填充字符 'x'。
你要是手一抖。
写成了 {}。
std::string s2{10, 'x'};
它可能就走了“列表构造”。
把 10 当成一个字符值。
再把 'x' 当成另一个字符。
你最后得到的字符串。
可能根本不是 10 个 'x'。
你得到的是两个字符。
第一个字符的码值是 10。
第二个是 'x'。
这就是我常说的。
花括号很认真。
认真到它不愿意替你猜。
它默认你是在列清单。
std::initializer_list 到底是个什么东西
很多人把它想得很重。
觉得像个容器。
其实它很轻。
它更像一张“临时清单”。
你可以把它理解成。
一段只读数组。
再配一个长度。
所以你能很自然地写一个接口。
#include <initializer_list>
int sum(std::initializer_list<int> xs) {
int s = 0;
for (int v : xs) {
s += v;
}
return s;
}
调用点也很舒服。
int x = sum({1, 2, 3, 4});
但它的“轻”。
也意味着一个现实。
它背后那段数组。
通常是编译器生成的临时对象。
所以你别干一件事。
把 initializer_list 存起来。
留到以后再用。
你如果真想长期保存。
就把元素拷贝进你自己的容器里。
这不是教条。
这是生命周期。
你自己也可以提供列表构造
当年 STL 的设计给了大家一个暗示。
“如果你的类型像个容器。”
“那你也可以支持 {...}。”
比如你写一个小的 Vec。
#include <initializer_list>
#include <vector>
class Vec {
public:
Vec(std::initializer_list<int> xs) : data_(xs) {}
std::size_t size() const { return data_.size(); }
private:
std::vector<int> data_;
};
用起来就挺像回事。
Vec v{1, 2, 3};
但你得记住前面那句话。
只要你提供了 initializer_list 构造。
那它就可能在重载决议里“抢话筒”。
你本来想让 {10} 表示“容量”。
它可能会理解成“一个元素”。
接口设计到这里。
就已经不是语法问题了。
是你想让这个类型。
更像数组。
还是更像“有容量概念的容器”。
一个容易被忽略的细节:explicit 和 {} 也有关系
再讲一个偏冷门。
但很值钱的细节。
explicit 构造函数。
在不同的初始化语法下。
待遇不一样。
struct Port {
explicit Port(int v) : v(v) {}
int v;
};
你这样写。
Port p1{80};
是可以的。
因为这是 direct-list-initialization。
你就是在“直接构造”。
但你这样写。
Port p2 = {80};
很多人直觉以为也行。
其实不行。
这是 copy-list-initialization。
explicit 在这条路上会把门关上。
我喜欢这个规则。
它让你在“隐式”这件事上。
少走几步歪路。
结尾:花括号不是万能钥匙
统一初始化这件事。
C++11 做得很有野心。
它确实让很多初始化写法变得一致。
也确实修了一批老坑。
most vexing parse 这种。
能少见一点是一点。
但它也带来了一种新的现实。
花括号有自己的语义倾向。
它更像“列一个清单”。
一旦 std::initializer_list 进场。
它就会更坚定。
所以我现在写代码。
心里会默认问一句。
“我是在给参数。”
还是。
“我是在列元素。”
如果我是列元素。
我就用 {}。
如果我是传参数。
尤其是像 vector(10, 3) 这种。
我就老老实实用 ()。
别逞强。
括号这东西。
你尊重它。
它就少让你背锅。