现代C++⊂C++11篇(一)列表初始化全解 & std::initializer_list

目录

一、浅谈C++的发展史

二、列表初始化

[2.1 源自1998的老传统](#2.1 源自1998的老传统)

[2.2 C++11 真正的野心:用 {} 统一所有初始化](#2.2 C++11 真正的野心:用 {} 统一所有初始化)

[2.2.1 内置类型也能用上{}](#2.2.1 内置类型也能用上{})

[2.2.2 类类型:这才是列表初始化的真正用武之地](#2.2.2 类类型:这才是列表初始化的真正用武之地)

[2.2.3 连等号都能省,写起来更利落](#2.2.3 连等号都能省,写起来更利落)

[2.2.4 容器传参:{}的性价比直接拉满](#2.2.4 容器传参:{}的性价比直接拉满)

[2.2.5 <补充---了解即可> 窄化转换与编译期检查](#2.2.5 <补充---了解即可> 窄化转换与编译期检查)

[2.2.5.1 什么是窄化转换与窄化检查?](#2.2.5.1 什么是窄化转换与窄化检查?)

[2.2.5.2 几种最常见的窄化报错](#2.2.5.2 几种最常见的窄化报错)

三、C++11的幕后功臣:std::initializer_list

[3.1 到底什么是std::initializer_list?](#3.1 到底什么是std::initializer_list?)

[3.2 列表初始化的三种常见写法](#3.2 列表初始化的三种常见写法)

[3.3 迭代器与底层实现](#3.3 迭代器与底层实现)

[3.3.1 先搞懂它的本质](#3.3.1 先搞懂它的本质)

[3.2.2 用{}构造容器时,底层到底走了几步?](#3.2.2 用{}构造容器时,底层到底走了几步?)

[3.2.3 现代编译器的优化:理论归理论,实际更激进](#3.2.3 现代编译器的优化:理论归理论,实际更激进)


一、浅谈C++的发展史

C++11是C++的第二个主要版本,也是自C++98问世以来分量最重的一次更新。它带来的远不止几个新语法糖:既把业界早已普及的实践做法正式纳入标准,也大幅升级了C++的抽象表达能力,几乎让这门语言焕了新颜。

在2011年8月12日由ISO正式定稿发布之前,它一直有个临时代号叫"C++0x",当初立项时,所有人都乐观地以为它能在2010年之前落地,没想到一拖就拖到了2011年。从C++03到C++11,整整隔了8年,这也是迄今为止C++标准最长的一次版本空档期。

也正是吃过了慢迭代的亏,从那之后C++就定下了节奏,雷打不动每三年推出一次标准更新。

二、列表初始化

2.1 源自1998的老传统

很多人以为列表初始化是C++11凭空蹦出来的新特性,其实真不是。 早在上古时代的C++98里,数组和结构体就已经支持用{}做初始化了。而这套语法追根溯源,还是从C语言手里接过来的老传统,属于祖师爷传下来的写法。

写出来就是所有人都眼熟的样子:

cpp 复制代码
struct Point
{
    int _x;
    int _y;
};

int main()
{
    int array1[] = { 1, 2, 3, 4, 5 };
    int array2[5] = { 0 };
    Point p = { 1, 2 };
    return 0;
}

只不过那时候的花括号能力非常有限,只能伺候数组和普通结构体,稍微复杂点的类对象就完全用不了。谁也没料到,这个不起眼的小语法,会在十几年后的C++11里脱胎换骨,变成贯穿整门语言的核心特性。

2.2 C++11 真正的野心:用 {} 统一所有初始化

先抛结论:C++11推出列表初始化,打从一开始就奔着"大一统"去的。它想彻底终结C++乱七八糟的初始化规则,做到所有对象、所有类型,全都能用一对{}完成初始化,这也就是我们常说的列表初始化

不管是int、double这种内置类型,还是自己写的自定义类,全给你支持到位。对于自定义类型来说,这套写法本质上是先做隐式类型转换生成临时对象,再走拷贝构造;不过现在的编译器基本都会做优化,最后直接变成原地构造,省掉了拷贝开销。 另外还有个很顺手的小细节:用{}初始化的时候,前面的等号可以直接省略,写起来更利落。

当然它也不是光讲情怀,实际写代码确实能省不少事。最典型的就是往容器里push、insert元素的时候,不用专门构造临时对象,套一对{}就能直接生成,代码干净很多。

下面我们拆开细说。

2.2.1 内置类型也能用上{}

很多人第一次见内置类型用{}初始化都会觉得多此一举 ------int x1 = { 2 };,这不纯纯多写一笔吗?

话糙理不糙。给内置类型也加上这套语法,更多是为了和类类型的初始化规则做对称,凑齐 "统一初始化" 这个完整的故事。 标准既然允许这么写,编译器自然会在语法层面接纳这种形式。但你要是扒到底层看汇编就会发现,它和普通的 int x1 = 2; 生成的机器指令完全一模一样,没有任何额外开销,纯粹就是表层语法不一样而已。

2.2.2 类类型:这才是列表初始化的真正用武之地

光给内置类型套花括号多少有点形式主义,列表初始化真正的价值,还是体现在自定义类类型上。

我们先写一个最简单的Date类作为测试用例,后面的讲解都基于它展开:

cpp 复制代码
#include<iostream>
#include<vector>
using namespace std;

class Date{
public:
    Date(int year = 1, int month = 1, int day = 1)
        :_year(year)
        , _month(month)
        , _day(day)
    {cout << "Date(int year, int month, int day)" << endl;}

    Date(const Date& d)
        :_year(d._year)
        , _month(d._month)
        , _day(d._day)
    {cout << "Date(const Date& d)" << endl;}

private:
    int _year;
    int _month;
    int _day;
};

先看最基础的写法:

Date d1 = { 2025, 1, 1 };

很多人会误以为这是直接构造,其实底层是两步走的逻辑:先用花括号里的三个参数构造出一个 Date临时对象,再用这个临时对象去拷贝构造d1。 当然,现在主流编译器都会做优化,直接把这两步合二为一,最终效果等价于直接调用构造函数原地初始化d1,不会产生多余的拷贝开销。

再看一个新手很容易踩坑的写法:

const Date& d2 = { 2024, 7, 25 };

这里为什么必须加const?因为d2绑定的是花括号构造出来的临时对象。C++的规则里,非const左值引用不能绑定临时对象,少了这个const,编译直接报错。

这里顺带区分一下和C++98的边界。单参数的隐式类型转换不是什么新特性,C++98里就已经支持了:

Date d4 = 2025; // C++98原生支持,单参构造的隐式类型转换

但多参数的初始化,就必须等到C++11的列表初始化才能实现:

Date d3 = { 2025 }; // C++11新增,后续参数自动使用缺省值

本质就是第一个参数显式传入,剩下的参数走默认缺省值。

最后补一个很多人绕不过来的常识:全缺省的构造函数,本质上可以当成支持0到n个参数的构造函数来用。比如我们这个三参数全缺省的构造函数,既可以当无参构造,也可以当1参、2参、3参构造用,自然也能完美适配列表初始化的各种传参方式。

2.2.3 连等号都能省,写起来更利落

列表初始化还有个很顺手的细节:中间的=可以直接省略。 不管是内置类型、结构体还是自定义类,这套写法都通用,本质上还是走的隐式类型转换逻辑:

cpp 复制代码
Point p1 { 1, 2 };
int x2 { 2 };
Date d6 { 2024, 7, 25 };
const Date& d7 { 2024, 7, 25 };

当然别飘,只有带{}的列表初始化能省这个等号。你要是写成Date d8 2025;这种裸奔写法,编译器直接不认,属于纯纯的语法错误。

2.2.4 容器传参:{}的性价比直接拉满

要说列表初始化日常写代码最香的场景,还得是配合容器用。 我们拿往vector里塞Date对象举例子,常见有三种写法:

cpp 复制代码
vector<Date> v;
v.push_back(d1);                  // 传有名对象,会触发拷贝
v.push_back(Date(2025, 1, 1));    // 传匿名对象,构造+移动,效率高
v.push_back({ 2025, 1, 1 });      // 列表初始化,效果和匿名对象一致

第三种写法不用写类名,不用显式构造临时对象,一对花括号直接搞定。编译器优化过后,生成的代码和匿名对象版本完全没差,效率丝毫不减,但写起来短了一大截,代码干净度直接上一个台阶。

2.2.5 <补充---了解即可> 窄化转换与编译期检查
2.2.5.1 什么是窄化转换与窄化检查?

所谓窄化转换(Narrowing Conversion),说白了就是那种可能丢数据的类型转换:大整数塞给小类型、浮点数转整数、负数赋给无符号数,都属于这类。转完要么数值溢出,要么精度砍半。普通赋值的时候编译器只会默默给你转,连个警告都不一定有,出了bug巨难查。

而C++11的列表初始化,专门加了一道窄化检查:只要赋值过程中存在窄化风险,编译直接报错,从源头把这类问题掐死。这也是很多人忽略的,列表初始化自带的安全buff。

2.2.5.2 几种最常见的窄化报错
cpp 复制代码
short s1{ 50000 };     // 50000超出short的取值范围 → 编译错误
int i1{ 3.14 };        // 浮点数转整数会截断小数部分 → 编译错误
unsigned u1{ -1 };     // 负数无法正常表示为无符号数 → 编译错误

换成普通的等号赋值,这三行都能编译通过,但运行时全是暗坑。也正是因为这个,很多编码规范都会推荐优先用列表初始化,图的就是这份编译期的安全保障。

三、C++11的幕后功臣:std::initializer_list

3.1 到底什么是std::initializer_list?

很多人天天用列表初始化,却没注意到背后真正的核心角色------std::initializer_list

C++标准库给它的定义很精准:这是一个只读数组的轻量引用视图 。划重点,它本身不是容器,不存数据,只是个代理视图。接口也做得极其克制,迭代器、基础构造,寥寥几个,主打一个轻量无负担。

比如你随手写这么一行,编译器推导出来的类型就是它:

cpp 复制代码
auto il = { 10, 20, 30 }; // il的类型为std::initializer_list<int>

前面讲的单个对象、数组、结构体的列表初始化,已经够好用了。但放到容器身上,光靠那点语法就不够打了。 你想啊,要是没有它,一个vector想支持用N个值直接构造初始化,你得写多少个构造函数重载?2个参数、3个参数、4个参数......总不能无限往下写吧。

std::initializer_list就是来补这个窟窿的。只要容器实现了接收std::initializer_list的构造函数,它立刻就能支持任意长度的花括号初始化:

cpp 复制代码
vector<int> v1 = {1, 2, 3};
vector<int> v2 = {1, 2, 3, 4, 5};

塞多少个元素都行,灵活度直接拉满。说白了,STL里所有容器能支持{x1, x2, x3...}这种批量初始化,全都是靠std::initializer_list这个底层机制实现的。

3.2 列表初始化的三种常见写法

别看都是用花括号给容器初始化,写出来其实有三种常见形式,底层走的全都是std::initializer_list的构造函数,只是表面语法不一样而已。

cpp 复制代码
// 写法一:带等号的拷贝列表初始化,最符合直觉
vector<int> v1 = { 1,2,3,4 };
vector<int> v2 = { 10,20,30,1,1,1,1,1,1,1,1 };
const vector<int>& v4 = { 10,20,30,1,1,1,1,1,1,1,1 };

// 写法二:省略等号的直接列表初始化,写起来更利落
vector<int> v1{ 1,2,3,4 };
vector<int> v2{ 10,20,30,1,1,1,1,1,1,1,1 };
const vector<int>& v4 { 10,20,30,1,1,1,1,1,1,1,1 };

// 写法三:把花括号显式当作构造参数传入
vector<int> v3({ 10,20,30,1,1,1,1,1,1,1,1 });

前两种是日常写代码最常用的,编译器会自动把花括号里的内容打包成initializer_list临时对象。第三种相当于把这个转换过程明明白白写了出来,语法上完全等价,只是平时用得不多,一般在需要强调构造语义的场景才会见到。

3.3 迭代器与底层实现
3.3.1 先搞懂它的本质

这块说起来有点绕,我们先把最基础的概念掰碎了讲。

std::initializer_list本身真的非常轻量,它内部就存了两个原生指针一个指向数组起始位置,一个指向结束位置 。也正因为是指针结构,它天然支持迭代器遍历,begin()、end() 都能用,和普通容器的遍历方式没什么两样。

这里有个很多人绕不明白的核心问题:同样是一对{},为什么有时候它是在构造单个对象,有时候又会被当成一个initializer_list呢?

说白了,编译器怎么处理这对花括号,完全看目标类提供了什么构造函数:

如果类只有普通的多参构造函数 ,那{}就会被当成单个临时对象的参数列表 。只要类专门重载了接收std::initializer_list的构造函数 ,编译器就会优先匹配这个版本 ,把{}里的所有元素打包成一个initializer_list传进去

就拿vector举例子,它专门实现了initializer_list<T>版本的构造函数。所以你写vector<int> v = {1,2,3}的时候,编译器第一反应就是走initializer_list构造,而不是拿三个int去挨个构造元素。

3.2.2 用{}构造容器时,底层到底走了几步?

我们掰开揉碎了说,整个过程标准定义了两步逻辑,顺着捋一遍就很清楚了。

第一步:先把列表打包成initializer_list ,当编译器识别到花括号初始化的语义,第一件事是在 上开辟一块临时内存,把你写在{}里的所有元素挨个存进去,再生成一个std::initializer_list对象指向这块空间。

别把它想得多复杂,这个对象本身几乎没有重量,内部就存了首尾两个原生指针(迭代器的底层就是指针),相当于一个只读的轻量视图,本身不掌管内存的生命周期。真正的数据老老实实躺在栈上,它只负责 "看",不负责 "管"。

为什么在栈上?如图

第二步:隐式转换调用容器构造函数,打包完成后,就轮到容器的构造函数接棒了。之所以能完成这一步转换,核心是vector专门重载了接收initializer_list的构造函数:

cpp 复制代码
vector (initializer_list<value_type> il, const allocator_type& alloc = allocator_type());

打包好的initializer_list会作为单个参数,通过隐式类型转换传入构造函数。而这个构造函数的内部逻辑其实非常朴素,说白了就是遍历列表里的每一个元素,挨个push_back进容器里。

3.2.3 现代编译器的优化:理论归理论,实际更激进

当然,上面说的都是标准层面的"理论流程"。真落到现代编译器手里,根本不会这么死板地按部就班。 从C++11开始,拷贝消除(Copy Elision)就已经是标配优化项了。

像这种构造临时对象再用作参数的场景,编译器大概率会直接省掉中间层:

cpp 复制代码
std::vector<int> v {1, 2, 3, 4};

很多时候编译器会直接在vector内部的内存空间里把元素初始化到位,连完整的 initializer_list 临时对象都不会生成 。表面看是"打包+构造"两步,底层可能直接合并成一步完成。

不过话说回来,就算不做这个优化也没啥大不了的。 initializer_list本身就俩指针,轻得不能再轻,哪怕真生成临时对象,开销也几乎可以忽略不计。编译器的优化,无非是把本来就不大的开销再抹掉而已。 有意思的是,C++标准其实只规定了语法规则和最终语义,从来没强制要求编译器必须老老实实生成initializer_list对象。委员会定规矩,厂商自由发挥做优化,这也算是C++这么多年的老传统了。

相关推荐
阿米亚波1 小时前
【C++ STL】std::forward_list
开发语言·c++·笔记·stl·visual studio·forward_list
buhuizhiyuci1 小时前
【python篇——一周速通python语法】python的条件语句和循环语句
开发语言·python
geovindu1 小时前
go: Recursion Algorithm
开发语言·后端·算法·golang·递归算法
geovindu1 小时前
CSharp: Composite Pattern
开发语言·后端·c#·组合模式·结构型模式
消失的旧时光-19431 小时前
KMP Web 开发实战(二):js、wasmJs、browser、executable 到底是什么意思?
开发语言·前端·javascript·跨平台·kmp
十五年专注C++开发2 小时前
qobject_cast转换失败原因分析
c++·qt·dynamic_cast·qobject_cast
sycmancia2 小时前
Qt——线程的生命期问题
开发语言·qt
影寂ldy2 小时前
C# 多线程进阶知识点(线程优先级、多委托传参、线程锁、死锁)
开发语言·数据库·c#
国服第二切图仔2 小时前
09-搜索工具Grep与Glob
开发语言·javascript·ecmascript