C++ 的花括号有多狂?std::initializer_list 那些不讲武德的事儿

0 阅读15分钟

震惊!C++ 为了让我们能写std::vector<int> v = {1, 2};到底付出了什么代价?

我们先看一个场景:

std::vector<intv1(1020)// 大哥,这铁定是:10个元素,每个都是20。
std::vector<int> v2{1020}; // 大哥,这是:两个元素,10 和 20。

为什么 {} 这么霸道?

因为编译器看到花括号就疯了,会拼了老命去找一个参数是 std::initializer_list 的构造函数。

哪怕参数类型对不上,它宁愿报错也不愿意调别的构造函数。

这就像钓鱼佬平时钓不到几条鱼,但要是哪天让他钓上条大鱼。

嘿,那这几天就别想看到他回家,哪怕媳妇拿把刀架在他脖子上都没用。

这就是 initializer_list 劫持

基础语法与使用

1. 列表初始化

以前初始化个变量,那叫一个麻烦:int 用 =,struct 用 {},数组用 [],vector 得先构造再 push_back,累得跟狗似的。

现在好了,C++11 大手一挥:都给老子用大括号!

int a{5}; // 行!
std::string s{"hello"}; // 这也行!
std::vector<int> v{1,2,3};// 包行的!
struct Point { int x; int y; };
Point p{1020}; // 太行了!

这里得插一条小知识:{} 禁止窄化转换,这是它比 = 安全的地方。

int x = 3.14// 编译器睁一只眼闭一只眼,警告都懒得给,x变成3
int y{3.14}; // 编译器直接掀桌:ERROR,从 double 到 int 会丢数据

2. std::initializer_list 的基本操作

initializer_list 就三个操作:

  • 瞅一眼大小:lst.size()
  • 拿个开头指针:lst.begin()
  • 拿个结尾指针:lst.end()

因为它没有 operator[]。

所以我们想 lst[2] 是不行的。

想取第三个值?老老实实 *(lst.begin() + 2)。

为什么不让随机访问?

因为它本质只是对底层数组的两个指针(首、尾),C++标准委员会觉得给我们俩指针就够了,要啥自行车?

虽然它自己没 [],但因为它有 begin/end,所以我们可以:

void print(std::initializer_list<int> list) 
{
    // 范围for
    for (auto& elem : list) 
    {
        std::cout << elem << " ";
    }
}

因为 elem 的类型是 const 的,我们想改都改不了。

3. 接受 initializer_list 的函数

使用 initializer_list 能让我们自定义些花括号函数。

情景一:我要写个自定义求和

// 写法极其无脑
int sum(std::initializer_list<int> list) 
{
    int total = 0;
    for (auto x : list) total += x;
    return total;
}

// 调用:
sum({12345}); // 返回15

情景二:接受一推参数

std::string join(std::string delim, std::initializer_list<std::string> strs);
// 调用:join(", ", {"a", "b", "c"});

4. 构造函数中的 initializer_list

先看一段代码:

class MyVector 
{
public:
    // 默认构造
    MyVector() :_data(0)
    {
        std::cout << "调了默认构造函数" << std::endl;
    }

    // 构造函数1:老老实实建N个默认值
    MyVector(size_t n) : _data(n) 
    { 
        std::cout << "调了 size_t 构造函数" << std::endl; 
    }

    // 构造函数2:列表初始化
    MyVector(std::initializer_list<int> list) : _data(list) 
    { 
        std::cout << "调了 initializer_list 构造函数" << std::endl; 
    }
    
private:
    std::vector<int> _data;
};

灵魂拷问环节:

MyVector v1(5)// 输出:调了 size_t 构造函数。这没毛病
MyVector v2{ 5 }; // 输出:调了 initializer_list 构造函数。
MyVector v3{ 12 }; // 铁定是列表初始化。
MyVector v4{}; // 此时会调哪个?

以为 {} 一定调列表构造?错咯!

当 {} 为空时,它调用的是默认构造函数。

因为编译器一看:“哦,你想列表初始化,但列表里没东西啊?那算了,你回默认构造那儿排队去吧。”

当然了,只要花括号里有一丁点东西,哪怕只有一个,列表构造就是第一优先级。

内部机制与生命周期

1. initializer_list 的本质

先看看 initializer_list 的源码,依旧是简化版:

// 这玩意儿底层差不多就长这样(简化版)
template<class T>
class initializer_list 
{
public:
    initializer_list() :_first(nullptr), _last(nullptr) {}

    initializer_list(T* first_arg, T* last_arg) 
        : _first(first_arg), _last(last_arg) {}

    const T* begin() const noexcept return _first; }
    const T* end() const noexcept return _last; }
    size_t size() const noexcept return static_cast<size_t>(_first - _last); }

private:
    const T* _first; // 指向第一个元素的指针
    const T* _last; // 指向最后一个元素的指针
};

假设我们写了这行代码:

auto list = {12345};

编译器偷偷干的事儿:

initializer_list.drawio.png

initializer_list 不拥有数据,它只是数据的观察者。

2. 临时数组的生命周期

这是最危险、最反直觉、Bug 率最高的玩意。

情况 A:正常的局部使用(安全)

void func() 
{
    std::initializer_list<int> lst = {123};
    // 在这花括号里面,底层数组是活着的,放心用
    for (auto x : lst) std::cout << x;
} // 函数结束,底层数组和 lst 一起升天,完美

编译器保证:底层数组的生命周期完全等同于 initializer_list 对象本身的生命周期。

情况 B:返回给上级领导

std::initializer_list<intmake_list() 
{
    return {123};
}

int main() 
{
    auto list = make_list();
    std::cout << *list.begin(); // 大概率打印出 1,但这只是因为栈还没被覆盖
}

分析:

  • return {1,2,3}; 这句话会先在 make_list 的栈帧上创建临时数组。
  • 函数一返回,栈帧被销毁,那片内存就变成公共厕所,谁都能来踩一脚。
  • 我们还在 main 里拿着那个 list 使用,其实它指向的已经是垃圾数据。

initializer_list 并不拥有它指向的元素数组。

它只是一个轻量级的视图,内部通常仅包含指向数组首尾元素的指针或长度。

情况 C:基于范围的for循环

这是最隐蔽的,因为 for 语句里那个临时对象我们看不见。

// 错误示范:list 的生命周期只在这一行
for (auto x : std::initializer_list<int>{a, b, c}) 
{
    // 如果这里把 x 的地址存到全局变量,或者起个线程异步处理...
    // 循环体执行时 list 确实活着,但如果有延后操作,立刻完蛋。
}

看了三个栗子,真是江湖险恶,处处都是危险呐。

那么正确做法:

  • initializer_list 永远不要作为函数返回值。
  • 如果必须返回一堆值,请用 std::vector 或 std::array,别偷懒。
  • initializer_list 就是一个引用型参数,适合传参,不适合存储。

3. 拷贝与赋值

拷贝 initializer_list 不会拷贝底层数据。

std::initializer_list<int> lst1 = {123};
std::initializer_list<int> lst2 = lst1; // 这是拷贝

拷了什么玩意?

只拷贝了那俩指针和长度,底层数组纹丝不动,还是原来那个。

所以:

  • lst1.size() 和 lst2.size() 都是 3。
  • 修改底层数组?想都别想,因为元素是 const 的。
  • 那拷贝有什么用?就是多了一个指向同一块只读内存的观察者

因为拷贝只是浅拷贝,所以 initializer_list 的拷贝构造函数和赋值运算符都是 noexcept 的、极轻量级的。

所以我们在函数传参时,请大胆地按值传递。

4. 与 auto 的恩怨情仇

陷阱:

auto x = {123}; // x 是什么类型?

这很坑,因为大部分时候 auto 会推导出 int、double、std::string。

但唯独遇到 {},C++ 标准规定必须推导为 initializer_list。

安全建议:

auto x = 1// int
auto y = {1}; // std::initializer_list<int>
auto z{1}; // C++17 后是 int 了

所有有些地方能别用 auto 接花括号就别用吧。

想用列表初始化容器,老老实实写 std::vector<int> v = {1,2,3} 蒜鸟。

与标准库的集成

1. 标准库容器的 initializer_list 构造函数

在 C++98 中,我们想初始化一个 vector,得这么写:

std::vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);

或者稍微聪明点,用个数组:

int arr[] = {1,2,3};
std::vector<intv(arr, arr + sizeof(arr)/sizeof(int));

简直是又累又慢。

现在好了,从 C++11 开始,所有标准库容器(除了 std::array 有点特殊)都给构造函数加了一条:

std::vector<int> v = {12345};
std::list<std::string> lst = {"hello""world"};
std::map<std::string, int> m = {{"apple"1}, {"banana"2}};
std::set<double> s{3.142.7181.414};

我们看这个 = 后面的花括号,它就是通过 initializer_list 构造函数实现的。

看个伪代码:

template<typename T>
class vector 
{
public:
    vector(std::initializer_list<T> init) {
        _M_allocate_and_copy(init.begin(), init.end());
    }
    // ...
};

标准库容器内部就是把 initializer_list 的首尾指针传给内部的内存分配函数,做个深拷贝。

这里必须提一嘴 std::map 的操作:

std::map<int, std::string> m = {
    {1"one"},
    {2"two"}
};

花括号里套花括号,外层的 {} 是 initializer_list 构造,内层的 {} 是调用了 std::pair<const Key, T> 的构造函数。

2. 赋值运算符重载

我们先看个示例:

std::vector<int> v = {102030};
v = {100200};  // v 现在长啥样?

v 变成了 {100, 200},原来的 {10,20,30} 被彻底干掉了。

这背后发生了什么?

  1. 调用 v.operator=({100,200})。
  2. 这个重载函数内部会先 clear() 掉原有元素。
  3. 然后把 initializer_list 里的数据拷贝进来。

所以啊,千万别以为 v = {100, 200} 是往里面追加元素。

追加的活是 insert 和 push_back 干的,不是 operator= 的职责。

这哥们只负责替换

3. initializer_list 与 std::array

这是很多人容易犯晕的地方,我们列个表格对比一下:

对比维度std::initializer_liststd::array
我是谁?一个观察者,一个视图一个真容器,一个包装类
我占内存吗?只有俩指针+长度(栈上)直接在栈上存所有元素
我能改数据吗?不能,元素是 const能,有 operator[]
我有所有权吗?没有,全靠底层临时数组有,数据是我的私有财产
我能拷贝数据吗?拷贝只是指针别名拷贝是真拷贝,元素全复制一遍

直接上代码看看:

// initializer_list 不能这样用
std::initializer_list<int> il = {1,2,3};
// il[1] = 5;  // 编译错误

// array 可以
std::array<int, 3> arr = {1,2,3};
arr[1] = 5;     // 完美,arr 变成 {1,5,3}

它们的核心差异在于构造函数:

  • array 的初始化必须知道大小,且大小是模板参数的一部分。
  • initializer_list 的大小是运行时的,可以任意。

这时候爱发问的小明要问了:那 std::array 有 initializer_list 构造函数吗?

答案是没有!

因为 std::array 是一个聚合类,它的大小是编译期决定的。

它使用的是聚合初始化,而不是 initializer_list 构造。

自定义类型与 initializer_list

1. 构造函数重载优先级

虽然之前我们讨论过了,但这里还是要提一嘴:

  1. 如果有 initializer_list 构造函数,且花括号里的所有元素都能转换成 initializer_list 的元素类型,铁定调它。
  2. 如果元素类型转换失败,编译错误,不会退而求其次去找别的构造函数。
  3. 唯一的例外:空花括号 {}。因为实在没东西可转,它会去调用默认构造函数(如果没有默认构造,才可能考虑带默认参数的 initializer_list 构造)。

2. 多个 initializer_list 参数

假设我们要这样写:

class Foo 
{
public:
    Foo(std::initializer_list<int> a, std::initializer_list<double> b);
};

如果允许两个连续的 initializer_list 参数,那么:

Foo f{1234.56.7};

编译器会一脸懵逼:第一个 {} 到哪里结束?是 {1,2} 然后 {3,4.5,6.7}?还是 {1} 然后 {2,3,4.5,6.7}?

所有 initializer_list 参数最好出现在函数参数列表的最后一个位置(或者唯一位置)。

要是实在想这么写,那就加两花括号:

Foo f{ {123}, {4.56.7} };

让编译器知道要在哪结束。

3. 手写一个迷你 MyVector 类

我们来实现一个极简的动态数组,重点在于如何正确处理 initializer_list 构造函数,以及拷贝控制。

template<typename T>
class MyVector 
{
public:
    // 默认构造函数
    MyVector() : _data(nullptr), _size(0), _capacity(0) 
    {
        std::cout << "默认构造" << std::endl;
    }

    // 列表初始化构造函数
    MyVector(std::initializer_list<T> il) 
    {
        std::cout << "initializer_list 构造" << std::endl;
        _size = _capacity = il.size();
        _data = new T[_size];
        std::copy(il.begin(), il.end(), _data); // 深拷贝数据
    }

    // 拷贝构造函数
    MyVector(const MyVector& other) 
    {
        std::cout << "拷贝构造" << std::endl;
        _size = other._size;
        _capacity = other._capacity;
        _data = new T[_capacity];
        std::copy(other._data, other._data + _size, _data);
    }

    // 拷贝赋值运算符
    MyVector& operator=(const MyVector& other) 
    {
        std::cout << "拷贝赋值" << std::endl;
        if (this != &other) {
            delete[] _data;
            _size = other._size;
            _capacity = other._capacity;
            _data = new T[_capacity];
            std::copy(other._data, other._data + _size, _data);
        }
        return *this;
    }

    // initializer_list 赋值运算符
    MyVector& operator=(std::initializer_list<T> il) 
    {
        std::cout << "initializer_list 赋值" << std::endl;
        delete[] _data; // 释放旧内存
        _size = _capacity = il.size();
        _data = new T[_size];
        std::copy(il.begin(), il.end(), _data);
        return *this;
    }

    // 析构函数
    ~MyVector() 
    {
        delete[] _data;
    }

    // 一些辅助函数
    size_t size() const return _size; }
    const T* begin() const return _data; }
    const T* end() const return _data + _size; }

    void print() const 
    {
        std::cout << "[ ";
        for (size_t i = 0; i < _size; ++i) std::cout << _data[i] << " ";
        std::cout << "]" << std::endl;
    }

private:
    T* _data;
    size_t _size;
    size_t _capacity;
};

测试代码:

int main()
{
    MyVector<int> v1{ 123 }; // 调列表构造
    v1.print(); // [ 1 2 3 ]

    MyVector<int> v2 = { 4567 }; // 同样列表构造
    v2.print(); // [ 4 5 6 7 ]

    v2 = { 1020 }; // 调 initializer_list 赋值
    v2.print(); // [ 10 20 ],且旧数据被清除了

    // 拷贝
    MyVector<int> v4 = v1; // 拷贝构造
    v4.print(); // [ 1 2 3 ]
    v4 = v2; // 拷贝赋值
    v4.print(); // [ 10 20 ]

    return 0;
}

解释:

  • 深拷贝是必须的:在列表构造函数和赋值运算符中,我们没有直接保存传入的 initializer_list 的指针,而是用 new 分配了新内存并拷贝了数据。因为 initializer_list 底层数组是临时的,拷贝它必须深拷贝。
  • 先释放再分配:赋值运算符必须先释放旧内存,再根据新列表分配,否则会内存泄漏。
  • 异常安全? 我们这个简易版没考虑 new 失败的情况,实际上应该使用 RAII 和 try-catch 保证安全。

性能考量

1. 与 push_back 循环对比

既然 initializer_list 是轻量级观察者,那用它构造容器肯定比循环 push_back 快?

那么是真的快吗?

我们分两种情况来讨论:

情况一:vector<int> v = {1,2,3} vs push_back 循环

表面看:

  • 列表初始化:一行搞定,编译器直接分配恰好大小的内存。
  • push_back:可能触发多次扩容和拷贝。

实际情况:

// 场景1:列表初始化
std::vector<int> v1 = {12345};

// 场景2:循环 push_back
std::vector<int> v2;
v2.reserve(5); // 先预留空间
v2.push_back(1);
v2.push_back(2);
v2.push_back(3);
v2.push_back(4);
v2.push_back(5);

以 int 这种平凡类型为例:

  • 列表初始化:编译器在构造时一次性分配恰好5个 int 的内存,然后从临时数组拷贝过来。一次内存分配,5次 int 拷贝。
  • 带 reserve 的 push_back:一次内存分配,5次 int 拷贝。
  • 所以对于平凡类型,两者性能差不多,列表初始化的优势在于代码简洁。

但是,如果不带 reserve:

  • push_back 循环可能触发多次扩容,导致多次内存分配和元素移动,这时候列表初始化胜出。

情况二:拷贝成本很高的时候

这是真正的性能分水岭。

看这个例子:

class HeavyObject 
{
public:
    HeavyObject() { std::cout << "默认构造" << std::endl; }
    HeavyObject(const HeavyObject&) { std::cout << "拷贝构造(贵)" << std::endl; }
    HeavyObject(HeavyObject&&) noexcept { std::cout << "移动构造(便宜)" << std::endl; }
    // 假设它持有一大块内存或文件句柄
};

int main() 
{
    // 方式A:列表初始化
    std::vector<HeavyObject> v1 = { HeavyObject(), HeavyObject(), HeavyObject() };

    // 方式B:循环 emplace_back
    std::vector<HeavyObject> v2;
    v2.reserve(3);
    v2.emplace_back();
    v2.emplace_back();
    v2.emplace_back();
}

输出会是什么?

  • 方式A:临时对象被拷贝到 initializer_list 底层数组(一次拷贝),然后 vector 构造时再从底层数组拷贝到堆内存(第二次拷贝),一共两次拷贝。
  • 方式B:emplace_back 直接在堆上构造对象,零拷贝。

所以方式 A 会调用3次默认构造和3次拷贝构造,方式 B 只会调用3次默认构造。

如果 HeavyObject 禁止拷贝呢?

比如我们使用 std::unique_ptr:

std::vector<std::unique_ptr<int>> v = 
{
    std::make_unique<int>(1),
    std::make_unique<int>(2)
}; // 编译错误,因为 initializer_list 试图拷贝 unique_ptr(它不可拷贝)

initializer_list 强制要求元素可拷贝,因为它底层就是拷贝过去的。

2. 移动语义的支持:为什么 initializer_list 不能移动?

答案藏在设计里:

initializer_list 的迭代器返回的是 const T&,元素是常量。

我们不能把常量对象移动走,移动操作需要修改源对象(将其置于状态)。

std::initializer_list<std::string> il = {"hello""world"};
std::vector<std::string> v;
for (auto& s : il) 
{
    v.push_back(std::move(s)); // 看似移动,实则拷贝。因为 s 是 const string&
}

编译器会悄悄选择拷贝构造函数,因为 std::move 把 const string& 转成了 const string&&,而移动构造函数接受的是 string&&(非 const),不匹配。

这么大的问题为什么不修复呢?

  1. initializer_list 设计之初是为了方便轻量级、可拷贝的字面量。
  2. 如果允许移动,那么多次使用同一个 initializer_list 会导致内容被掏空,语义混乱。
  3. 编译器优化:临时数组可能放在只读段,移动修改会导致段错误。

当然了,如果我们有一堆临时对象想放进 vector,又不想多次拷贝,可以这样:

// 先放一个空 vector,然后用 insert 配合移动迭代器
std::vector<std::string> v;
v.reserve(3);
std::initializer_list<std::string> il = { "a""b""c" }; // 这里发生一次拷贝到临时数组
v.insert(v.end(),
    std::make_move_iterator(const_cast<std::string*>(il.begin())),
    std::make_move_iterator(const_cast<std::string*>(il.end())));

但是我不推荐这么用,const_cast 去掉常量性容易导致未定义行为。

所以我们还是老老实实 emplace_back 吧。

结尾

好了,我们就用3句话为这篇文章画上个完美的句号吧。

第一句:这玩意到底是个啥?

说白了,它就是个天天拿两根破签子在那举着的临时工。

两根破签子:一根指着头,一根指着尾。

我们要是让它搬个小板凳坐在那数人头,没有任何问题。

但让它去工地搬砖,哎呦,那可要老命了。

第二句:花括号会劫持我们的构造函数

编译器只要看见花括号,哪怕里面装的是屎,它也得先去找那个带 initializer_list 参数的坑位蹲一下。

第三句:免费的往往最贵

便利有价,拷贝收费。

小打小闹随便用,核心循环请清醒。

就3句话,说到做到( ◜◡‾),完~