C++:initializer_list 与 {} 初始化的本质
目录

前言
受限于篇幅和时间,暂时只能说明msvc环境下的初始化列表和__autoclassinit2,linux后续有时间再更新,而且没有说明更多的initializer_list的生命周期等内容。
再次强调本篇环境为MSVC编译器,不同的编译器环境会有差异。
用()和 { } 初始化有何不同?
你在编码的时候肯定会产生这样的疑问,如下面代码所示:
#include <iostream> #include <set> int main(void) { std::set<int> s1{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 }; std::set<int> s2(s1); std::set<int> s3{ s1 }; // 访问s2和s3 for (auto tem : s2) { std::cout << tem << " "; } std::cout << std::endl; for (auto tem : s3) { std::cout << tem << " "; } return 0; }结果输出是一样的:
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9可以明显看出,使用如下两个构造方法,构造出来的set集合的对象内容是一摸一样的:
std::set<int> s2 ( s1 ); std::set<int> s3 { s1 };一个是(),一个是 { },为何能初始化出来一样的容器内容?
首先()大家肯定知道,这个在类对象初始化的时候,放到类的对象名后面表示调用他的构造函数,如果传入一个同类型的参数的话,就表示调用他的拷贝构造函数。上述代码的:
std::set<int> s2 ( s1 );正是调用的拷贝构造函数,将s1的内容拷贝到s2中,那么s3的构造方法使用的{}似乎是初始化列表?
官方文档中关于 {} 初始化的蛛丝马迹
如果你经常翻阅C++的参考文档,那么你肯定会对一些容器类的琳琅满目的构造函数而感到震惊:一个类为什么要这么多初始化方式?原因也很简单,就是为了适合不同的编程场景。
那么如果你再细心一点就会发现一个东西,在set容器的构造函数中,你或许会发现存在着这样一个构造函数,其声明如下:
set ( initializer_list<value_type> il, const key_compare& comp = key_compare(), const allocator_type& alloc = allocator_type() );我们来看看这三个参数:
- il 是一个initializer_list<value_type>的对象,目的不纯,这里先卖个关子,因为它是本章的主角
- comp,是一个比较器,用来制定排序规则。
- alloc是一个内存池。
这三个参数中,comp和alloc都是具有默认值的,除去他们两个,就只剩下一个initializer_list的对象。他到底是个啥?
initializer_list
我们先来看看他的头文件:
// initializer_list standard header (core) // Copyright (c) Microsoft Corporation. // SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception #ifndef _INITIALIZER_LIST_ #define _INITIALIZER_LIST_ #include <yvals_core.h> #if _STL_COMPILER_PREPROCESSOR #include <cstddef> #pragma pack(push, _CRT_PACKING) #pragma warning(push, _STL_WARNING_LEVEL) #pragma warning(disable : _STL_DISABLED_WARNINGS) _STL_DISABLE_CLANG_WARNINGS #pragma push_macro("new") #undef new _STD_BEGIN _EXPORT_STD template <class _Elem> class initializer_list { public: using value_type = _Elem; using reference = const _Elem&; using const_reference = const _Elem&; using size_type = size_t; using iterator = const _Elem*; using const_iterator = const _Elem*; constexpr initializer_list() noexcept : _First(nullptr), _Last(nullptr) {} constexpr initializer_list(const _Elem* _First_arg, const _Elem* _Last_arg) noexcept : _First(_First_arg), _Last(_Last_arg) {} _NODISCARD constexpr const _Elem* begin() const noexcept { return _First; } _NODISCARD constexpr const _Elem* end() const noexcept { return _Last; } _NODISCARD constexpr size_t size() const noexcept { return static_cast<size_t>(_Last - _First); } private: const _Elem* _First; const _Elem* _Last; }; _EXPORT_STD template <class _Elem> _NODISCARD constexpr const _Elem* begin(initializer_list<_Elem> _Ilist) noexcept { return _Ilist.begin(); } _EXPORT_STD template <class _Elem> _NODISCARD constexpr const _Elem* end(initializer_list<_Elem> _Ilist) noexcept { return _Ilist.end(); } _STD_END #pragma pop_macro("new") _STL_RESTORE_CLANG_WARNINGS #pragma warning(pop) #pragma pack(pop) #endif // _STL_COMPILER_PREPROCESSOR #endif // _INITIALIZER_LIST_ 他的头文件内容并不多,可以看到它的核心属性有两个:
- _First
- _Last
看到这两个参数你会想到什么?对啦是不是链表啊,一个指向头,一个指向尾部。如果是顺序存储来实现的话,那么_First和_Last的用途就更明确了,就是用来表明你存储的数据的起始和结束位置。
然后提供了两个函数来获取这两个值,经过简化的代码如下:
template <typename T> class SimpleInitializerList { public: // 类型别名定义 using value_type = T; using reference = const T&; using const_reference = const T&; using size_type = size_t; using iterator = const T*; using const_iterator = const T*; // 默认构造函数 - 创建空的列表 constexpr SimpleInitializerList() noexcept : m_begin(nullptr), m_end(nullptr) {} // 内部使用的构造函数 - 用指针范围构造 constexpr SimpleInitializerList(const T* first, const T* last) noexcept : m_begin(first), m_end(last) {} // 获取起始迭代器 constexpr const T* begin() const noexcept { return m_begin; } // 获取结束迭代器 constexpr const T* end() const noexcept { return m_end; } // 获取元素个数 constexpr size_t size() const noexcept { return m_end - m_begin; } private: const T* m_begin; // 指向数组开始 const T* m_end; // 指向数组结束(最后一个元素的下一个位置) };好巧不巧的是,获取这两个首尾指针的函数叫做end和begin,是不是更熟悉了?没错这两个函数其实就是迭代器访问的语法糖的核心函数,一个容器只要提供了begin和end函数,就可以使用foreach的语法来遍历容器,foreach会在编译的时候,自动将foreach语法转变为迭代器循环访问。
我这里写了一个伪代码方便理解:
for (int x : 容器) { std::cout << x << " "; } // 编译器实际上会转换成类似这样的代码: { // 获取容器的 begin 和 end 迭代器 auto&& __range = vec; // 万能引用绑定到容器 auto __begin = __range.begin(); // 调用容器的begin() auto __end = __range.end(); // 调用容器的end() // 遍历 for (; __begin != __end; ++__begin) { std::cout << *__begin << " "; } }可以看到foreach语句,后面被转变为了迭代器访问。
我们来总结一下我们目前所掌握的关键信息:
- initializer_list是一个类
- 它有两个私有的指针变量,指向某个数据集的首部和尾部
- 提供了迭代器访问的方法
set<int>{1,2,3} 到底怎么初始化?
我们回到set容器使用 { } 来初始化的方法:
set<int> set{other_set}; set<int> set{1, 2, 3, 4};- 第一种方法是貌似跟拷贝构造函数等价的构造方法。
- 第二种又跟初始化列表类似。
咱们已经没有能找到的突破口了,只能请求汇编老祖了:
std::set<int> s1{ 1, 2, 3}; 00007FF7E26D6C1F mov edx,18h 00007FF7E26D6C24 lea rcx,[s1] 00007FF7E26D6C28 call std::set<int,std::less<int>,std::allocator<int> >::__autoclassinit2 (07FF7E26D1578h) 00007FF7E26D6C2D mov dword ptr [rbp+1A8h],1 00007FF7E26D6C37 mov dword ptr [rbp+1ACh],2 00007FF7E26D6C41 mov dword ptr [rbp+1B0h],3 00007FF7E26D6C4B lea rax,[rbp+1B4h] 00007FF7E26D6C52 mov r8,rax 00007FF7E26D6C55 lea rdx,[rbp+1A8h] 00007FF7E26D6C5C lea rcx,[rbp+128h] 00007FF7E26D6C63 call std::initializer_list<int>::initializer_list<int> (07FF7E26D10BEh) 00007FF7E26D6C68 lea rcx,[rbp+180h] 00007FF7E26D6C6F mov rdi,rcx 00007FF7E26D6C72 mov rsi,rax 00007FF7E26D6C75 mov ecx,10h 00007FF7E26D6C7A rep movs byte ptr [rdi],byte ptr [rsi] 00007FF7E26D6C7C lea rdx,[rbp+180h] 00007FF7E26D6C83 lea rcx,[s1] 00007FF7E26D6C87 call std::set<int,std::less<int>,std::allocator<int> >::set<int,std::less<int>,std::allocator<int> > (07FF7E26D172Bh) 00007FF7E26D6C8C nop 这是s1的初始化逻辑,看着很乱,但是可以提取出三个最显眼关键信息,那就是这里面存在三个call,所谓call就是函数调用,说明在初始化这个对象的时候调用了三个函数,分别如下:
std::set<int,std::less<int>,std::allocator<int> >::__autoclassinit2 (07FF7E26D1578h) std::initializer_list<int>::initializer_list<int> (07FF7E26D10BEh) std::set<int,std::less<int>,std::allocator<int> >::set<int,std::less<int>,std::allocator<int> > (07FF7E26D172Bh) 除去类域和stl命名空间就会清晰许多:
set ::__autoclassinit2 initializer_list ::initializer_list<int> set ::set<int,std::less<int>,std::allocator<int> > (这里为了好看,我还是保留了原始的类域,以便区分是谁的函数。)
可以看到set并没有匆忙调用自己的构造函数,而是先调用了一个叫做__autoclassinit2的函数,看名字差不多就能知道,这是一个自动类初始化的函数,只不过后面的2和这个函数是什么意思还不得而知。
然后由initializer_list进行了它自己的构造函数,然后紧接着调用了set的构造函数。
但是信息就止步于此,得不出更多的结论了,或许是因为我过度关注于函数,忽略了某些细节?那我们再回过头去看看之前的那段汇编,有没有更多细节。
找到关键之处!!
00007FF7E26D6C2D mov dword ptr [rbp+1A8h],1 00007FF7E26D6C37 mov dword ptr [rbp+1ACh],2 00007FF7E26D6C41 mov dword ptr [rbp+1B0h],3 这段代码都是rbp+某个值,仔细观察发现,后面几个值:1A8h、1ACh、1B0h是三个连续的值,他们三个中间的差值为4,也就是四字节,刚好对应一个int类型的大小,这说明这三个数其实是存储在一个连续的空间的。
!!!!提到连续空间,你想到了什么?没错,还几天我们前面在提到的那两个指针么?可以合理怀疑编译器就是用这一块连续的空间去初始化inlitializer_list这个对象。
如何验证我们的猜想?
思路:学过编程和考过408的都知道,在调用一个函数之前需要用寄存器传参,如果在调用initializer_list函数的call指令前面有寄存器相关的mov操作,而且操作数正是前面的:rbp+1A8hrbp+1B0h (有的结束地址是以有效元素的下一个地址作为结束地址,也就是可能为rbp+1B4h)
这两个参数。
结合上面的思路,我们先查看initializer_list的构造函数声明:
constexpr initializer_list ( const _Elem* _First_arg, const _Elem* _Last_arg ) noexcept : _First(_First_arg), _Last(_Last_arg) { // 这类是空的,仅仅使用了初始化列表。 }可以看到了两个参数,结合汇编来看:
00007FF7E26D6C2D mov dword ptr [rbp+1A8h],1 00007FF7E26D6C37 mov dword ptr [rbp+1ACh],2 00007FF7E26D6C41 mov dword ptr [rbp+1B0h],3 00007FF7E26D6C4B lea rax,[rbp+1B4h] 00007FF7E26D6C52 mov r8,rax 00007FF7E26D6C55 lea rdx,[rbp+1A8h] 00007FF7E26D6C5C lea rcx,[rbp+128h] 00007FF7E26D6C63 call std::initializer_list<int>::initializer_list<int> 可以看到:
- rax中存储了我们上述猜想的rbp+1B4h的地址,这显然是一个结束地址,然后将这个地址从rax转入r8, 这里为何转入需存疑。
- rdx中显然存储了这串连续空间的起始地址。
- rcx中rbp+128h是什么?这个地址显然比上面的起始地址小,根据栈的特性,rbp一般是当前栈帧的基址,地址空间是从高地址向低地址消耗,那么显然这个rcx的地址相较于rdx是低地址,因此可以得出结论是:rcx中存储了一个地址,这个地址比这块连续空间更迟生成。
根据分析,rcx显然符合this指针的特征,而且即使是构造函数,也会显式传递this指针,因此这个rpb+128h断定为initializer_list的对象地址,但是并没有完成初始化,仅仅只占据了空间。
根据这一小节的内容可以得知,set使用{ 1,2,3 }来初始化的时候是先构建了一个临时的连续空间,也可以成为临时的数组,然后用这个数组的起始和结束地址去初始化initialzer_list对象。
结合上面的set构造函数的声明:
set ( initializer_list<value_type> il, const key_compare& comp = key_compare(), const allocator_type& alloc = allocator_type() );接着看后面的逻辑:
1 00007FF7E26D6C68 lea rcx,[rbp+180h] 2 00007FF7E26D6C6F mov rdi,rcx 3 00007FF7E26D6C72 mov rsi,rax 4 00007FF7E26D6C75 mov ecx,10h 5 00007FF7E26D6C7A rep movs byte ptr [rdi],byte ptr [rsi] 6 00007FF7E26D6C7C lea rdx,[rbp+180h] 7 00007FF7E26D6C83 lea rcx,[s1] 8 00007FF7E26D6C87 call std::set<int,std::less<int>,std::allocator<int> >::set<int,std::less<int>,std::allocator<int> > (07FF7E26D172Bh) 这段汇编已经简单的不能再简单了,我们直接讲解:
- 第七行rcx, [s1],这个目的太显然了,它是将s1的地址存入rcx,也就是所谓的this指针传递。
- this指针的传递在rcx这有些反直觉,因为编译器是从右往左存储函数声明中的参数到寄存器的,这里的this指针相当于是函数声明中最左边的参数,而且是隐式的(这里的隐式指的是寄汇编代码级别,源代码中是看不到的,并非真的不存在)。
- 除了this指针,还剩下三个参数,分别是il,comp,和alloc。
- 第六行的rdx中必然存储的il的地址,但是为何这里是+180h,我们不是说il是rbp+128吗?
针对128h编程180h这个突兀的转变,必须从第一行汇编开始解析。
| lea rcx,[rbp+180h] | 这个指令是计算出rbp+180h的地址,然后存入rcx |
| mov rdi,rcx | 将rcx中的值再复制到rdi |
| mov rsi,rax | 将rax中的值传递给了rsi |
这里的rax不是前面的连续数组的结束地址吗?为何传给rsi?其实你错了,我们看一下initializer_list构造函数的汇编:
可以看到,在弹出栈帧和返回之前,将this指针传递给了rax,所以rax是this指针。
| mov rsi,rax | 更正:将li的地址传递给rsi |
| mov ecx,10h | 将立即数10h(16)传递给ecx |
| rep movs byte ptr [rdi],byte ptr [rsi] | 这条指令是在执行一个16字节的内存块复制,将数据从 rsi 指向的位置复制到 rbp+180h(之前保存的对象地址)。这是编译器生成的对象初始化或复制操作的典型模式。 |
到此任督二脉全部打通,rdx最终存储了il的对象地址。但是这里并没有看见传递后面两个默认参数,一般来说应该是被vs优化了。
然后set就用构建的initializer_list对象进行了初始化。
至于说用这个initializer_list怎么初始化,那么就显而易见了,我个人猜测会通过迭代器方法进行构建。
总结:
set<int> set{1,2,3}这样的初始化方法其原理就是通过构建一个汇编级别的连续地址空间存放1,2,3,然后取首尾指针初始化initializer_list对象,然后利用set的带有initializer_list形参的构造函数进行初始化。
弄懂了这个再来看看set{other_set}
set<int> set{other_set} 初始化?
那么他也是触类旁通了,直接请汇编老祖:
std::set<int> s2{ s1 }; 00007FF7E26D6C8D mov edx,18h 00007FF7E26D6C92 lea rcx,[s2] 00007FF7E26D6C96 call std::set<int,std::less<int>,std::allocator<int> >::__autoclassinit2 (07FF7E26D1578h) 00007FF7E26D6C9B lea rdx,[s1] 00007FF7E26D6C9F lea rcx,[s2] 00007FF7E26D6CA3 call std::set<int,std::less<int>,std::allocator<int> >::set<int,std::less<int>,std::allocator<int> > (07FF7E26D1442h) 00007FF7E26D6CA8 nop 请读者自行分析,锻炼自己。
这里还是用了一个autoclassinit,到现在还不知道是用来干嘛的,我们直接看这个构造函数。
明显是将s1和s2所占据的内存地址给到寄存器作为参数,这显然符合拷贝构造函数的特征。
也就是说他直接就是调用了拷贝构造函数,跟直接使用()拷贝构造函数初始化逻辑相同。
我们通过汇编代码后面给出的函数指针来跳转,发现确实是拷贝构造函数:
set(const set& _Right) : _Mybase(_Right, _Alnode_traits::select_on_container_copy_construction(_Right._Getal())) {}所以使用{other_set}的方法初始化等价于拷贝构造函数。
_autoclassinit2是个什么玩意?
它的作用非常简单,就是给对象的内存做 “初始化清零” 和 “调试标记”,方便你在 Debug 时发现问题:
void __autoclassinit2(set* this_ptr, size_t size) { // 1. 把对象的内存全部填充为 0xCC(MSVC Debug 模式的“未初始化内存”标记) memset(this_ptr, 0xCC, size); // 2. 可能还会设置一些调试用的“对象状态标记” // ... }为什么要填 0xCC?
- 如果你在 Debug 模式下不小心访问了未初始化的成员变量,会看到 0xCCCCCCCC 这样的值,一眼就能知道 “哦,我忘初始化了”
- Release 模式下,这个函数会被完全优化掉,内存不会被填 0xCC,直接构造对象
{ }初始化真正的作用
{ }到底是用拷贝构造,还是构建initializer_list对象来构造,完全在编译期就决定了的,和运行期的任何函数(包括 __autoclassinit2)都无关!由编译器来严格匹配。
你可以使用{}来智能匹配各种构造函数,这是C++提供的一个统一初始化操作,能调用任何构造函数,我们来一一举例看看。
- 拷贝构造函数:
这就跟我们前面提到的set<int> set(other_set)一样了:
#include <iostream> #include <set> int main(void) { std::set<int> s1{ 1, 2, 3}; std::set<int> s2{ s1 }; // 匹配拷贝构造函数 return 0; }编译器识别到s1是一个同类型的对象,就匹配拷贝构造函数的逻辑。
- 单参数普通构造函数
创建一个类,它有接受一个参数的构造函数,我们调用它:
#include <iostream> #include <string> class Person { public: std::string name_; Person(const std::string& name) : name_(name) {} }; int main(void) { Person person{ "libo" }; // 匹配单参数构造函数 std::cout << person.name_ << std::endl; return 0; }输出: libo
- 匹配无参构造函数
#include <iostream> #include <string> class Person { public: std::string name_; Person() { std::cout << "Person()" << std::endl; } }; int main(void) { Person person{}; return 0; }输出:Person()
- 匹配多参数构造函数
#include <iostream> #include <string> class Person { public: std::string name_; int age_; Person(const std::string name, int age) : name_(name), age_(age) { std::cout << "age: " << age_ << std::endl; std::cout << "name: " << name_ << std::endl; } }; int main(void) { Person person{"libo", 18}; return 0; }输出:
age: 18 name: libo- 匹配initializer_list
#include <iostream> #include <string> #include <initializer_list> class Person { public: std::string name_; int age_; Person(std::initializer_list<int> list) { // 仅仅打印list for (auto tem : list) { std::cout << tem << " "; } } }; int main(void) { Person person{1, 2, 3, 4}; return 0; }输出:
1 2 3 4
注意,使用initializer_list需要引入头文件<initializer_list>, 并且他也属于std命名空间。
且{}优先匹配initializer_list对象构造,而非普通参数构造:
#include <iostream> #include <string> #include <initializer_list> class Person { public: std::string name_; int age_; Person(std::initializer_list<int> list) { std::cout << "list"; } Person(int age) { std::cout << "age"; } }; int main(void) { Person person{1}; return 0; }输出list;
因此读到这里就可以回答第一个大标题的问题了:()是拷贝构造初始化,而{}是基于传参去匹配对应的构造函数的初始化。
回首我们经常所说的使用初始化列表构建参数,例如:
Person person1{"libo", 18, 200}; // libo,18岁,200斤其实本身和使用()来构建无异:
Person person1("libo", 18, 200); // libo,18岁,200斤如下代码来验证这个说法:
#include <iostream> #include <string> #include <initializer_list> class Person { public: std::string name_; int age_; Person(const std::string name, int age) : name_(name), age_(age) { std::cout << name_ << ":" << age_ << std::endl;; } }; int main(void) { // 两者都使用了初始化列表 Person p1{ "libo", 18 }; Person p2("cow", 20); return 0; }如下:
libo:18 cow:20这其实就是两阶段重载决议的规则,也就是说当你用 {} 初始化时,编译器找构造函数分两步走:
- 第一步:优先找 initializer_list 构造函数
- 第二步:找不到,才去匹配普通构造函数
编译器会先强行匹配initializer_list构造,匹配不上才匹配普通构造函数。
{}初始化禁止窄化转换
只要谈论到构造函数,就逃不过传参数,只要涉及到整形和浮点数相关的传参,那么就必定逃不过隐式转换带来的类型提升和精度丢失的风险:
- int -> double,类型提升
- double -> int,精度丢失
那什么叫窄化?
其实就是:
- 大类型 → 小类型
- 浮点数 → 整数
- 有符号 ↔ 无符号
- 会丢数据的转换
其实就是上面给出的两个风险的抽象性描述,整体来看就是从一个大的类,转变为小的类。就好比我本来是用大桶来装水,现在换小桶,必然会损失一部分水。而窄化是特别指定的{}初始化的场景,下面我给出几个例子:
我们构建一个类,他们具有上述所说的几个“小桶”类型:
- short -> int
#include <iostream> #include <string> class BadExample { public: short x_; int y_; unsigned z_; BadExample(short x): x_(x) {} //BadExample(int y) : y_(y) {} //BadExample(unsigned z) : z_(z) {} }; int main(void) { int x = 1; BadExample bad1{ x }; return 0; }输出错误提示:
C2398 元素“1”: 从“int”转换为“short”需要收缩转换 - double -> int
#include <iostream> #include <string> class BadExample { public: short x_; int y_; unsigned z_; //BadExample(short x): x_(x) {} BadExample(int y) : y_(y) {} //BadExample(unsigned z) : z_(z) {} }; int main(void) { double x = 1.0; BadExample bad1{ x }; return 0; }输出:
C2398 元素“1”: 从“double”转换为“int”需要收缩转换 - int -> unsigned int
#include <iostream> #include <string> class BadExample { public: short x_; int y_; unsigned z_; //BadExample(short x): x_(x) {} //BadExample(int y) : y_(y) {} BadExample(unsigned z) : z_(z) {} }; int main(void) { int x = 1; BadExample bad1{ x }; return 0; }输出:
C2398 元素“1”: 从“int”转换为“unsigned int”需要收缩转换 test 窄化转换可能发生在任何需要将一种类型隐式转换为范围更小或精度更低的类型时。C++11的列表初始化提供了防止意外窄化的保护机制。
给读者的最后建议
希望这篇从语法到汇编的深挖,能帮你彻底理解 {} 初始化的来龙去脉。下次再写 std::set<int> s{1,2,3}; 时,你脑海里浮现的不再只是一行代码,而是:
- 编译器在编译期按 “两阶段规则” 选好构造函数
- 运行期在栈上构造临时数组 {1,2,3}
- 用两个指针构造 initializer_list
- 按 x64 约定传递 this 和参数
- 最后调用 set 的初始化列表构造函数插入元素
这就是 “知其然,更知其所以然” 的乐趣 —— 你不再只是 C++ 语法的 “使用者”,更是它底层逻辑的 “理解者”。
全文完。 如果你对 C++ 其他底层特性(比如移动语义、模板推导、内存模型)也感兴趣,欢迎继续探索!
