C++11新特性,{}与左值

列表初始化与 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};

可以理解为以下过程:

  1. 编译器创建一个隐藏的常量数组,存储 10、20、30。

  2. 创建一个 initializer_list<int> 对象,关联这个数组。

  3. 通过 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);

初始化过程:

  1. 编译器根据 {10, 20, 30} 创建隐藏数组。

  2. 创建 initializer_list<int> 访问该数组。

  3. 将 initializer_list 传递给 vector 的构造函数。

  4. 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 构造函数能够匹配,但涉及不允许的窄化转换,程序可能直接编译失败,而不是自动选择普通构造函数。


五、核心总结

  1. 列表初始化 :C++11 使用 {} 初始化变量或对象的语法,能够统一初始化形式,并禁止窄化转换。

  2. std::initializer_list:C++11 提供的类模板,底层关联一个隐藏的常量数组,本身不拥有数据,提供只读访问。

  3. 两者的关系 :列表初始化是语法,initializer_list 是类型。使用 {} 不代表一定会创建 initializer_list。

  4. 构造函数优先级 :使用 {} 初始化类对象时,会优先考虑可行的 initializer_list 构造函数。

  5. 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. 为什么移动构造能够提高效率?

对于管理动态内存的类:

拷贝构造:

  1. 申请新的内存空间。

  2. 复制源对象的数据。

  3. 两个对象分别管理自己的资源。

移动构造:

  1. 接管源对象的资源。

  2. 将源对象调整为可以安全析构的有效状态。

  3. 避免不必要的深拷贝。

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;
}

其中:

  1. 判断是否为自身赋值。

  2. 释放当前对象原来管理的资源。

  3. 接管源对象的资源。

  4. 将源对象置于有效的可析构状态。

  5. 返回 *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 移动语义优化资源转移 → 移动构造和移动赋值避免部分深拷贝 → 容器通过右值引用重载提高插入效率。

相关推荐
j7~2 小时前
【穷举、深搜与回溯算法】一棵决策树讲透全排列与子集
数据结构·c++·算法·leetcode·回溯·递归
kiracrimson3 小时前
栈与队列容器:适配器、deque 与 priority_queue
开发语言·c++·适配器模式
必须得开心呀5 小时前
windows10系统任务栏图标消失修复
windows
qq_466302455 小时前
如何通过部分文件名,读取出整个文件名
c++·qt
octopus_c5 小时前
C++ 入门
开发语言·c++·算法
zl_dfq5 小时前
Windows 下 UDP 套接字编程指南
网络·windows·udp
raindayinrain6 小时前
c++新特性--实用新特性
c++
学习论之费曼学习法6 小时前
无人值守AI内容矩阵实战(10):Windows 上让 Python 进程 7×24 活着
人工智能·windows·python·矩阵·powershell·进程守护
Soneswy6 小时前
C++基础篇——类与对象(中)
开发语言·c++