列表初始化与 std::initializer_list
一、列表初始化(List Initialization)
1. 基本概念
C++11 引入了列表初始化,允许使用花括号 {} 初始化变量或对象,使不同类型的初始化语法更加统一。
int a{10}; // 直接列表初始化
int b = {10}; // 拷贝列表初始化
int arr[]{1, 2, 3}; // 数组初始化
vector<int> v{10, 20, 30}; // 容器初始化
列表初始化分为两种:
-
直接列表初始化 :
T obj{...}; -
拷贝列表初始化 :
T obj = {...};
注意:拷贝列表初始化不代表一定会发生对象拷贝。
2. 列表初始化的特点
(1)统一初始化语法
基本类型、数组、类对象、容器等都可以使用 {} 初始化。
(2)禁止窄化转换(Narrowing Conversion)
double d = 3.14;
int a = d; // 允许,小数部分丢失
int b{d}; // 编译错误,禁止窄化转换
列表初始化可以避免某些隐式类型转换导致的数据丢失。
二、std::initializer_list
1. 基本概念
std::initializer_list 是 C++11 引入的类模板,用于访问一组相同类型的初始化元素。
auto il = {10, 20, 30};
编译器推导出:
std::initializer_list<int>
2. 底层原理
执行:
auto il = {10, 20, 30};
可以理解为以下过程:
-
编译器创建一个隐藏的常量数组,存储
10、20、30。 -
创建一个
initializer_list<int>对象,关联这个数组。 -
通过
initializer_list访问数组中的元素。
其底层可以抽象理解为:
template<class T>
class initializer_list {
private:
const T* begin_ptr;
const T* end_ptr;
};
其中:
-
begin_ptr:指向数组第一个元素。 -
end_ptr:指向数组最后一个元素的下一个位置。
注意:这只是示意实现,标准并未强制要求使用两个指针,有些实现使用指针和元素数量。
3. 主要特点
(1)不拥有底层数组
initializer_list 本身只是一个轻量级的访问对象,不负责分配或释放数组内存。
(2)元素只读
auto il = {10, 20, 30};
cout << *il.begin(); // 10
cout << il.size(); // 3
*il.begin() = 100; // 错误,元素不可修改
底层数组元素是 const 的。
(3)复制 initializer_list 不复制底层元素
auto il1 = {10, 20, 30};
auto il2 = il1;
il1 和 il2 访问同一个底层数组,不会重新复制数组元素。
(4)底层数组具有特殊的生命周期规则
auto il = {10, 20, 30};
这里隐藏数组的生命周期会延长至 il 的生命周期结束。
但复制 initializer_list 不会进一步延长数组的生命周期,需要注意悬空引用问题。
三、列表初始化与 initializer_list 的关系
列表初始化是一种语法,initializer_list 是一种类型,两者不是同一个概念。
例如:
int a{10};
使用了列表初始化,但并没有创建 initializer_list 对象。
auto il = {10, 20, 30};
同样使用列表初始化,但这里 auto 推导得到 initializer_list<int>。
所以,并不是所有 {} 初始化都需要经过 initializer_list。
四、initializer_list 与构造函数
1. 为什么说 initializer_list 是桥梁?
例如:
vector<int> v{10, 20, 30};
vector 提供了接收 initializer_list 的构造函数:
vector(initializer_list<int> il);
初始化过程:
-
编译器根据
{10, 20, 30}创建隐藏数组。 -
创建
initializer_list<int>访问该数组。 -
将
initializer_list传递给vector的构造函数。 -
vector将数据复制到自己管理的存储空间。
因此,initializer_list 相当于连接花括号中的初始化数据和容器构造函数的桥梁。
2. 圆括号初始化与花括号初始化
vector<int> v1(10, 20);
vector<int> v2{10, 20};
二者结果不同:
| 对比 | v1(10, 20) |
v2{10, 20} |
|---|---|---|
| 初始化方式 | 圆括号初始化 | 列表初始化 |
| 构造函数 | vector(count, value) |
vector(initializer_list) |
| 元素数量 | 10 个 | 2 个 |
| 元素内容 | 全部为 20 | 10、20 |
原因:
-
()使用普通构造函数的重载匹配规则。 -
{}使用列表初始化规则,优先考虑可行的initializer_list构造函数。
3. initializer_list 构造函数的优先级
class Test {
public:
Test(int a, int b) {
cout << "普通构造" << endl;
}
Test(initializer_list<int> il) {
cout << "initializer_list构造" << endl;
}
};
Test t1(10, 20); // 普通构造
Test t2{10, 20}; // initializer_list构造
对于类类型的列表初始化,编译器会优先尝试 initializer_list 构造函数。
如果不存在可行的 initializer_list 构造函数,才会进一步考虑其他构造函数。
注意:如果 initializer_list 构造函数能够匹配,但涉及不允许的窄化转换,程序可能直接编译失败,而不是自动选择普通构造函数。
五、核心总结
-
列表初始化 :C++11 使用
{}初始化变量或对象的语法,能够统一初始化形式,并禁止窄化转换。 -
std::initializer_list:C++11 提供的类模板,底层关联一个隐藏的常量数组,本身不拥有数据,提供只读访问。
-
两者的关系 :列表初始化是语法,
initializer_list是类型。使用{}不代表一定会创建initializer_list。 -
构造函数优先级 :使用
{}初始化类对象时,会优先考虑可行的initializer_list构造函数。 -
vector 的区别:
-
vector<int> v(10, 20):创建 10 个值为 20 的元素。 -
vector<int> v{10, 20}:创建两个元素,分别是 10 和 20。
-
一句话记忆:
{} 提供列表初始化语法,initializer_list 负责向支持它的构造函数提供一组只读元素,而真正的容器负责管理自己的数据。
左值和右值的概念
一、左值(lvalue)和右值(rvalue)
在 C++ 中,表达式可以根据其值类别分为左值、右值等。
1. 左值(lvalue)
左值通常表示一个具有明确身份、可以定位的对象。
简单理解:左值通常有名字、有地址,可以被持续访问。
int a = 10;
int b = a;
其中:
a是左值。b是左值。10是右值。
一般可以通过取地址操作符 & 获取普通对象左值的地址:
int a = 10;
cout << &a; // 合法
2. 右值(rvalue)
右值通常表示临时值或计算结果,不具有可以持续使用的对象身份。
int a = 10;
int b = 20;
int c = a + b;
其中:
a、b、c都是左值。10、20是右值。a + b的计算结果是右值。
例如:
cout << &(a + b); // 错误
因为 a + b 的结果是右值,不能直接对其使用内置取地址运算符。
二、左值和右值的根本区别
根本区别不是有没有名字、能不能取地址,而是表达式是否具有可识别的对象身份。
| 对比 | 左值 | 右值(常见情况) |
|---|---|---|
| 本质 | 表示有身份的对象或函数 | 表示临时值或可移动资源 |
| 能否取地址 | 通常可以 | 通常不能直接取地址 |
| 能否赋值 | 非 const 且可修改时通常可以 | 内置类型的右值通常不可以 |
| 例子 | a、*p、arr[0] |
10、a+b、a++ |
注意:右值不一定没有内存地址,也不一定没有名字对应的对象。能否取地址只是一种辅助判断方法。
三、为什么 C++11 要区分左值和右值?
主要是为了支持移动语义和右值引用,避免不必要的数据复制。
例如:
string s1 = "hello";
string s2 = s1; // 拷贝
string s3 = std::move(s1); // 移动
-
s1是左值,可以反复使用,因此一般需要保留它的数据。 -
std::move(s1)是右值(准确说是将亡值 xvalue),使移动构造函数有机会接管s1的资源。
这里 std::move 本身并不移动数据,它只是将表达式转换为适合移动操作的值类别。
四、C++11 还进一步细分了值类别

-
lvalue(左值):例如变量
a。 -
prvalue(纯右值):例如字面量
10、计算结果a + b。 -
xvalue(将亡值):例如
std::move(a),通常可以用于资源移动。
其中,右值包括 prvalue 和 xvalue。
五、什么是将亡值(xvalue)?
将亡值可以理解为:仍然具有对象身份,但其资源允许被移动的表达式。
例如:
int a = 10;
std::move(a); // 将亡值
这里:
-
a是左值,因为它表示一个可以识别身份的对象。 -
std::move(a)是将亡值,因为它将a转换成了可以绑定到右值引用的表达式。
注意,std::move(a) 并没有真正移动 a,只是进行了类型转换。
将亡值属于右值的一种:
右值(rvalue)
├── 纯右值(prvalue):10、a + b
└── 将亡值(xvalue):std::move(a)
将亡值并不表示对象马上就会销毁,而是表示它的资源可以被移动。
六、什么是右值引用?
右值引用是 C++11 新增的引用类型,使用 && 表示。
int&& rr1 = 10;
这里:
-
10是纯右值。 -
int&&是右值引用类型。 -
rr1绑定到由10初始化的临时对象上。
这个临时对象的生命周期会因直接绑定到 rr1 而延长。
例如:
int&& rr1 = 10;
rr1 = 20;
cout << rr1; // 20
虽然字面量 10 是右值,但通过 rr1 可以修改它所绑定的临时对象。
那为什么下面的写法不行?
int x = 10;
int& r1 = x; // 正确:左值引用绑定左值
int&& rr1 = x; // 错误:右值引用不能直接绑定左值
因为 x 是左值。
但是:
int&& rr1 = std::move(x); // 正确
std::move(x) 是将亡值,属于右值,因此能绑定到右值引用。
而且 rr1 与 x 引用的是同一个对象,并没有创建新对象。
七、一个非常重要的细节:右值引用变量本身是左值
看下面的代码:
int&& rr1 = 10;
int& r2 = rr1; // 正确
int&& r3 = rr1; // 错误
为什么?
因为 rr1 的类型虽然是 int&&,但表达式 rr1 是左值。
这是因为 rr1 是一个有名字的变量,表达式 rr1 具有可识别的对象身份。
如果想把它再次作为右值使用,需要:
int&& r3 = std::move(rr1); // 正确
注意区分:
| 概念 | 示例 | 含义 |
|---|---|---|
| 左值引用类型 | int& r1 |
可以绑定合适的左值 |
| 右值引用类型 | int&& rr1 |
可以绑定合适的右值 |
| 左值表达式 | rr1 |
有名字的引用变量表达式是左值 |
| 将亡值表达式 | std::move(rr1) |
可用于绑定右值引用 |
左值引用、右值引用与移动语义
一、左值引用的主要使用场景
左值引用(T&)本质上是给已有对象起别名,不会因为引用本身而创建一个新的对象。
主要应用于函数传参和函数返回值。
1. 左值引用传参
作用:减少对象拷贝,并且可以修改实参。
普通传值:
void func(string s) {
cout << s << endl;
}
调用 func(str) 时,需要使用实参初始化形参,可能发生拷贝构造。
使用左值引用:
void func(string& s) {
s += " world";
}
string str = "hello";
func(str);
cout << str; // hello world
此时 s 是 str 的别名,不需要拷贝整个对象,还可以直接修改实参。
如果只需要读取,不需要修改,推荐使用常量左值引用:
void func(const string& s) {
cout << s << endl;
}
const T& 可以避免拷贝,也能绑定左值和右值。
2. 左值引用作为返回值
作用:避免复制已有对象,并允许调用者修改原对象。
int& getElement(vector<int>& v, int index) {
return v[index];
}
vector<int> v{10, 20, 30};
getElement(v, 1) = 100;
cout << v[1]; // 100
函数返回的是容器内部元素的引用,而不是元素的副本。
注意:返回引用要求被引用的对象在函数返回后仍然存活。
错误示例:
int& func() {
int a = 10;
return a; // 错误:返回局部变量的引用
}
函数结束后,局部变量 a 的生命周期结束,返回的引用成为悬空引用,使用它会导致未定义行为。
二、左值引用的局限性
左值引用虽然能够解决很多拷贝效率问题,但不能安全地返回函数内部新创建的局部对象的引用。
1. addStrings:拼接字符串
string addStrings(const string& a, const string& b) {
string result = a + b;
return result;
}
这里的 result 是函数内部创建的局部对象。
不能为了避免拷贝而改成:
string& addStrings(const string& a, const string& b) {
string result = a + b;
return result; // 错误:悬空引用
}
因为 result 在函数结束后生命周期终止。
2. generate:生成新的对象
vector<int> generate(int n) {
vector<int> v;
for (int i = 0; i < n; ++i) {
v.push_back(i);
}
return v;
}
同样不能返回 vector<int>&,因为 v 是局部对象。
这类函数的共同特点:在函数内部创建新的对象,并将其作为结果交给调用者。
3. 右值引用返回也不能解决生命周期问题
string&& func() {
string s = "hello";
return std::move(s); // 错误
}
即使使用右值引用,局部变量依然会在函数结束后被销毁。
std::move 不会延长对象的生命周期。
三、输出型参数
1. 基本概念
输出型参数(Output Parameter)就是:
不通过函数返回值传递结果,而是由调用者提前创建对象,再通过引用参数让函数直接修改该对象。
例如:
void addStrings(
const string& a,
const string& b,
string& result
) {
result = a + b;
}
int main() {
string s1 = "hello";
string s2 = "world";
string s3;
addStrings(s1, s2, s3);
cout << s3; // helloworld
}
其中:
-
a、b:输入型参数,提供数据。 -
result:输出型参数,用来接收结果。 -
result是s3的别名,因此函数内部修改result就相当于修改s3。
2. 输出型参数的意义
C++98 中,如果担心按值返回大型对象造成昂贵的拷贝,可以使用输出型参数。
但需要注意:
-
输出型参数不意味着函数内部完全不会产生临时对象或拷贝。
-
C++98 已经支持返回值优化(RVO/NRVO),所以并不是必须使用输出型参数。
-
C++11 引入移动语义后,按值返回对象通常更加高效和方便。
四、C++11 如何优化返回值
对于函数内部创建的新对象,推荐直接按值返回。
string createString() {
string s = "hello";
return s;
}
string str = createString();
这里有两种重要的优化机制。
1. 返回值优化(RVO/NRVO)
编译器可以省略中间对象的拷贝或移动,直接在目标位置构造结果。
-
RVO:返回值优化。
-
NRVO:具名返回值优化,典型场景是
return s;。
RVO/NRVO 在 C++98 中就已经存在。
2. 移动语义(Move Semantics)
C++11 引入移动构造函数。
如果没有发生返回值优化,编译器在适当条件下可以使用移动构造,将局部对象的资源转移给返回对象,而不是进行昂贵的深拷贝。
因此:
string func() {
string s = "hello";
return s; // 正确,推荐
}
不要为了触发移动而随意写成 return std::move(s);,因为这可能妨碍 NRVO。
核心结论:返回函数内部创建的新对象时,优先使用按值返回 T,而不是 T& 或 T&&。
五、移动构造函数
1. 基本概念
移动构造函数是 C++11 引入的特殊构造函数。
它和拷贝构造函数的区别在于:
-
拷贝构造:复制源对象的资源。
-
移动构造:尽可能接管源对象的资源,避免重新复制。
函数形式:
class String {
public:
String(const String& other); // 拷贝构造
String(String&& other); // 移动构造
};
移动构造函数的第一个参数必须是该类类型的右值引用;如果有其他参数,这些参数必须具有默认实参。
2. 为什么移动构造能够提高效率?
对于管理动态内存的类:
拷贝构造:
-
申请新的内存空间。
-
复制源对象的数据。
-
两个对象分别管理自己的资源。
移动构造:
-
接管源对象的资源。
-
将源对象调整为可以安全析构的有效状态。
-
避免不必要的深拷贝。
3. 简单实现
假设自定义 String 类拥有一个独占的动态字符数组指针 _str:
String(String&& other) noexcept
: _str(other._str)
{
other._str = nullptr;
}
这里:
-
_str(other._str):接管源对象指针。 -
other._str = nullptr:避免两个对象同时管理同一块内存。 -
noexcept:说明此移动构造不抛出异常,有利于容器进行移动优化。
这是简化实现。如果类还包含 _size、_capacity 等成员,也需要正确处理这些成员。
移动构造的核心是资源所有权转移,而不是复制资源。
六、移动赋值运算符
1. 基本概念
移动赋值是赋值运算符 operator= 的重载。
它和移动构造的区别:
-
移动构造:创建新对象。
-
移动赋值:修改已经存在的对象。
例如:
String s1("hello");
String s2("world");
s2 = std::move(s1); // 移动赋值
2. 简化实现
String& operator=(String&& other) noexcept {
if (this != &other) {
delete[] _str;
_str = other._str;
other._str = nullptr;
}
return *this;
}
其中:
-
判断是否为自身赋值。
-
释放当前对象原来管理的资源。
-
接管源对象的资源。
-
将源对象置于有效的可析构状态。
-
返回
*this。
这里同样假设 _str 是独占管理的动态数组指针。
3. 四种特殊操作对比
| 操作 | 典型函数形式 | 主要作用 |
|---|---|---|
| 拷贝构造 | T(const T&) |
创建对象,复制资源 |
| 移动构造 | T(T&&) |
创建对象,转移资源 |
| 拷贝赋值 | T& operator=(const T&) |
给已有对象复制资源 |
| 移动赋值 | T& operator=(T&&) |
给已有对象转移资源 |
移动构造和移动赋值对于 string、vector 等管理动态资源的类通常更有意义。
但普通类也可以拥有移动操作,只是移动不一定比拷贝快。
七、std::move 的作用
1. 基本概念
std::move 并不真正移动对象,而是将表达式转换为将亡值(xvalue),使其能够匹配右值引用参数。
string s1 = "hello";
string s2(std::move(s1)); // 移动构造
其中:
-
s1是左值。 -
std::move(s1)是将亡值,属于右值。 -
移动构造函数可以接管
s1的资源。
2. 有名字的右值引用变量仍然是左值
string s1 = "hello";
string&& rr = std::move(s1);
string s2(rr); // 拷贝构造
string s3(std::move(rr)); // 移动构造
原因是:
右值引用是一种类型,而一个有名字的右值引用变量在表达式中是左值。
因此,想再次触发移动操作,通常还需要 std::move。
3. 移动后的对象
被移动后的对象仍然存在,必须保持有效状态,但它的具体值通常不能再假定与移动前相同。
对于标准库中的 string、vector 等对象,移动后仍然可以析构、重新赋值等。
八、右值引用和移动语义在容器传参中的优化
1. C++98 的 push_back
C++98 中,vector 的 push_back 主要提供:
void push_back(const T& value);
例如:
vector<string> v;
string s = "hello";
v.push_back(s);
v.push_back(string("world"));
无论传入左值还是右值,都可以绑定到 const T&。
但容器构造内部元素时,通常需要进行拷贝构造。
对于临时对象,这种拷贝可能造成额外开销。
2. C++11 增加右值引用重载
void push_back(const T& value);
void push_back(T&& value);
编译器根据实参的值类别选择重载。
传入左值:
string s = "hello";
v.push_back(s);
匹配:
push_back(const T& value);
通常通过拷贝构造创建容器中的元素,原对象资源保持不变。
传入右值:
v.push_back(string("world"));
匹配:
push_back(T&& value);
通常通过移动构造创建容器中的元素,可以减少资源复制。
使用 std::move:
string s = "hello";
v.push_back(std::move(s));
将左值转换为将亡值,从而匹配右值引用版本。
注意:std::move 不保证一定发生资源转移,实际行为取决于类型是否支持移动构造及其实现。
九、为什么右值引用参数内部还需要 std::move?
这是移动语义的重要细节。
假设实现:
void push_back(T&& value) {
// ...
new (p) T(value);
}
虽然 value 的声明类型是 T&&,但表达式 value 是左值。
因此这里可能调用拷贝构造。
正确写法:
void push_back(T&& value) {
// ...
new (p) T(std::move(value));
}
通过 std::move(value) 转换为右值,使移动构造函数能够参与匹配。
因此:
右值引用形参在函数内部是左值,想将其资源继续转移,通常需要使用 std::move。
十、自定义 list 支持移动语义
对于自己实现的链表,可以给 push_back 和 insert 增加右值引用重载。
1. push_back
void push_back(const T& x) {
insert(end(), x);
}
void push_back(T&& x) {
insert(end(), std::move(x));
}
2. insert
iterator insert(iterator pos, const T& x) {
// 创建节点,拷贝构造元素
}
iterator insert(iterator pos, T&& x) {
// 创建节点,移动构造元素
}
右值版本应当在创建节点元素时正确使用 std::move(x),并保证节点构造过程能够继续使用移动语义。
调用:
bit::list<string> lt;
string s = "hello";
lt.push_back(s); // 拷贝构造
lt.push_back(string("abc")); // 移动构造
lt.push_back(std::move(s)); // 移动构造
这样就可以在插入右值时减少不必要的深拷贝。
十一、emplace 系列接口
C++11 还引入了 emplace、emplace_back 等接口。
例如:
vector<string> v;
v.push_back(string("hello"));
v.emplace_back("world");
二者区别:
-
push_back:接收一个已经构造好的对象,再使用拷贝或移动构造在容器中创建元素。 -
emplace_back:接收构造参数,直接在容器的存储位置构造元素。
emplace_back 使用可变参数模板和完美转发。
它可以减少某些场景下的临时对象,但并不保证始终比 push_back 高效。
十二、最终总结
1. 引用和返回值
-
T&:用于修改已有对象,或返回仍然存活的已有对象。 -
const T&:适合只读传参,避免不必要的复制。 -
T:适合按值返回函数内部创建的新对象。 -
不应该返回局部变量的
T&或T&&。
2. 拷贝与移动
-
拷贝:复制资源,让源对象和目标对象分别拥有自己的资源。
-
移动:尽可能转移资源所有权,避免昂贵的资源复制。
-
std::move:只是转换值类别,不负责实际转移资源。
3. 容器插入
| 调用 | 接口 | 通常执行 |
|---|---|---|
push_back(s) |
const T& |
拷贝构造 |
push_back(T{}) |
T&& |
移动构造 |
push_back(std::move(s)) |
T&& |
移动构造 |
emplace_back(args...) |
可变参数模板 | 使用参数直接构造 |
对于 vector,扩容时还可能涉及原有元素的移动或拷贝;对于 list,插入新节点通常不会搬迁已有元素。
核心知识链
左值引用减少已有对象的传参拷贝 → 但不能安全地返回局部对象引用 → C++11 移动语义优化资源转移 → 移动构造和移动赋值避免部分深拷贝 → 容器通过右值引用重载提高插入效率。