🔥 本文专栏:c++
🌸作者主页:努力努力再努力wz

在这里插入图片描述
在这里插入图片描述

💪 今日博客励志语录 这个时代不缺少选项,缺的是选择后不反复摇晃的定力。 你的价值不在于选择了什么,而在于你如何守护和深耕自己的选择


引入

时光即将步入2026年,C++ 的版本也已演进至 C++23——正如其名,这一标准于2023年正式发布。而 C++11,虽然诞生于14年前,却至今仍在我们的日常编码中频繁出现。它在 C++ 发展历程中的地位无可替代,堪称一次划时代的更新,为之后所有的现代 C++ 特性奠定了坚实基础。

在正式深入探讨 C++11 所引入的新语法和特性之前,我们不妨先简要回顾一下 C++ 的发展历程,了解 C++11 诞生的背景与意义。

在 C++11 之前,真正具有里程碑意义的版本是 C++98。该版本最关键的贡献是引入了模板(Template)机制,使 C++ 具备了泛型编程能力,并直接推动了标准模板库(STL)中各类容器的出现。C++98 发布之后,C++ 标准委员会又推出了 C++03。不过,C++03 并未引入重要的新特性,其主要目的是修复 C++98 中存在的一些问题,因此可将其视为 C++98 的修补版。

随后,C++ 委员会制定了“每三年推出一个新版本”的计划,并将下一代标准的代号定为 C++0x。按照原计划,新版本本应在2006或2007年发布。但由于委员会希望在该版本中一次性加入多项重大特性,实际开发时间远超预期,导致 C++0x 一再延迟发布。

转折点出现在2011年。这一年,C++ 终于迎来了新版本——C++11。这是一个真正改变 C++ 发展轨迹的版本,引入了诸多关键而强大的特性,例如我们即将介绍的 lambda 表达式和包装器等。它不仅为后续的 C++ 发展铺平了道路,也使委员会重新回归“三年一版”的发布节奏,C++14 和 C++17 得以如期面世。

了解了 C++ 的发展脉络与 C++11 的重大意义之后,接下来就让我们正式开启探索之旅,一起学习 C++11 带来的全新语法吧。

列表初始化

那么要引入的第一个语法就是列表初始化。在 C++11 之前,我们对内置类型的初始化有两种主要方式:赋值运算符初始化和括号初始化。赋值运算符初始化(如 int a = 10;)的本质是编译器将右侧的值直接写入目标对象的内存空间。括号初始化(如 int b(20);)的底层行为与赋值运算符初始化相同,也是将括号内的值直接写入目标内存。

#include<iostream>
int main()
{
    int a = 10;    // 赋值运算符初始化
    int b(20);     // 括号初始化
    std::cout << "a=" << a << " b=" << b << std::endl;
    return 0;
}

在这里插入图片描述


对于自定义类型,初始化同样分为括号初始化和赋值运算符初始化。括号初始化(如 myclass ant(val);)会直接调用匹配的构造函数,括号内的参数传递给对应构造函数。

而赋值运算符初始化(如 myclass ant = val;)的底层行为需要特别注意:

  1. 若 val 是相同类型:调用拷贝构造函数(非赋值运算符)

  2. 若 val 是不同类型:如果该类中定义了接收val对应类型的构造函数,那么会先调用该构造函数隐式类型转化生成一个临时对象,然后再调用拷贝构造函数,但是编译器会优化为直接调用一次接收val对象类型的构造函数来初始化目标对象

  3. 本质上仍是构造过程而非赋值操作

myclass ant(val);  // 括号初始化 → 调用构造函数

myclass val;
myclass ant = val; // 赋值初始化 → 调用拷贝构造函数



接着讨论聚合体(Aggregate)的初始化。聚合体是指满足以下全部条件的类/结构体实例:

  1. 所有非静态成员均为 public

  2. 无用户声明的构造函数(包括默认/拷贝/移动构造)

  3. 无基类(即非派生类)

  4. 无虚函数(即非多态类)

(补充:允许存在默认析构函数)

由于聚合体没有构造函数,使用空括号初始化 myclass l1(); 会被编译器解析为函数声明而非对象创建。若尝试访问 l1 成员将导致编译错误:

class myclass {
public:
    int member1;  
    int member2;
    int member3;
};
int main() {
    myclass l1();  // 被解析为函数声明!
    return 0;
}

正确的聚合体初始化应使用列表初始化(花括号语法):

class myclass {
public:
    int member1;
    int member2;
    int member3;
};
int main() {
    myclass l1 = { 10, 20, 30 };  // 列表初始化
    std::cout << l1.member2 << std::endl;  // 输出:20
    return 0;
}

在这里插入图片描述


列表初始化的底层原理:

  1. 编译器解析聚合体声明,确定内存布局(成员按声明顺序排列,遵循内存对齐规则)

  2. 计算每个成员的精确内存偏移量

  3. 直接将花括号内的值按顺序写入对应内存位置

(补充:此过程完全绕过构造函数调用,属于直接内存操作)

聚合体严格条件的必要性:

• public 成员 → 允许外部直接访问内存

• 无用户声明构造 → 避免编译器尝试调用构造函数

• 无基类/虚函数 → 保证内存布局简单连续(无虚表指针/基类子对象)

这些条件共同确保编译器能安全地进行直接内存写入,无需构造函数的介入。


在讨论内置类型变量和自定义类型对象初始化之后,我们还需要关注数组的初始化需求。对于内置类型数组的初始化,可采用与聚合体相同的方式——列表初始化:

int arr[10] = {1, 2, 3};  

当使用列表初始化内置类型数组时,编译器首先在栈区为数组分配连续内存空间,随后将初始化列表中的值按顺序直接写入每个元素对应的内存位置。需要特别注意的是,数组初始化不能使用括号初始化语法(如 int arr[10](1,2,3)),这是无效的语法。

对于自定义类型数组的初始化,同样可以采用列表初始化方式:

class myclass
{
public:
    int member1;
    int member2;
    int member3;
    myclass(int x=10, int y=20, int z=30)
        :member1(x)
        ,member2(y)
        ,member3(z)
    {
    }
};
int main()
{
    myclass arr10 = { myclass(1,2,3) };  
    return 0;
}

在此过程中,编译器会在初始化列表中构建匿名对象,然后依次调用数组各元素的拷贝构造函数完成初始化。现代编译器通常会进行优化(copy elision):本来是先调用构造函数构建匿名对象,然后再调用拷贝构造函数初始化数组中的对应元素,但是编译器会优化为直接调用数组对应元素的构造函数,而不会创建中间的临时对象,避免不必要的拷贝操作。

对于聚合体数组(即元素为聚合类型的数组),由于聚合体没有用户声明的构造函数,初始化列表中的内容直接对应聚合体的成员变量:

#include<iostream>
class myclass  // 聚合体定义
{
public:
    int member1;
    double member2;
    char member3;
};
int main()
{
    myclass arr[10] = { 10, 8.9, 'c', 20 };  
    
std::cout << arr[0].member1 << std::endl;
std::cout << arr[0].member2 << std::endl;
std::cout << arr[0].member3 << std::endl;
std::cout << arr[1].member1 << std::endl;

return 0;

}

在这里插入图片描述

由于数组空间是连续的且元素类型相同,编译器能够精确计算每个元素的内存布局和偏移量。初始化时,编译器会将花括号中的内容按顺序直接写入数组元素的成员变量中,这种直接内存写入操作是高效的底层操作。


在了解C++11之前的内置类型变量、自定义类型变量、聚合体及其对应数组的初始化方式后,我们可以观察到:内置类型和自定义类型变量的初始化方式较为统一,均可使用赋值运算符初始化和括号初始化。然而对于聚合体和数组,它们只能采用花括号(即列表初始化)的方式。同时,根据数组存储元素的数据类型的不同,初始化列表中元素的形式也会有所差异。

在C++11之前,我们无法使用统一的初始化方式来处理内置类型、自定义类型、聚合体及数组。C++11引入的列表初始化正是为了解决这一问题——无论处理内置类型、自定义类型、聚合体还是数组,均可采用列表初始化。这种创新如同秦始皇统一货币,使我们在初始化时无需关注目标对象是聚合体还是普通自定义类型。

列表初始化的语法是通过花括号进行初始化。对于内置类型,花括号内的内容即为目标对象的设定值。编译器底层的处理行为相同:直接将花括号的值写入目标变量对应的内存空间。这种初始化可以写在赋值运算符后,也可省略赋值运算符,两种写法的底层行为完全一致:

#include<iostream>
int main()
{
	int a = { 10 };  // 带赋值运算符的列表初始化
	int b{ 20 };      // 直接列表初始化
	std::cout << a << std::endl;
	std::cout << b << std::endl;
	return 0;
}

在这里插入图片描述

对于自定义类型,同样支持列表初始化。对于带有参数的构造函数的类或结构体,列表中的内容将作为构造函数的参数。编译器会调用目标对象的构造函数,并将列表参数传递给该构造函数:

class myclass
{
public:
	int member1;
	int member2;
	int member3;
	myclass(int x, int y, int z)
		:member1(x)
		, member2(y)
		, member3(z)
	{
		// 构造函数实现
	}
};
int main()
{
	myclass l1{ 1,2,3 };      // 直接列表初始化
	myclass l2 = { 1,2,3 };   // 带赋值运算符的列表初始化
    return 0;
}

对于聚合体,在C++11之前本就采用列表初始化,前文已详述,此处不再赘述。


对于数组,同样支持列表初始化。需特别注意自定义类型数组的初始化:在C++11之前,我们通常需要在初始化列表中构造匿名对象来初始化数组元素;而C++11引入列表初始化后,可直接通过列表构建元素:

class myclass
{
public:
	int member1;
	int member2;
	int member3;
	myclass(int x=100, int y=200, int z=300)
		:member1(x)
		, member2(y)
		, member3(z)
	{

	}
};
int main()
{
	myclass arr[10] = { {1,2,3},{10,20,30} };
    //myclass arr[10]{ {1,2,3},{10,20,30} }; 也可以采取这种写法,那么无论是采取这种写法和上面的写法,那么底层编译器做出的行为都是相同的
    return 0;
}

在此过程中,编译器会识别数组元素的数据类型,并调用对应类的构造函数(无参或全缺省构造函数)来构造对象。对于未显式初始化的数组元素(如示例中索引2到9的元素),编译器将调用默认构造函数进行初始化。


initializer_list

列表初始化的能力远不止于此。对于自定义类型,我们知道它们可以接收一系列参数进行初始化。以 vector 为例,其底层维护了一个动态数组。在使用 vector 对象时,我们实际上是将其视为一个动态数组。对于原生数组的初始化,我们习惯使用列表初始化方式,即将列表中的值依次写入数组各元素的内存空间中完成初始化。那么对于 vector,能否实现类似原生数组的初始化行为?也就是说,我们能否通过列表初始化方式,直接向 vector 底层的动态数组中插入一系列值,并由 vector 接收这些值并完成初始化?

事实上,vector 确实支持这种初始化方式:

#include <iostream>
#include <vector>

int main()
{
    std::vector<int> l1 = { 1, 2, 3 };
    for (auto& e : l1)
    {
        std::cout << e << " ";
    }
    return 0;
}

在这里插入图片描述

我们可以看到,vector 确实支持上文提到的、类似原生数组的初始化方式。这时读者自然会产生疑问:vector 是如何实现这一机制的?这便与 C++ 标准库中的一个类密切相关——initializer_list

在这里插入图片描述

initializer_list 是 C++ 标准库提供的一个类,上文所使用的初始化方式,底层调用的正是 vector 中接受 initializer_list 对象的构造函数。

在这里插入图片描述

initializer_list 是一个模板类,其内部通常维护两个 const 属性的指针,分别指向数组的起始位置和结束位置(即“超尾”位置,past-the-end)。当我们使用列表初始化 vector 对象时,编译器识别到正在使用列表初始化一个自定义类型对象,会首先检查该类型是否定义了接受 initializer_list 的构造函数。如果定义了,编译器会执行以下步骤:

  1. 构建一个临时数组,并将列表中的值写入该数组;
  2. 创建一个临时的 initializer_list 对象,并使用该临时数组的起始和结束位置初始化其内部指针。

在 Visual Studio 平台中,其标准库实现的 initializer_list 的构造函数为一个模板构造函数,接受两个表示区间范围的迭代器(或指针),用于初始化这两个内部指针。

微软官方文档参考链接:https://learn.microsoft.com/zh-cn/cpp/standard-library/cpp-standard-library-reference?view=msvc-170

若临时数组元素为 int 类型,则该模板构造函数实例化为接受两个 const int* 类型参数,分别指向数组起始与结束位置,并使 initializer_list 内部的两个指针指向该临时数组的相应位置。

随后,该临时 initializer_list 对象被传递给 vector 的构造函数。vector 会遍历 initializer_list 所指向的区间,将临时数组中的值依次拷贝至其底层动态数组中,完成初始化。此后,临时数组与 initializer_list 对象会被销毁。

回顾整个过程,主要的开销在于临时数组的创建及由此到 vector 动态数组的元素拷贝。而 initializer_list 仅充当一个轻量级的“中间代理”,其本身并不创建或复制数组元素,而只是提供对临时数组的访问接口。


需要注意的是,initializer_list 是标准库提供的一个模板类,它本身并不是一个容器,因为它不存储任何元素。其内部通常仅包含两个成员变量,即两个指针,分别指向一段连续内存空间的起始位置和结束位置。我们也可以通过引入 <initializer_list> 头文件来直接使用 initializer_list

#include<iostream>
#include<initializer_list>
int main()
{
	int arr[3] = { 0,1,2 };
	std::initializer_list<int> ant = { 1,2 };
	for (auto& e : ant)
	{
		std::cout << e << " ";
	}
	std::cout<<std::endl;
	std::initializer_list<int> bnt(arr, arr + 3);
	for (auto& e : bnt)
	{
		std::cout << e << " ";
	}
	return 0;
}

在这里插入图片描述

实际上,initializer_list 单独使用的场景较为少见。它的主要作用在于增强自定义类型初始化的灵活性,通过在类中定义接受 initializer_list 的构造函数来实现。但需要注意的一个重要问题是:一旦类中定义了接受 initializer_list 的构造函数,当使用列表初始化语法对该类对象进行初始化时,即使存在其他可能匹配的构造函数,编译器也会优先选择调用 initializer_list 版本的构造函数。编译器会首先将初始化列表构造为一个临时的 initializer_list 对象,然后将其传递给相应的构造函数。

vector 为例,我们知道 vector 有一个构造函数支持创建包含 count 个元素且每个元素初始化为 value 的容器:

std::vector<int>::vector(size_type count, const int& value);

如果意图调用此构造函数,初始化一个包含3个元素且每个元素都为2的 vector,但若错误地使用列表初始化语法:

std::vector<int> l1 = { 3, 2 };

实际得到的结果将是一个包含两个元素(3和2)的 vector,而非三个值为2的元素。这是因为编译器优先将 {3, 2} 解释为 initializer_list<int>,从而匹配了 vector(initializer_list<int>) 构造函数,而非 vector(size_type, const int&)

因此,若需调用其他版本的构造函数,应使用圆括号 () 进行初始化,或者避免定义 initializer_list 版本的构造函数。

// 正确的调用方式 (使用圆括号)
std::vector<int> l2(3, 2); // 创建一个包含3个元素(每个元素值为2)的vector

auto关键字和decltype关键字

那么auto关键字想必各位读者一定并不陌生,即使有些读者没有学过c++11的新语法,那么之前也一定经常使用auto关键字,而在c++中出场频率十分高的auto关键字其实是在c++11时期诞生的产物

当我们使用某个容器提供的迭代器来访问该容器中的元素的时候,那么我们常常不会自己写迭代器的类型,而是直接使用auto关键字,那么auto关键字能够自动推导类型,那么auto的定义类似于引用一样,那么当我们使用auto关键字定义变量的时候,那么我们一定要让auto定义的变量初始化,不能用auto定义变量的时候,那么该变量没有初始化,那么这样编译器无法推导该变量的数据类型,从而报错

auto a;//错误写法,a必须初始化
-----------------------------------------
 int a=10;
auto b=a;//正确写法

那么auto最为常见的应用场景,便是出现在范围for这个语法糖上,那么范围for的底层实现其实就是利用迭代器,那么我们可以直接用auto关键字来定义一个变量,然后通过范围for来遍历容器

std::vector<int> l1;
for(auto a: l1)
{
    std::cout<<a<<std::endl;
}

而第二个关键字便是decltype,那么这里decltype关键字的作用和auto其实十分相似,那么它也能自动推导类型,但是它相比于auto,能够更能精确推导类型

对于auto,那么auto推导目标对象的类型,会忽略对象的引用,那么为了验证这一点,我们可以用auto去推导一个引用类型的变量,然后修改auto定义的变量的值,如果auto没有忽略引用,那么我们对auto定义的变量做修改,其实是是对引用所绑定的变量做修改:

#include<iostream>
int main()
{
	int a = 10;
	int& ref = a;
	auto ant = ref;
	ant++;
	std::cout << a << std::endl;
    return 0;
}

在这里插入图片描述

同样,我们也可以验证auto能够忽略推导的对象本身所携带的const属性,那么如果auto不会忽略推导的对象本身携带的const属性,那么我们去修改auto定义的变量,那么就是不合法的:

#include<iostream>
int main()
{
	const int a = 10;
	auto ant = a;
	ant++;
	std::cout << ant << std::endl;
	return 0;
}

在这里插入图片描述

所以通过上面两个实验,我们就能够验证到auto推导类型,会忽略变量的引用以及变量自身的const属性

而对于decltype来说,其类型推导相比于auto则更加精确,那么如果被推导的对象本身就是一个引用类型,那么decltype不会忽略引用,则会保留其引用的类型:

#include<iostream>
int main()
{
	 int a = 10;
	 int& ref = a;
	decltype(ref) ant = ref;
	ant++;
	std::cout << a << std::endl;
	return 0;
}

在这里插入图片描述

那么这里根据结果,我们可以观察到,我们对cnt修改,结果打印变量a的值,变成了11,那么之所以a的值会变,就是因为这里ant的类型会被推导为int&,那么ant会被绑定到ref所绑定的对象,也就是变量a,所以对ant修改,其实就是对其绑定的变量a本身进行修改

其次如果被推导的变量本身具有const属性,那么这里decltype也不会忽略const属性,那么我们还是可以写代码来验证这一点:

#include<iostream>
int main()
{
	 const int a = 10;
	decltype(a) ant = 20;
	ant++;
	std::cout << a << std::endl;
	return 0;
}

在这里插入图片描述

那么这里报一个不可修改的左值,之所以报这个错误,就是因为这里ant的类型会被推导为const int,那么修改const属性的值是不合法,而至于什么是左值,那么会在后文进行详细的讲解,decltype还能够推导各种表达式的类型,比如算术表达式:decltype(10+20.5),那么这里推导的就是算术表达式计算结果的类型,并且相比于auto,我们知道auto定义变量,必须要要初始化,因为编译器需要通过初始化的变量来推导类型,而decltype定义变量则不一定需要初始化,除非你推导出的类型是引用,那么必须要初始化,要绑定一个对象,那么你如果不需要精确推导类型,那么就可以选择auto,而如果你要精确推导类型,不想忽略引用以及变量本身携带的const,那么就只能选择decltype

新容器以及新接口

array

那么c++11还更新了新的容器,其中就包括array以及forward_list和unordered_set和unordered_map,那么这四个容器都是c++11期间引入的

array这个容器的出场率其实并不高,那么给不认识的读者科普一下什么是array:
在这里插入图片描述

array这个容器底层维护了一个静态数组,因为array就是对标的就是原生的静态数组,那么array会接收一个非类型模版参数,这个非类型模版参数的作用就是用来设置静态数组的大小,而array这个容器和原生的静态数组的区别其实并不大,并且这个c++11引入的新容器也有很多让人吐槽的点:

array内部只提供了一个无参的构造函数以及拷贝构造函数,而array既然是STL的容器,那么它至少得继承其他STL的容器的优点,也就是其无参的构造函数至少给静态数组中的内容初始化一下,如果静态数组中存储的元素是内置类型,就让静态数组的所有的元素值设置为0,如果是静态数组存储的实自定义类型就调用其默认的构造函数,但事实上array的无参的构造函数不会对静态数组做任何的初始化

吐槽归吐槽,但是array也不是一无是处,array本身还是具有一些优点的:

array相比于静态数组的优势就在于,我们通过索引取访问array中的静态数组的元素的时候,那么array内部会检查这个索引的合法性,因为其重载了[]运算符,其会检查索引,而相反编译器则不会检查我们用原生静态数组的索引是否越界

其次就是我们可能会有拷贝静态数组的需求,对于原生数组来说,那么需要我们自己写代码创建一个静态数组然后接着遍历被拷贝的静态数组,而对于array来说,只需要调用拷贝函数即可,虽然拷贝构造函数的原理和逻辑和我们自己用原生静态数组拷贝的逻辑是一样的,但是总之不用我们自己手写相关的代码,只需要一行代码就能够搞定,这点还是十分方便

最后则是函数传参,那么静态数组传参,那么我们知道数组名会退化为指针,但对于array对象来说,如果该函数接收一个array对象,如果是传值给该函数,那么必然要涉及到拷贝,但是我们可以通过引用,用引用来接收一个array对象,减少了拷贝,虽然底层引用本质上还是通过指针来实现,但是这里通过引用,相当于直接在函数内部对外部的array对象本身进行操作,那么比起指针操作,更加直观且不容易出错

void fun(std:array<int,10>& arr);

那么最后综合评价一下array这个容器,和原生静态数组对比的话,那么肯定array是更有优势,但STL已经有了vetcor这个容器,那么一般我们还是更倾向于使用vector,即使其会有动态数组的创建以及扩容和销毁的开销,但是其功能都十分完善,能够满足绝大部分的需求

forward_list

在这里插入图片描述

那么forward_list也是c++11引入的新容器,那么这个容器底层维护的就是一个单链表,既然底层维护的是单链表,那么根据单链表的特性,我们就很容易能够想到forward_list不能支持尾插与尾删,因为要从头往后一直遍历单链表,并且forward_list支持的任意位置的插入函数insert_after,则是接收一个迭代器,在迭代器指向的节点之后插入,而不是在迭代器指向的位置之前插入,就是为了避免遍历从头遍历该单链表来得到该迭代器指向的节点的前驱节点

而我们知道库中还有一个容器list,那么其底层维护的是一个带头的双向循环链表,那么forward_list支持的功能,那么list也都有对应的实现,而forward_list的优势就在于其对应的单链表中的每一个节点,比list对应的节点少一个指向前驱节点的指针,相比于list来说,对空间的消耗要少,但list和forward两者的功能都高度重合,所以一般我们都还是倾向于使用list

而其余的两个新容器,unordered_map以及unorder_set,这两个容器则是非常的有用的更新,底层采取的是哈希表,那么查找数据的效率是比红黑树还要高效,而unordered_set以及unordered_map我在之前的博客做过十分详细的讲解,那么如果对这两个容器感到陌生或者有兴趣的读者,可以看我之前的博客

cbegin/cend接口

在这里插入图片描述

在这里插入图片描述

那么c++11又为STL的容器提供了几个新的接口,其中就包括cbegin以及cend接口,那么讲解这两个接口的原因就是首先让读者认识这两个接口,下一次看到有人使用这两个接口不会感到陌生,其次就是让读者知道虽然c++11是一个非常伟大的c++版本,但是它所做的所有的更新并不都是优秀或者说完美的,这里的引入的这两个接口就是代表,那么cbegin和cend接口的内容很简单,就是返回该容器对应的const版本的迭代器,而容器本身就重载了返回const版本的迭代器以及非const版本的迭代器的begin以及end函数,那么这里之所以引入这个cbegin以及cend接口,就是因为委员会考虑到:如果你要返回const版本的迭代器,那么创建的对象必须是被const修饰,那么才能调用返回const版本的迭代器的begin以及end函数从而返回const版本的迭代器,但是有的用户可能有这样的需求,就是它定义的对象本身没有被const修饰,但是其想直接返回一个const版本的迭代器,所以就提供了cbegin以及cend接口,并且语义还清晰,你看到这两个接口就知道返回的一定是const版本的迭代器

cbegin和cend这两个接口确实有用,但是 是否足够优秀还得辩证的去看待

左值和右值以及移动语义

左值和右值

许多C++开发者在编写代码时,经常会遇到编译器错误信息中出现"左值(lvalue)"和"右值(rvalue)"这两个术语。常见的误解是认为左值就是赋值运算符左侧的表达式,右值就是赋值运算符右侧的表达式。然而,这种基于位置的区分方式并不准确 - 左值和右值的本质区别在于它们的生命周期特性。

我们知道,任何变量或表达式都具有生命周期。对于在局部作用域中定义的变量,其生命周期与其所处的作用域相同。例如,在函数中定义的局部变量,其生命周期从变量定义处开始,到函数调用结束时终止,随着函数栈帧一起被销毁。对于全局变量和静态全局变量,其生命周期与程序一样长。而对于匿名对象或字面量等,它们的生命周期极其短暂,仅限于创建它们的完整表达式中,一旦所在的完整表达式结束,它们的生命周期就立即结束。

基于这种生命周期差异,左值和右值的概念被引入来标识变量或对象的生命周期状态:

  • 右值标识生命周期极其短暂的实体,仅存在于创建它的完整表达式内
  • 左值标识生命周期持久的实体,其存在时间与所在作用域相同

左值和右值的本质就像是给变量或对象添加的一个生命周期标记,用于标识其寿命长度。右值标记表示该变量的生命周期极其短暂,只有"一瞬",即只能存在于创建它的完整表达式中。根据这个特点,我们可以知道匿名对象、字面量以及算术表达式的计算结果等都是常见的右值,因为它们只能存活于创建它们的完整表达式中,一旦所在的完整表达式结束,它们就会被销毁。而左值标记表示该变量能够长时间存在,能够在内存中持久存储,因此我们定义的全局变量、局部变量以及有名变量等都是左值。

所以根据上文左值和右值的特点,我们就能够得到一些常见的右值包括:匿名对象、字面量、算术表达式结果、函数返回的非引用类型值等。以及常见的左值包括:具名变量、字符串字面量、解引用表达式、返回左值引用的函数调用等。

我们可以通过记忆常见的左值和右值类型来帮助判断,也可以根据生命周期特性来判断:如果某个变量的生命周期极其短暂,只能存活在一个完整表达式中,那么它就是右值;否则就是左值。此外,我们还可以通过取地址操作来快速判断值类别:如果某个变量无法被取地址,那么它是右值;否则是左值。

而我们还有一种更为快速,更为准确的判断方法,便是取地址判断

判断值类别的方法:

  1. 生命周期判断法:实体是否仅存在于一个完整表达式中
  2. 取地址测试法:能否合法获取该实体的地址

以典型的右值变量(字面量和算术表达式)为例,如果我们尝试取它们的地址,编译器会报错:

include<iostream>
int main()
{
    std::cout << &20 << std::endl;        // 错误:无法取字面量的地址
    std::cout << &(20 + 20) << std::endl; // 错误:无法取算术表达式结果的地址
    return 0;
}

在这里插入图片描述

右值无法取地址的原因有两个方面:首先,对于算术表达式,其操作数通常存放在CPU的寄存器中,计算结果也保存在寄存器中,不会写入内存,因此没有内存地址可言。对于字面量,它们作为立即数被嵌入指令中,而指令存储在内存的代码段,我们只能对数据段的内容取地址,无法对代码段的内容取地址。

其次,即使某些右值变量在内存的数据段中分配了空间,由于其生命周期十分短暂,只能存在于创建它的完整表达式中。当我们尝试在下一个完整表达式中对其取地址时,该右值所分配的内存空间早已被销毁,即使取到了地址,这个地址也是无效的。因此,编译器在语法层面上严格禁止对右值变量取地址,这也是为什么我们能够通过取地址操作来快速且精确地判断值类别。


左值引用和右值引用

读者知道了左值以及右值的含义之后,由于c++11引入了左值以及右值,左值和右值将变量按照生命周期换分成了两类,那么这里c++11对于引用,其也进行了更新,那么在c++11之前,现在有了左值以及右值的概念之后,那么引用也自然的分为两类:分为左值引用以及右值引用

左值引用就是类型后面跟上一个取地址符号,而类型后面跟上两个取地址符号,就是右值引用

type& ref1=val1;//左值引用
//例子:int & ref1=a;  
------------------------------
type&& ref2=30;//右值引用
//例子:int&& ref2=20; std::string&& ref2=std::string("hhhhhhh");

那么顾名思义,左值引用是用来绑定左值,右值引用则是用来绑定右值,而我们知道右值的生命周期只存在于定义它的那行代码中,但是如果此时右值被右值引用所绑定,那么右值的生命周期就不再是只存在于定义它的那一行代码中,而是生命周期延长到和引用的生命周期一致,其不会执行到下一行代码就会被销毁,而是直到引用被销毁,那么引用所绑定的右值对象才会被销毁

并且像字面量以及算术表达式的结果,这些都是右值,而根据上文,我们知道算术表达式的结果一般都存放到CPU的寄存器,而字面量则是作为立即数嵌入指令存放到代码段中,但一旦字面量和算术表达式被右值引用所绑定,由于其生命周期得到了延长,能够长时间存在,所以编译器会将绑定的算术表达式的结果以及字面量会在内存中分配空间,因为需要长时间存在,并且我们可以对其右值引用来取地址来验证这一点

#include<iostream>
int main()
{
	int&& ref1 = 20;
	int&& ref2 = (70 + 90);
	std::cout << &ref1 << std::endl;
    ref1++;
    std::cout << ref1 << std::endl;    
	std::cout << &ref2 << std::endl;
	return 0;
}

在这里插入图片描述

那么要注意右值引用是无法绑定左值的,但是左值引用却可以绑定右值,前提是该左值引用必须是const属性的左值引用:

#include<iostream>
int main()
{
	const int& ref1 = 20;;
	std::cout << ref1 << std::endl;
	std::cout << &ref1 << std::endl;
	return 0;
}

在这里插入图片描述


移动构造函数和移动赋值函数

那么通过上文的介绍,想必读者已经对左值引用和右值引用有了基本认识。现在读者的疑问主要集中在左值引用和右值引用的具体应用场景上。接下来我将通过一个具体场景,帮助大家理解左值引用和右值引用的使用方法。

假设现在需要编写一个 MyClass 类,该类包含一个指向堆内存空间的指针。由于涉及堆内存管理,该类的拷贝构造函数必须实现深拷贝而非浅拷贝。深拷贝指的是目标对象需要先开辟与被拷贝对象相同大小的堆空间,然后将被拷贝对象中堆空间的数据完整复制过来。

当我们创建 MyClass 对象时,可以直接调用无参或有参构造函数进行初始化,也可以使用已存在的 MyClass 对象通过拷贝构造函数进行初始化。此外,还可以通过匿名对象来初始化目标对象。使用匿名对象的优势在于不需要显式创建已初始化的对象来调用拷贝构造函数,且匿名对象在执行完当前完整表达式后就会被销毁,其作用仅限于初始化新创建的对象,十分便捷。

自定义类型的拷贝构造函数参数通常设置为被拷贝对象的常量左值引用(const T&),这意味着它可以接收左值也可以接收右值。然而,传统的拷贝构造函数无法区分传入的对象是左值还是右值,统一采用深拷贝处理。对于左值这是正确的做法,但对于右值对象来说,由于其生命周期极其短暂(仅持续到当前完整表达式结束),当右值被拷贝构造函数的引用形参绑定时,其生命周期会延长至该引用形参的生命周期。

这意味着传递给拷贝构造函数的右值对象,在拷贝构造函数调用结束后就会销毁,而左值对象则不受影响,因为左值的生命周期与其所在作用域绑定。构造函数中的引用形参会随函数栈帧销毁而失效,但这不影响左值对象本身的生存期。

这个过程可以类比为:一个即将离世的亿万富豪拥有大量未使用的财产。对于注定要死亡的富豪来说,这些财产已无保留意义,不如捐赠给社会基金会或有需要的人,让资源得到继续利用。

同理,对于拷贝构造函数,如果引用形参绑定的是右值对象,那么该右值对象在拷贝构造函数调用结束后将立即被销毁,其内部资源也会随之释放。而对于左值对象,拷贝构造函数调用结束后它仍然存在,后续代码可能还会使用它。既然右值对象的命运注定是在调用结束后立即销毁,我们何不直接转移其内部资源呢?

所谓的资源转移,就是让新创建的 MyClass 对象中的指针直接指向右值对象指针所指向的内存空间,然后将右值对象中的指针置为空。这一过程类似于让临终的亿万富豪签署财产转移协议。

将右值对象中的指针置为空至关重要,因为在拷贝构造函数调用结束后,右值对象会被销毁并调用析构函数。如果不将指针置空,析构函数会释放该指针指向的内存,导致资源转移失败。


基于以上分析,我们可以得出结论:如果拷贝构造函数接收到的是左值对象,必须进行深拷贝,因为左值对象在后续代码中可能还会被使用;如果接收到的是右值对象,则可以直接转移其资源,因为右值对象的生命周期短暂,后续无法再被访问。

因此,我们可以定义两个版本的拷贝构造函数:参数为常量左值引用的传统拷贝构造函数,以及参数为右值引用的新版本拷贝构造函数。这两个版本构成函数重载,参数为右值引用的版本专业上称为移动构造函数,其中的"移动"二字表明其会直接转移右值对象内部的资源。

理解了移动构造函数的概念后,就能明白STL库中的许多容器(如 vectorlist 等)也都支持移动构造函数。

在这里插入图片描述

在这里插入图片描述

除了拷贝构造函数可能接收右值对象外,赋值运算符重载函数同样可能接收右值对象。当接收到右值对象时,处理方式与移动构造函数类似——直接转移资源而非复制。

在这里插入图片描述


应用

通过前文的介绍,我们便能理解C++11为何煞费苦心地引入左值与右值以及左值引用和右值引用——正是为了支持移动构造函数和移动赋值运算符这"一碟醋",才包了这么多"饺子"。对于需要深拷贝的自定义类型,引入移动语义后,当传递右值时,不再需要传统方式中的先开辟空间再拷贝数据,而是可以直接转移资源(即指针赋值),这是一个常数级别的操作。这意味着移动构造函数和移动赋值运算符的时间复杂度为O(1),带来了大幅度的效率提升。

左值引用和右值引用还为我们提供了更多编程可能性:

左值引用的主要用途包括:作为函数的输出型参数(对形参的改变会影响实参);减少传值带来的拷贝开销;以及引用返回。对于引用返回,如果返回的是静态局部变量或堆上分配的对象,可以采用引用返回方式。

然而,大多数函数返回场景中,返回的是局部变量而非静态变量或堆对象。在这种情况下,移动构造函数和移动赋值函数就能发挥重要作用:

当返回函数中定义的局部变量时,必须采用传值返回而非引用返回,因为函数调用结束后,局部变量会随栈帧销毁,如果使用引用返回,将导致悬空引用。

在传统编译器中,返回局部变量时:对于内置类型,会构建一个中间临时变量,先将局部变量拷贝给临时变量,再由临时变量拷贝给外部接收变量;对于自定义类型,同样会构建中间临时变量,先调用临时变量的拷贝构造函数初始化临时变量,再调用外部接收对象的拷贝构造函数,总共需要两次拷贝构造调用。

这个过程的总开销包含两次深拷贝:从局部变量到中间临时变量,再从中间临时变量到外部接收对象。

虽然局部变量本身是左值(生命周期与函数作用域相同且可取地址),但在函数返回时,它即将被销毁,其内部资源也将随之消失。这种情况类似于拷贝构造函数接收右值的场景。

当return返回这个局部变量时,它的生命周期即将结束,后续代码无法再使用该变量。既然该变量注定要销毁,而我们又需要其内部资源,何不直接转移资源给外部接收对象,而非进行深拷贝?

而对于堆对象或静态对象,由于它们不会随函数栈帧销毁而销毁(生命周期与程序相同,会持续存在),必须采用深拷贝方式,因为这些对象后续还可能被继续使用。


但如前所述,函数中定义的局部变量是明确的左值对象,而要调用移动构造函数,需要的是右值对象。这时就需要引入C++标准库中的std::move函数。如果将左值对象传递给move函数,它会将其值类别转换为右值,并返回转换后的对象引用;如果传递的是右值对象,则返回不变的对象引用。

下面是一个示例函数fun,它返回一个局部变量,使用std::move将其转换为右值。在myclass类中,我在构造函数、拷贝构造函数、移动构造函数和析构函数中都添加了打印语句,方便观察执行过程:

#include <iostream>
#include <cstring> 
#include <utility> 

class myclass
{
public:
    size_t a;
    int* ptr;
myclass(size_t num = 10)
    : a(num)
    , ptr(new intnum)
{
    std::cout << "myclass init" << std::endl;
}

myclass(const myclass& val)
{
    a = val.a;
    ptr = new int[a]
    memcpy(ptr, val.ptr, a * sizeof(int)); 
    std::cout << "myclass copy " << std::endl;
}

myclass(myclass&& val) 
{
    a = val.a;
    ptr = val.ptr;
    val.ptr = nullptr;
    val.a = 0; // 确保移后源对象处于有效状态
    std::cout << "myclass move " << std::endl;
}

~myclass()
{
    delete ptr;
    ptr = nullptr;
    std::cout << "myclass destructor" << std::endl;
}
};

myclass fun()
{
    myclass temp(200);
    return std::move(temp); // 使用std::move将左值转换为右值
}

int main()
{
    myclass val = fun();
    std::cout << val.a << std::endl;
    return 0;
}

在这里插入图片描述

从运行结果可以看出:先调用构造函数初始化fun函数中的局部变量temp,然后调用移动构造函数。这里编译器进行了优化——理论上应该构建一个中间临时对象,调用临时对象的移动构造函数将temp的资源转移给临时对象,然后再调用一次移动构造函数将临时对象的资源转移给外部接收对象val。但编译器优化为直接调用一次外部接收对象val的移动构造函数完成初始化。


补充1:将亡值和RVO返回值优化

需要特别注意的是,代码中的 std::move 函数并不会改变原对象的值类别,它仅仅返回一个传递给它的对象的右值引用。如果源对象是左值,std::move 返回的是该左值的右值引用形式,而源对象本身的值类别保持不变。如果 std::move 真的改变了原对象的值类别(例如从左值变为右值),那么改变后的对象将无法被取地址。我们可以通过以下代码验证这一点:

#include <iostream>
int main()
{
    myclass val(100);
    std::move(val); // 调用move不会改变val的值类别
    std::cout << &val << std::endl; // 仍然可以成功取地址,证明val仍然是左值
    return 0;
}

在这里插入图片描述

因此,我们可以对右值进行更细致的分类。右值主要包括两种类型:

  1. 纯右值(prvalue):指天生就是右值的表达式,如字面量、匿名对象、算术表达式结果等。
  2. 将亡值(xvalue):指通过 std::move 或类似操作从左值转换而来的右值,或者是在某些特定表达式(如类型转换)中产生的右值。

将亡值这一名称非常形象地描述了这类右值的特性:它们原本是左值(具有持久生命周期),但通过 std::move 转换后,变成了右值引用,表示其资源可以被"移动",且生命周期通常仅限于当前完整表达式。这意味着 std::move 的返回值作为将亡值,其生命周期短暂,往往在表达式结束后就会被销毁,因此被称为"将亡"(即将消亡)的值。


这种方式避免了传统深拷贝的开销,因为在函数返回过程中仅涉及指针的设置和资源转移,效率较高。

事实上,编译器提供了一种更为高效的优化方式——返回值优化(Return Value Optimization, RVO)。

如果我们修改上面的代码,将fun函数改为普通的传值返回(不使用std::move),观察会出现什么结果:

myclass fun()
{
    myclass temp(200);
    return temp;
}

在运行之前,我们可以先进行理论推导:这里是传值返回,按照传统理解,编译器应该先调用fun函数中局部变量temp的构造函数初始化temp,然后调用拷贝构造函数。编译器可以进行优化,不构建中间临时变量,而是直接调用接收返回值的val对象的拷贝构造函数或赋值运算符重载函数,将值拷贝给外部的val对象。因此预期输出应该是一次构造函数、一次拷贝构造/赋值操作,以及两次析构函数(先析构fun中的局部变量temp,再析构外部接收对象val)。

在这里插入图片描述

然而,实际运行结果显示:只调用了一次构造函数和一次析构函数,且这次构造函数实际上是外部接收返回值对象的构造函数。

编译器采用的返回值优化(RVO)机制如下:在调用函数之前,编译器会预先为接收函数返回值的对象预留内存空间,并将该对象的地址隐式传递给被调用函数。编译器会识别被调用函数中作为返回值的局部变量,并将其替换为外部接收函数返回值的对象。当定义该局部变量时,实际上直接调用了外部接收对象的构造函数,在被调用函数内部完成外部对象的初始化。这意味着作为返回值的局部变量实际上并没有被创建或存在过。

后续在被调用函数中对"局部变量"的所有操作和修改,实际上都是直接对外部接收对象本身进行的操作。函数的return语句在这种情况下变得没有实际意义,因为外部对象已经在函数内部完成了初始化,且没有创建真正的局部变量。

我们可以通过代码验证这一点:在构造函数中打印this指针(指向对象的地址),在函数调用结束后打印外部接收对象的地址,比较两者是否相同:

#include <iostream>
class myclass
{
public:
    size_t a;
    int* ptr;
    myclass(size_t num = 10)
        : a(num)
        , ptr(new int[num])
    {
        std::cout << "myclass init" << "  this:" << this << std::endl;
    }
    // 其他成员函数定义...
};

int main()
{
    myclass val = fun();
    std::cout << &val << std::endl;
    return 0;
}

在这里插入图片描述

根据运行结果,可以看到两个地址完全相同。这表明之前打印的构造函数语句调用的不是fun中局部对象temp的构造函数,而是外部接收函数返回值对象val的构造函数。

现在我们可以对比返回值优化和移动构造函数这两种方式的效率:

  • 采用移动构造函数方式:会调用一次构造函数(创建局部变量)和一次移动构造函数(转移资源),以及两次析构函数(清理局部变量和最终对象)
  • 采用返回值优化方式:只调用一次构造函数(直接初始化外部对象)和一次析构函数(清理最终对象)

虽然移动构造函数的开销已经很小(主要是指针交换操作),但返回值优化仍然略胜一筹,因为它完全避免了局部变量的创建和销毁过程,减少了析构函数调用的开销。


需要明确的是,编译器并非在所有情况下都能触发返回值优化。RVO的第一个前提条件是接收返回值的对象必须是新创建的。在上文的示例中,我们使用函数返回值直接初始化新创建的对象val。然而,如果val对象在函数调用前已经存在并初始化,编译器就无法应用RVO技术。

#include<iostream>
class myclass
{
public:
    size_t a;
    int* ptr;
    myclass& operator=(const myclass& val)
    {
        a = val.a;
        ptr = new int[a];  
        memcpy(ptr, val.ptr, a * sizeof(int));  
        std::cout << "myclass operator=" << std::endl;
        return *this;
    }
    // 其他成员函数...
};

myclass fun()
{
    myclass temp(200);
    return temp;
}

int main()
{
    myclass val;         // 对象已构造完成
    val = fun();         // 无法使用RVO,只能调用赋值运算符
    return 0;
}

在这里插入图片描述

在这种情况下,由于val对象已经完成初始化,编译器无法直接在函数内部调用其构造函数(否则会导致资源泄漏),只能调用赋值运算符重载函数,此时返回值优化无法生效。

其次,即使使用新创建的对象接收返回值,也不一定能触发RVO。当函数中包含条件判断语句时,编译器在编译期无法确定具体哪个局部变量将被返回,因此无法实施优化:

myclass fun(int x)
{
    return x == 100 ? myclass(100) : myclass(200);
}

int main()
{
    myclass val = fun(300);  // 多返回路径可能阻止RVO
    return 0;
}

这种情况下,移动构造函数的价值就体现出来了——它为编译器提供了备选方案。当RVO无法实施时,移动构造确保了性能不会大幅下降。此外,如果返回的局部变量类型与函数返回值类型不匹配,也会阻止RVO的触发。

对于已存在对象接收返回值的情况,虽然函数返回的局部变量本质上是左值,但如果类中定义了移动赋值运算符,编译器会自动进行隐式转换,将其视为将亡值(xvalue),这本身也是一种优化:

#include<iostream>
class myclass
{
public:
    size_t a;
    int* ptr;
    myclass& operator=(const myclass& val)  // 拷贝赋值
    {
        a = val.a;
        ptr = new inta;
        memcpy(ptr, val.ptr, a * sizeof(int));
        std::cout << "myclass  operator=" << std::endl;
        return *this;
    }
    myclass& operator=(myclass&& val) noexcept  // 移动赋值
    {
        a = val.a;
        ptr = val.ptr;      // 资源转移
        val.ptr = nullptr;  // 置空源对象指针
        std::cout << "myclass move operator=" << std::endl;
        return *this;
    }
    // 其他成员函数...
};

myclass fun()
{
    myclass temp(200);
    return temp;
}

int main()
{
    myclass val;
    val = fun();  // 自动选择移动赋值而非拷贝赋值
    return 0;
}

在这里插入图片描述

补充2:新增的默认成员函数

那么我们理解了移动构造函数以及移动赋值运算符重载函数。C++11在此方面做了重要更新,在类原有的六个默认成员函数基础上新增了两个。这六个原有的默认成员函数分别是:默认构造函数、拷贝构造函数(执行浅拷贝)、拷贝赋值运算符重载函数、取地址运算符重载函数、const版本取地址运算符重载函数,以及析构函数。

C++11在这6个默认成员函数的基础上新增了两个默认成员函数:移动构造函数和移动赋值运算符重载函数。编译器默认生成的移动构造函数和移动赋值运算符的底层实现原理,与我们手动编写的标准实现原理是一致的:

  • 对于内置类型的成员变量(即不需要深拷贝的资源),直接进行值拷贝(浅拷贝)。
  • 对于需要深拷贝的资源(通常是指向堆空间的指针),编译器生成的移动操作会执行以下步骤:将源对象(右值引用)的指针值拷贝到目标对象对应的指针成员,然后将源对象的该指针成员置为空(nullptr)。这实现了资源所有权的转移,避免了深拷贝的开销。

有的读者可能会产生疑问:既然编译器默认生成的移动操作与手动编写的逻辑基本相同,这是否意味着我们不需要显式定义移动构造函数和移动赋值运算符了呢?

事实上,编译器自动生成默认移动操作的条件十分苛刻。具体规则是:该类必须没有显式声明拷贝构造函数、移动构造函数、移动赋值运算符和析构函数中的任何一个。如果用户显式定义了这些函数中的任何一个,编译器就不会自动生成默认的移动操作。在这种情况下,如果需要移动语义,我们必须手动编写移动构造函数和移动赋值运算符。

这里存在一个看似矛盾的现象:移动语义主要针对的是那些需要深拷贝的类(例如管理动态内存的类)。然而,一个需要深拷贝的类,必然需要用户显式定义构造函数(可能负责资源分配)、拷贝构造函数(实现深拷贝)、拷贝赋值运算符(实现深拷贝并安全释放原有资源)以及析构函数(负责资源释放)。而一旦用户定义了这些函数,根据上述规则,编译器就无法自动生成移动操作了。

因此,实际情况往往是:编译器能够自动生成默认移动操作的类(通常只包含内置类型或具有正确移动语义的类类型成员),由于本质是浅拷贝,移动操作与拷贝操作在效率上差异不大,对移动语义的需求反而不强烈。反之,那些最需要移动语义来提升性能的类(需要深拷贝的类),编译器却无法为其自动生成移动操作。

为了解决这个问题,C++11引入了default关键字。这个关键字专门用于指示编译器生成某个默认成员函数的实现,它只能用于那八个默认成员函数(包括新增的两个移动操作)。将= default置于函数声明之后,即使当前类的定义情况(例如用户定义了析构函数)本应抑制该默认成员函数的自动生成,这个声明也会强制编译器生成该函数的默认版本。

MyClass(MyClass&& val) = default; 
MyClass& operator=(MyClass&& val) = default;

最后,我们知道既可以通过default关键字强制编译器生成默认成员函数,也可以选择手动定义。读者可能会面临选择困难:究竟是应该始终让编译器生成移动操作,还是自己编写呢?

这里的建议是优先使用= default,即优先依赖编译器生成的默认移动操作。原因如下:

  1. 正确性:编译器生成的默认移动操作行为是正确的。它对内置类型进行值拷贝;对类类型成员,会调用该成员自身的移动操作(如果可用);对指针成员,能正确实现所有权转移(拷贝指针值并将源对象指针置空)。
  2. 效率与可维护性:这能减少我们的编码负担,降低出错概率,并使代码更简洁、更易于维护。

只有当你的类中存在某些成员,其移动行为不能采用上述标准方式时(例如,需要特殊的资源管理逻辑或额外的副作用),才需要自定义移动构造函数和移动赋值运算符。


万能引用

在理解了左值和右值的基本概念后,我们知道对于普通类可以编写参数为右值引用的构造函数(移动构造函数),通过直接转移右值对象的资源来避免深拷贝的开销。

同样地,模板类也应当支持移动语义,这意味着我们需要为模板类定义参数为右值引用的移动构造函数和移动赋值运算符。假设我们定义了一个模板类myclass,其模板参数为T,那么如何定义参数为右值引用的移动构造函数呢?

许多读者会自然地联想到,对于模板类或模板函数,右值引用的形式是在模板参数后面添加两个取地址符&&,如下所示:

template<typename T>
class myclass
{
public:
    size_t num;
    T* ptr;
// 移动构造函数
myclass(myclass&& val) noexcept
{
    num = val.num;
    ptr = val.ptr;
    val.ptr = nullptr;
}

// 其他成员函数...
};

事实上,对于模板函数和模板类来说,这种形式的参数声明确实能够实现右值引用功能,并且这种语法有一个专业术语——万能引用(universal reference),这是C++11引入的新特性。

之所以称之为"万能引用",是因为这种引用形式可以根据上下文被实例化为左值引用或右值引用,具有极大的灵活性。

在万能引用出现之前(C++11之前的版本),模板函数或模板类中只能定义普通的左值引用或常量左值引用。早期版本的编译器在实例化模板函数时存在一个问题:无法精确推导模板参数的类型。

当我们调用模板函数时,编译器会根据传递的实参推导出数据类型并实例化模板函数。如果传递的实参是引用类型,编译器会忽略实参的引用属性,只推导出引用所绑定对象的实际类型:

#include<iostream>
template<typename T>
void fun(T ref)
{
    ref += 10;
}

int main()
{
    int a = 10;
    int& ref = a;
    fun(ref);        // T被推导为int,而非int&
    std::cout << a << std::endl;  // 输出10,证明是传值调用
    return 0;
}

在这里插入图片描述

如果编译器根据传递的实参推导出的数据类型是int&,那么对形参的修改应该会影响原始变量。但实际结果显示a的值没有改变,证明T被实例化为int而非int&,表明这是传值调用。

解决这个问题的方法是在模板参数后面添加引用符号&,这样虽然编译器推导的类型会忽略引用属性,但添加的&符号会确保最终实例化的模板函数参数变为引用类型。

此外,编译器在推导实参类型时还会忽略实参本身的const属性:

#include<iostream>
template<typename T>
void fun(T ref)
{
    ref += 10;       // 如果T被推导为const int,此操作应非法
    std::cout << ref << std::endl;
}

int main()
{
    const int a = 10;
    fun(a);          // T被推导为int,而非const int
    return 0;
}

在这里插入图片描述

如果编译器没有忽略实参的const属性,T应该被推导为const int,对ref的修改应该是非法的。但实际代码能够编译运行,证明T被推导为int

了解了没有万能引用时的编译器推导规则后,我们来看当使用万能引用时,编译器的行为会发生什么变化。当我们定义了一个使用万能引用的模板函数:

template<typename T>
void fun(T&& ref)  // 万能引用
{
    // 函数体
}

调用此模板函数时,编译器会采用特殊的推导规则:

  • 如果传递的实参是左值(无论是变量还是左值引用),编译器会将T推导为左值引用类型T&
  • 如果传递的实参是右值或右值引用,编译器会将T推导为普通类型T

这种特殊推导规则的原因在于引用折叠机制:

  • T被推导为T&时,T&&会折叠为T&(左值引用)
  • T被推导为T时,T&&保持为T&&(右值引用)

这种机制使得万能引用能够根据传入实参的值类别自动适配为左值引用或右值引用,同时保留对象的const属性,为模板编程提供了极大的灵活性。

并且这里还不会忽略对象本身或者引用所绑定的对象携带的const属性,我们也可以写代码来验证这一点:

#include<iostream>
template<typename T>
void fun(T&& temp)
{
	temp++;
}
int main()
{
	int a = 10;
	const int& ref = a;
	fun(ref);
	return 0;
}

在这里插入图片描述

所以这里我们就能明白为什么称其为万能引用,那么就是因为其既能被实例化为左值引用,也能被实例化右值引用,但是万能引用的内容还没有完,其有一个巨大的坑,那么这个坑就和我接下来要讲的完美转发有关

完美转发

在理解了万能引用的概念后,我们知道万能引用可以根据上下文被实例化为左值引用或右值引用。这引出了一个重要的应用场景:

假设我们定义了一个模板函数,其参数为万能引用,且该函数内部需要调用其他重载函数。当我们传递右值实参给这个模板函数时,万能引用会被实例化为右值引用类型,并绑定到该右值,从而延长其生命周期至与引用相同。

如果被调用的外部函数有多个重载版本(分别接受左值引用和右值引用),我们可能期望根据传递实参的值类别来选择相应的重载函数。让我们通过以下代码验证这一行为:

#include<iostream>
void fun(int& ref)
{
    std::cout << "fun(ref&)" << std::endl;
}
void fun(int&& ref)
{
    std::cout << "fun(ref&&)" << std::endl;
}
template<typename T>
void Fun(T&& val)
{
    fun(val);
}
int main()
{
    Fun(20);  // 传递右值
    return 0;
}

在这里插入图片描述

运行结果显示,模板函数内部调用的是参数为左值引用的fun函数,而非右值引用版本。这一结果可能令人困惑:既然模板函数参数被实例化为右值引用并绑定右值对象,为何不调用右值引用版本的重载函数?

要解释这一现象,需要明确区分类型与值类别的概念:右值引用是一种引用类型,但其值类别是左值。正因为右值引用本身是左值,它只能绑定到左值引用参数,这就解释了为什么调用的是左值引用版本的函数。

为什么C++将右值引用的值类别定义为左值?这需要从左值和右值的本质特性来理解:左值表示具有持久生命期的对象(与所在作用域共存),而右值(包括纯右值和将亡值)的生命期仅限于当前完整表达式。如果右值引用的值类别是右值,那么它只能在定义它的表达式中存在,这将无法支持后续的资源访问和转移操作(如移动构造函数中的资源移动)。因此,右值引用必须具有左值值类别,以确保其在整个作用域内可用。

既然右值引用的值类别是左值,我们如何实现调用右值引用版本函数的需求呢?这就需要引入完美转发机制:

完美转发通过std::forward模板函数实现,其核心作用是保持参数的值类别。如果传递给std::forward的是右值引用,它会返回一个保持右值值类别的右值引用;如果传递的是左值引用,则返回保持左值值类别的左值引用(只设计值类别的改变,不涉及构造)

使用示例:

#include<iostream>
void fun(int& ref)
{
    std::cout << "fun(ref&)" << std::endl;
}
void fun(int&& ref)
{
    std::cout << "fun(ref&&)" << std::endl;
}
template<typename T>
void Fun(T&& val)
{
    fun(std::forward<T>(val));  // 完美转发保持值类别
}
int main()
{
    Fun(20);  // 现在会调用fun(int&&)
    return 0;
}

在这里插入图片描述

关于std::forward需要显式指定模板参数的原因:std::forward是一个模板函数,它需要知道原始参数的值类别信息。当模板函数参数为万能引用时,编译器会根据实参值类别进行特殊推导:

  • 对于左值实参,T被推导为T&
  • 对于右值实参,T被推导为T

通过显式传递模板参数Tstd::forward能够获取到原始值类别信息,并据此返回相应值类别的对象。这就是为什么std::forward能够实现完美转发的关键技术机制。

补充:支持移动语义的STL容器的相关函数接口

基于上文对移动构造函数和移动赋值运算符的理解(它们能够避免不必要的深拷贝),我们观察到 STL 容器中的许多成员函数(如 push_back)也提供了接受右值引用的重载版本。那么,为什么需要提供这种右值引用版本的 push_back 呢?

vector的push_back:

在这里插入图片描述

核心原因在于效率优化,特别是当容器存储的元素类型是需要深拷贝的自定义类型时。 push_back 操作插入到容器中的元素,其值类别(value category) 既可以是左值(例如具名对象),也可以是右值(例如临时对象或显式 std::move 的结果)。

当向容器插入一个右值时,容器可以利用移动语义来避免昂贵的深拷贝:

  1. 对于底层是数组的容器(如 std::vector):

    • 插入操作(可能发生在尾部添加或中间插入导致扩容时)需要将元素放入数组的指定位置。
    • 如果传入的是右值,容器会尝试使用该元素的移动构造函数来构造数组中的新元素,从而直接转移(而非复制)右值所拥有的资源(如动态内存),避免了深拷贝的开销。
  2. 对于底层是链表的容器(如 std::list, std::forward_list):

    • 插入操作需要构造一个新的节点。
    • 如果传入的是右值,容器会使用该元素的移动构造函数来初始化新节点中存储的元素数据,同样避免了深拷贝。

然而,这里存在一个关键点:在 push_back 函数内部,接收到的右值引用参数(例如 T&& val)本身是一个左值表达式(它有名字 val)。 如果直接将 val 传递给元素类型的构造函数(无论是拷贝构造还是移动构造),编译器会将其视为左值,从而可能调用拷贝构造函数(这不是我们期望的)。

为了解决这个问题,std::forward(完美转发)就可以登场了:

  • 如果 push_back 最初接收的是一个右值,std::forward 会将其转换成一个右值表达式(xvalue),从而确保调用元素类型的移动构造函数来转移资源。
  • 如果 push_back 最初接收的是一个左值(通过 push_back(const T&) 重载),std::forward 则保持其为左值,调用拷贝构造函数。

因此,右值引用版本的 push_back 配合内部的 std::forward(完美转发),使得容器能够在插入右值时高效地利用移动语义,避免深拷贝,显著提升性能,尤其是在处理大型或资源密集型对象时。

lambda表达式

Lambda 表达式是 C++11 中引入的一项重要语法特性。 在正式学习 Lambda 表达式之前,需要回顾已学过的两种可调用对象:函数指针和函数对象(仿函数)。可调用对象指的是可以像调用函数一样使用的实体,例如函数指针和函数对象。

对于函数指针,它指向在全局作用域中定义的普通函数或静态成员函数。函数指针的语法相对复杂,其声明必须与所指向函数的声明完全匹配。

对于函数对象(仿函数),需要定义一个类,并在该类中重载 operator()。定义该类的目的就是为了调用其内部的 operator() 函数。因此,通常需要实例化一个轻量级的函数对象(通常不含成员变量),然后调用该对象的 operator()

然而,上述两种可调用对象都存在一个局限性:它们无法直接访问其定义点所在作用域中的局部变量。因为函数指针指向的函数位于全局作用域,只能访问全局变量或静态变量,无法访问函数指针本身所处作用域(如某个函数内部)的局部变量。

理论上,仿函数对象可以访问其定义点所在作用域中的局部变量,但这需要在该仿函数对象对应的类中定义相应类型的成员变量,并通过构造函数将需要访问的局部变量传入以初始化这些成员变量。这样,类中的 operator() 函数就能通过成员变量间接访问外部局部变量。但这种方式不够直接且增加了编码负担。

C++11 引入的新语法——Lambda 表达式——解决了上述问题。首先介绍 Lambda 表达式的基本语法结构:

[捕获列表](参数列表)->返回值{函数体}

接下来,通过一个具体的使用案例来理解 Lambda 表达式的语法和定义方式:

假设需要定义一个实现两个 int 类型数相加的功能。如果使用可调用对象来实现:

函数指针方式:首先定义一个全局函数执行加法,然后定义函数指针指向该函数,最后调用函数指针。

#include <iostream>
int add(int x, int y) {
    return x + y;
}
int main() {
    int (*Add)(int, int) = add; // 声明并初始化函数指针
    std::cout << Add(10, 20) << std::endl; // 调用函数指针
    return 0;
}

函数对象方式:定义一个包含 operator() 重载的类,该函数接收两个 int 参数并返回它们的和。然后实例化该类对象并调用 operator()

#include <iostream>
class AddNum {
public:
    int operator()(int x, int y) {
        return x + y;
    }
};
int main() {
    AddNum add; // 实例化函数对象
    std::cout << add(10, 20) << std::endl; // 调用函数对象的 operator()
    return 0;
}

Lambda 表达式方式:使用 Lambda 表达式实现相同的加法功能。

#include <iostream>
int main() {
    auto add =  -> int { return x + y; }; // 定义 Lambda 表达式
    std::cout << add(10, 20) << std::endl; // 调用 Lambda 表达式
    return 0;
}

在这里插入图片描述

对比前两种方式,Lambda 表达式最直观的优势是代码更简洁。 观察其结构,除了捕获列表 ,参数列表、返回值类型( -> int )和函数体 {…} 的写法与普通函数定义非常相似。 返回值类型可以省略,编译器能够根据函数体中的 return 语句自动推导返回类型:

#include <iostream>
int main() {
    auto add = [](int x, int y) { return x + y; }; // 省略返回值类型
    std::cout << add(10, 20) << std::endl;
    return 0;
}

在这里插入图片描述

但是,如果函数体包含多个返回路径且返回类型不一致,编译器将无法推导返回类型:

#include <iostream>
int main() {
    auto add = [](int x, int y, int z) {
        if (z == 100) {
            return x + 20.9; // 返回 double
        } else {
            return x + y;    // 返回 int
        }
    };
    std::cout << add(10, 20, 100) << std::endl; // 编译错误:返回类型不一致
    return 0;
}

在这里插入图片描述

现在讨论捕获列表. 如前所述,函数指针和函数对象无法直接访问其定义点所在作用域中的局部变量。Lambda 表达式的主要优势在于能够直接访问其定义点上方(即同一作用域内)定义的局部变量。 访问方式是通过捕获列表声明需要捕获的变量名。Lambda 表达式只能捕获其定义点之前(上方)定义的变量,无法捕获其后(下方)定义的变量。

例如,访问 Lambda 表达式上方定义的两个 int 变量 a 和 b :

#include <iostream>
int main() {
    int a = 1000;
    int b = 55;
    auto add = [a, b](int x, int y) { // 捕获列表捕获 a 和 b
        std::cout << "a=" << a << " b=" << b << std::endl; // 访问捕获的变量
        return x + y;
    };
    std::cout << add(10, 20) << std::endl;
    return 0;
}

在这里插入图片描述

需要注意:默认情况下,通过值捕获(如 a, b )的变量在 Lambda 函数体内是 const 的,即只能读取不能修改:

#include <iostream>
int main() {
    int a = 1000;
    int b = 55;
    auto add = [a, b](int x, int y) {
        a++; // 编译错误:a 是 const
        b++; // 编译错误:b 是 const
        std::cout << "a=" << a << " b=" << b << std::endl;
        return x + y;
    };
    std::cout << add(10, 20) << std::endl;
    return 0;
}

在这里插入图片描述

如需修改值捕获的变量,需要在参数列表后添加 mutable 关键字:

#include <iostream>
int main() {
    int a = 1000;
    int b = 55;
    auto add = [a, b](int x, int y) mutable { // 添加 mutable
        a++; // 允许修改(副本)
        b++; // 允许修改(副本)
        std::cout << "a=" << a << " b=" << b << std::endl; // 输出修改后的副本值
        return x + y;
    };
    add(10, 20);
    std::cout << "a=" << a << " b=" << b << std::endl; // 输出原始值,未改变
    return 0;
}

在这里插入图片描述

值捕获在 Lambda 函数体内对变量的修改仅作用于其副本,不影响外部原始变量。

若需要在 Lambda 内部修改外部局部变量,必须使用引用捕获。 引用捕获在变量名前加 & (如 &a, &b )。引用捕获的变量在 Lambda 函数体内不是 const :

#include <iostream>
int main() {
    int a = 1000;
    int b = 55;
    auto add = [&a, &b](int x, int y) { // 引用捕获 a 和 b
        a++; // 修改外部变量 a
        b++; // 修改外部变量 b
        std::cout << "a=" << a << " b=" << b << std::endl;
        return x + y;
    };
    add(10, 20);
    std::cout << "a=" << a << " b=" << b << std::endl; // 输出已改变的值
    return 0;
}

在这里插入图片描述

如果上方定义的变量较多,不想逐一列出,Lambda 支持隐式捕获:

[=] :以值捕获方式捕获所有上方局部变量(等同于显式列出所有变量)。

#include <iostream>
int main() {
    int a = 1000;
    int b = 55;
    auto add = [=](int x, int y) mutable { // 值捕获所有上方变量
        a++; // 修改副本
        b++; // 修改副本
        std::cout << "a=" << a << " b=" << b << std::endl;
        return x + y;
    };
    add(10, 20);
    std::cout << "a=" << a << " b=" << b << std::endl; // 原始值不变
    return 0;
}

在这里插入图片描述

[& ]:以引用捕获方式捕获所有上方局部变量。

#include <iostream>
int main() {
    int a = 1000;
    int b = 55;
    auto add = [&](int x, int y) { // 引用捕获所有上方变量
        a++; // 修改外部变量
        b++; // 修改外部变量
        std::cout << "a=" << a << " b=" << b << std::endl;
        return x + y;
    };
    add(10, 20);
    std::cout << "a=" << a << " b=" << b << std::endl; // 值已改变
    return 0;
}

在这里插入图片描述

C++11 还支持混合捕获: 可以默认以值捕获所有变量,但对某些变量使用引用捕获;或者默认以引用捕获所有变量,但对某些变量使用值捕获。只需在捕获列表中显式列出需要特殊处理的变量即可。

#include <iostream>
int main() {
    int a = 1000;
    int b = 55;
    // 默认引用捕获所有,但 b 显式指定为值捕获
    auto add = [&, b](int x, int y) mutable {
        a++; // 引用捕获,修改外部 a
        b++; // 值捕获,修改副本 b
        std::cout << "a=" << a << " b=" << b << std::endl;
        return x + y;
    };
    add(10, 20);
    std::cout << "a=" << a << " b=" << b << std::endl; // a 改变,b 不变
    return 0;
}

在这里插入图片描述

至此,Lambda 表达式的主要语法已介绍完毕。 读者可能会疑惑:为什么前面的示例都使用 auto 定义的变量来存储 Lambda 表达式后再调用,而不是直接调用?这与 Lambda 表达式的底层实现原理有关。

Lambda 表达式本质上是一种匿名函数对象的简写形式。 当编译器遇到一个 Lambda 表达式时,它会生成一个唯一的、编译器命名的类类型(称为闭包类型)。 这个类包含:

1.一个 operator() 成员函数: 其参数列表、返回类型(根据 Lambda 的返回值声明或推导)和函数体均与 Lambda 表达式定义一致。

2.成员变量: 根据捕获列表,编译器会生成对应的成员变量。对于值捕获(如 a ),成员变量类型为 const T ( T 是 a 的类型),除非 Lambda 被声明为 mutable (此时无 const )。对于引用捕获(如 &b ),成员变量类型为 T& ( T 是 b 的类型)。

3.一个构造函数: 用于初始化这些成员变量(将捕获的外部变量或引用传入)。

编译器随后会创建一个该闭包类型的匿名对象(一个函数对象)。 使用 auto 变量(如 auto add = …; )接收 Lambda 表达式,实质上是让编译器自动推导这个闭包类型,并用该匿名对象初始化 add 变量(发生拷贝或移动)。 之后调用 add(…) 就是在调用该闭包类型对象的 operator() 。

  • 而这里我们也可以用代码来验证,我们定义的这行lambda表达式,会被转化为匿名仿函数对象,这里我们可以用auto的左值引用去接收lambda表达式:

    auto& add = [](int x, int y)mutable{ return x + y;};
    

在这里插入图片描述

但是用右值引用就没有任何问题,那么我们可以验证编译器会将lambda表达式转化为匿名对象

auto&& add = [](int x, int y)mutable{ return x + y;};

其次我们可以使用 typeidtype_info::name() 查看编译器生成的闭包类型名称(注意:名称由编译器决定,无跨平台保证):

#include <iostream>
#include <typeinfo>
int main() {
auto add1 =  { return x + y; };
auto add2 =  { return x + y; };
std::cout << typeid(add1).name() << std::endl; // 输出编译器生成的类型名
std::cout << typeid(add2).name() << std::endl; // 通常 add1 和 add2 类型不同
return 0;
}

在这里插入图片描述

可变模版参数

在正式讲解可变模板参数之前,需要回顾 C 语言中的可变参数概念。

在 C 语言中定义函数时,需要声明函数原型,包括函数名、参数列表和返回值类型。函数声明确定了其接受的参数类型和数量是固定的。 若希望一个函数能灵活处理不同类型和数量的参数,一种方法是使用函数重载。但函数重载需要为每种参数组合编写独立的函数定义,增加了编码负担。

另一种方法是使用可变参数。在函数参数列表中使用 ...(三个点)表示该函数接受可变数量的参数。

void fun(int num,...);

但 C 语言的可变参数有一个限制:传递的所有参数必须是相同类型(或兼容类型)。 printfscanf 是典型的支持可变参数的库函数。

C 语言的可变参数不是本文重点(感兴趣的读者可自行研究),提及它是为了引入 C++ 中的可变模板参数。

C 语言引入可变参数是为了提高函数处理数据的灵活性,使其能处理任意数量的参数(类型需相同)。类似地,在 C++ 模板编程中,模板类或模板函数在实例化后,其处理的数据类型和数量由其模板参数决定。

然而,我们有时希望模板类或模板函数能处理任意类型和任意数量的数据,而不仅限于预定义的固定模板参数。可变模板参数(Variadic Templates)正是为此设计,它极大地增强了模板的灵活性。

首先介绍如何声明接收可变模板参数的模板类或模板函数:

template<typename... Args> // Args 是模板参数包(Template Parameter Pack)

这里,typename(或 class)关键字后跟 ... 和参数名 Args,表示 Args 是一个模板参数包(Template Parameter Pack)。Args 被称为“包”(Pack),因为它代表一组(零个或多个)类型参数。

接着看如何声明带有可变模板参数的模板函数:

template<typename... Args>
void fun(Args ...args) // args 是函数参数包(Function Parameter Pack)

该模板函数声明接收一个函数参数包(Function Parameter Pack) argsArgs 是类型包,args 是值包。

使用带有可变模板参数的模板函数时,通常需要一个递归展开机制。 为此,模板函数的参数列表通常包含一个固定参数(用于提取首个参数)和一个参数包(接收剩余参数):

template<typename T, typename... Args> // T 是固定模板参数,Args 是模板参数包
void Fun(T val, Args ...args)          // val 是固定参数,args 是函数参数包

这种设计的原因在于:编译器需要一种机制来逐个处理参数包中的参数(递归展开)。

当调用该模板函数时,编译器会推导实参类型:

  1. 第一个实参的类型用于实例化固定模板参数 T
  2. 剩余实参的类型被“打包”到模板参数包 Args 中。
  3. 剩余实参的值被“打包”到函数参数包 args 中。

示例调用:

Fun(10, 87, 99.9, "xxxxxxxx"); // 调用 Fun

第一个实参 10 的类型 int 实例化 T,值 10 传递给 val

  • 剩余实参的类型 (int, double, const char*) 被打包到 Args
  • 剩余实参的值 (87, 99.9, "xxxxxxxx") 被打包到 args

类型包 Args 和函数参数包 args 本质上都是编译期的占位符。 它们告诉编译器该函数将接收一串任意类型和数量的参数。编译器在实例化函数模板时,会将这些占位符“展开”为具体的类型和参数,而这每个参数的参数名是由编译器自己设置。

未实例化之前:

void Fun(T val,args ...Args)
{

}

​ 实例化之后,占位符被展开:

void Fun(int val,int _arg0,double _arg1,const char* _arg3){//形参名由编译器自己决定以及生成
    
}

正是由于我们不知道编译器将占位符展开得到的具体类型的参数的参数名,导致我们无法直接访问参数包内部单个元素,因此需要递归机制来展开:

template<typename T, typename... Args>
void Fun(T val, Args ...args) {
    std::cout << val << std::endl; // 处理当前参数 (val)
    Fun(args...); // 递归调用:展开参数包 (args...)
}

递归过程:

  1. 每次调用 Fun,固定参数 T val 接收参数包 args... 中的第一个参数。
  2. 剩余参数再次形成一个新的参数包 args...,传递给下一次递归调用。
  3. 这个过程就像从一个袋子(参数包)中逐个取出参数,直到袋子为空。

必须定义一个递归终止函数来处理参数包为空的情况(即只剩一个参数):

template<typename T>
void Fun(T val) { // 递归终止函数
    std::cout << val << std::endl;
}

完整示例代码:

#include <iostream>

// 递归终止函数 (处理最后一个参数)
template<typename T>
void Fun(T val) {
    std::cout << val << std::endl;
}

// 可变参数模板函数 (递归展开)
template<typename T, typename... Args>
void Fun(T val, Args ...args) {
    std::cout << val << " "; // 打印当前参数
    Fun(args...);            // 递归调用,展开剩余参数包
}

int main() {
    Fun(10, 87, 99.9, "xxxxxxxx"); // 调用可变参数函数
    return 0;
}

在这里插入图片描述

模版函数调用链:

// 第一次展开
Fun<int, int, double, const char*>(10, 87, 99.9, "xxxxxxxx");

// 递归调用(编译器重写)
Fun<int, double, const char*>(87, 99.9, "xxxxxxxx");

// 再次递归
Fun<double, const char*>(99.9, "xxxxxxxx");

// 最后一次递归
Fun<const char*>("xxxxxxxx");

注意每一次递归展开,编译器都会先实例化独立的不同声明的函数模版,会导致代码膨胀

emplace_back

在引入可变模板参数后,C++ STL 容器中的许多接口(如 emplace_back)也定义了支持可变模板参数的版本。 emplace_back 函数的作用是在容器底层数据结构的尾部就地构造(emplace) 一个新元素。

由于 emplace_back 支持可变模板参数,我们可以向其传递任意类型、任意数量的实参。 当容器存储的元素类型是自定义类型时,向 emplace_back 传递实参有两种主要方式:

  1. 传递完整对象:

    • 可以传递左值(具名对象)或右值(临时对象或 std::move 的结果)。
    • 底层处理逻辑:
      • 数组容器(如 std::vector): 在数组尾部直接调用元素的拷贝构造函数(传递左值时)或移动构造函数(传递右值时)构造新元素。
      • 链表容器(如 std::list): 在链表尾部创建新节点,并在节点数据域中调用元素的拷贝构造函数(左值)或移动构造函数(右值)初始化元素。
  2. 传递构造参数:

    • 直接传递构造元素所需的参数(而非完整对象)。
    • 底层处理逻辑(核心优势):
      • 容器利用可变模板参数和完美转发(std::forward),将这些参数直接传递给元素类型的构造函数。
      • 元素在容器内部就地构造(in-place construction),完全避免了创建临时对象及其后续的拷贝或移动操作。
      • 示例: 若元素类型是 std::tuple<int, std::string>,调用 vec.emplace_back(42, "hello") 会直接在 vector 内存中调用 tuple 的构造函数 tuple(int, const char*)

emplace_back 的核心价值在于第二种方式: 通过将构造参数直接传递给元素的构造函数,它实现了最高效的就地构造。这种方式:

  1. 完全避免了临时对象的创建(即使临时对象可通过移动语义优化)。
  2. 消除了所有不必要的拷贝或移动操作。
  3. 特别高效:尤其当元素类型构造代价高昂(如需要分配资源)或不可拷贝/移动时。

包装器

本文讲解的最后一个 C++ 语法特性是包装器(std::function)。 上文介绍了 lambda 表达式,我们知道其本质是编译器生成的匿名函数对象,底层对应一个闭包类(closure type)。由于编译器生成的闭包类类型名对程序员不透明,我们只能借助 auto 关键字(自动类型推导)来定义变量并存储 lambda 表达式对应的匿名对象,再通过该变量调用闭包类的 operator()

容器可以存储各种类型的元素(内置类型和自定义类型)。对于可调用对象,我们可以定义特定类型的容器:

  • 存储函数指针的 vector(如 std::vector<int(*)(int, int)>
  • 存储函数对象(仿函数)的 vector(如 std::vector<Addnum>

然而,我们无法定义存储 lambda 表达式闭包类型的容器(如 std::vector<???>),因为闭包类型名由编译器生成且不可知。 这正是包装器 std::function 诞生的原因。

包装器 std::function 的作用是提供统一的类型擦除机制,用于存储各种可调用对象:

  • Lambda 表达式(及其闭包类型)
  • 函数对象(仿函数)
  • 函数指针
  • 成员函数指针(需配合 std::bind
  • 其他可调用实体

包装器的语法基于标准库的 function 模板类(需包含 <functional> 头文件):

cpp
include
std::function<返回值类型(参数类型列表)> 对象名;

std::function 的模板参数声明方式特殊:

  • 普通类模板在 <> 中指定具体类型(如 std::vector<int>
  • std::function<> 中指定函数签名(返回值类型 + 参数类型列表),类似函数声明但省略函数名

示例:

std::function<int(int, int)> func; // 可存储任何返回 int 并接受两个 int 的可调用对象

包装器对可调用对象的要求:函数签名必须严格匹配。

  • 函数指针:指向的函数必须具有相同签名(如 int(*)(int, int)
  • 函数对象:其 operator() 必须具有相同签名
  • Lambda 表达式:其返回值以及参数列表必须匹配function指定的该签名

调用包装器对象与调用普通函数语法一致:

int result = func(10, 20); // 调用存储的可调用对象

完整示例代码:

#include <iostream>
#include <functional> // 必须包含

int add(int x, int y) {
    return x + y;
}

class Addnum {
public:
    int operator()(int x, int y) {
        return x + y;
    }
};

int main() {
    // 声明包装器:存储返回 int 且接受两个 int 的可调用对象
    std::function<int(int, int)> func; 
// 存储 lambda 表达式
func =  { return x + y; };
std::cout << func(20, 10) << std::endl;

// 存储函数指针
int(*addPtr)(int, int) = add;
func = addPtr;
std::cout << func(20, 10) << std::endl;

// 存储函数对象
func = Addnum();
std::cout << func(20, 10) << std::endl;

return 0;
}

结语

那么这就是本文关于c++11的新语法和新特性的全部讲解,那么下一期我会更新智能指针,我会持续更新,希望你能够多多关注,本期博客制作不易,如果本文有帮组到你的话,还请三连加关注哦,你的支持就是我创作的最大动力!
在这里插入图片描述

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐