浅谈C++的列表初始化std::initializer_list
兄弟们有群了:https://github.com/orgs/Awesome-Embedded-Learning-Studio/discussions/9
这个仓库已经开源!现代化 C++(C++11/14/17/20)从基础到进阶的系统教程都在这里,力争做一条完备的现代 C++ 学习路径!欢迎各位大佬前来参观,喜欢的话点个⭐!
Github 一键直达: https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP
看看超酷的新网站:https://awesome-embedded-learning-studio.github.io/Tutorial_AwesomeModernCPP/
想找其他伙伴的话在这里!https://github.com/orgs/Awesome-Embedded-Learning-Studio/discussions/9,加入我们!
开始的第一步------它是什么呢?编译器为 {...} 生成的只读视图
std::initializer_list 是 C++11 给"花括号列表初始化"配的标准库类型。咱们写 vector<int>{1, 2, 3} 或 f({1, 2, 3}) 时,编译器会在背后构造一个 std::initializer_list<int>,代表 {1, 2, 3} 这段序列。它本身是个极轻量的对象------大致就是一个指针加一个长度,和 span 一样属于"不拥有数据的视图"。
cpp
std::initializer_list<int> il = {1, 2, 3}; // 编译器构造,指向底层 const int[3]
il.size(); // 3
il.begin(); // 指向首元素
il.end(); // 尾后
几条性质捏在一起,决定了它的全部行为。它不拥有 元素,元素住在编译器生成的一段底层 const 数组里。元素是 const 的,只读。拷贝它又是浅拷贝,只拷那个指针和长度,不动元素。它最出名的坑就埋在这里,咱们往下看就会撞上。
它轻在于它浅拷贝
initializer_list 的拷贝是浅的:拷贝一个 initializer_list 就是拷它内部那个指针(和长度),底层那段 const 数组纹丝不动。所以咱们拿 initializer_list 传参几乎零成本,和传指针差不多。
cpp
void f(std::initializer_list<int> il); // 按值传,其实是浅拷贝(指针 + size)
f({1, 2, 3, 4, 5}); // 不拷贝 5 个 int,只传一个视图
但"元素是 const"这一点要记住:initializer_list 里的元素是 const T,咱们拿不到非 const 的访问。这看起来无害,却在和移动语义结合时挖了个大坑------下一节专门说。
移动陷阱:{...} 里的元素,进容器时只能拷贝
这是 initializer_list 最经典的坑。咱们想把几个对象塞进 vector,顺手写了 vector<T>{a, b, c},以为现代 C++ 会高效地移动它们------结果它们是拷贝进去的。
根因就在"元素是 const":initializer_list 的元素是 const T,而移动构造需要 T&&(非 const)。vector 从 initializer_list 构造时,得把每个 const 元素拷进自己的存储,const 拷不出 move,只能 copy。哪怕咱们在花括号里写 std::move,也只能让对象"移动进 initializer_list"(因为构造那一步接的是右值),可一旦进了 initializer_list 它就成了 const,再往 vector 里搬就只能拷贝了。
咱们量一下,看看到底拷了几次。用一个能统计拷贝 / 移动次数的类型:
cpp
#include <iostream>
#include <vector>
#include <string>
#include <utility>
struct Counted {
std::string s;
inline static int copies = 0;
inline static int moves = 0;
Counted(std::string x) : s(std::move(x)) {}
Counted(const Counted& o) : s(o.s) { ++copies; }
Counted(Counted&& o) noexcept : s(std::move(o.s)) { ++moves; }
};
int main()
{
// 场景 1:左值构造 initializer_list → vector
{
Counted a{"a"}, b{"b"}, c{"c"};
Counted::copies = 0;
Counted::moves = 0;
std::vector<Counted> v{a, b, c};
std::cout << "vector{a,b,c} : copies=" << Counted::copies
<< " moves=" << Counted::moves << "\n";
}
// 场景 2:move 进 initializer_list → vector(陷阱:进 vector 那步还是拷贝)
{
Counted a{"a"}, b{"b"}, c{"c"};
Counted::copies = 0;
Counted::moves = 0;
std::vector<Counted> v{std::move(a), std::move(b), std::move(c)};
std::cout << "vector{move(a),...} : copies=" << Counted::copies
<< " moves=" << Counted::moves << "\n";
}
// 场景 3:不用 initializer_list,push_back(move) → 全移动
{
Counted a{"a"}, b{"b"}, c{"c"};
Counted::copies = 0;
Counted::moves = 0;
std::vector<Counted> v;
v.reserve(3);
v.push_back(std::move(a));
v.push_back(std::move(b));
v.push_back(std::move(c));
std::cout << "push_back(move) : copies=" << Counted::copies
<< " moves=" << Counted::moves << "\n";
}
return 0;
}
bash
g++ -std=c++17 -O2 -o /tmp/init_list_test /tmp/init_list_test.cpp && /tmp/init_list_test
text
vector{a,b,c} : copies=6 moves=0
vector{move(a),...} : copies=3 moves=3
push_back(move) : copies=0 moves=3
咱们把三个场景对比着看。第一种 vector{a, b, c}(左值):6 次拷贝、0 次移动------3 次拷贝构造 initializer_list 的元素,再 3 次拷贝进 vector。第二种 vector{std::move(a), ...} 是 3 次拷贝、3 次移动:std::move 让对象移动进了 initializer_list(省了 3 次拷贝),但进 vector 那一步还是 3 次拷贝,const 移不动。第三种 push_back(std::move(...)) 是 0 拷贝、3 移动:绕开 initializer_list,直接 move 进 vector,零拷贝。
所以这个坑咱们得记住:把若干对象塞进容器,vector{move(a), ...} 仍会拷贝进 vector,只有 push_back(move) 才零拷贝。当 T 是重型类型(大 string、大 vector),这个差距是实打实的拷贝开销。
花括号优先:为什么 {...} 总爱匹配 initializer_list 构造
initializer_list 还有个咱们得提防的"重载偏好":只要一个类的构造函数有 initializer_list 版本,花括号初始化就会优先选它,哪怕别的构造函数看起来更"合身"。最经典的翻车现场是 vector<int>:
cpp
std::vector<int> v1(10, 0); // 圆括号:10 个 0(count + value 构造)
std::vector<int> v2{10, 0}; // 花括号:两个元素 10 和 0(initializer_list 构造!)
咱们对比着看:v1 是 10 个 0,v2 是 {10, 0} 两个元素------同一段意图,圆括号和花括号给出了完全不同的结果,就因为花括号优先匹配了 initializer_list<int> 构造。这不是 bug,是规则:花括号初始化在有 initializer_list 构造时优先它。所以构造容器时,(count, value) 和 {a, b} 别混用,意图不同就用不同括号。