C++11-右值引用/新的类功能/可变参数列表

2022-11-30 13:07:36 浏览数 (1)

C 11-右值引用/新的类功能/可变参数列表

  • 零、前言
  • 一、右值引用
    • 1、左值和右值
    • 2、左值引用和右值引用
    • 3、右值引用
    • 4、移动语义
    • 5、右值引用引用左值
    • 6、完美转发
    • 7、右值引用作用
  • 二、新的类功能
    • 1、默认成员函数
    • 2、移动构造和移动赋值
  • 三、可变参数列表
    • 1、参数包的展开
    • 2、STL中的emplace

零、前言

本章继续跟着上章讲解C 11的新语法特性,主要包括右值引用

一、右值引用

  • 引入及概念:
  1. C 98中提出了引用的概念,引用即别名,引用变量与其引用实体公共同一块内存空间,而引用的底层是通过指针来实现的,因此使用引用,可以提高程序的可读性
  • 示例:引用交换变量
代码语言:javascript复制
void Swap(int& left, int& right)
{
    int temp = left;
    left = right;
    right = temp;
}
  1. 为了提高程序运行效率,C 11中引入了右值引用,右值引用也是别名,但其只能对右值引用
  • 示例:
代码语言:javascript复制
int Add(int a, int b)
{
	return a   b;
}
int main()
{
    //右值示例
    const int&& ra = 10;
    // 引用函数返回值,返回值是一个临时变量,为右值
    int&& rRet = Add(10, 20);
    return 0;
}

注:为了与C 98中的引用进行区分,C 11将该种方式称之为右值引用

1、左值和右值

  • 概念:

  1. 左值与右值是C语言中的概念,但C标准并没有给出严格的区分方式
  2. 一般认为:左值可放在赋值符号的左边,右值可以放在复制符号的右边;或者能够取地址的称为左值,不能取地址的称为右值

注:左值也能放在赋值符号的右边,右值只能放在赋值符号的右边

  • 示例:
代码语言:javascript复制
int g_a = 10;
// 函数的返回值结果为引用
int& GetG_A()
{
	return g_a;
}
int main()
{
	int a = 10;
	int b = a;
    int* p=new int(0);
	// a和b,p和*p都是左值,说明:左值既可放在=的左侧,也可放在=的右侧
	const int c = 30;
	//c = a;
	// 特例:c虽然是左值,但是为const常量,只读不允许被修改
	cout << &c << endl;
	// c可以取地址,所以c严格来看也是左值
	//b   1 = 20;
	// 编译失败:因为b 1的结果是一个临时变量,没有具体名称,也不能取地址,因此为右值
	GetG_A() = 100;
	return 0;
}
  • 关于左值与右值的区分一般认为:

  1. 普通类型的变量,因为有名字,可以取地址,都认为是左值
  2. const修饰的常量,不可修改,只读类型的,理论应该按照右值对待,但因为其可以取地址C 11认为其是左值
  3. 如果表达式的运行结果是一个临时变量或者对象,认为是右值
  4. 如果表达式运行结果或单个变量是一个引用则认为是左值
  • 注意:

  1. 不能简单地通过能否放在=左侧右侧或者取地址来判断左值或者右值,要根据表达式结果或变量的性质判断
  2. 能得到引用的表达式一定能够作为引用,即为左值,否则就用常引用,即为右值
  • C 11对右值进行了严格的区分:

  1. C语言中的纯右值,比如:a b, 100
  2. 将亡值,也就是生命周期即将结束的变量,比如临时变量:表达式的中间结果、函数按照值的方式进行返回,匿名变量

2、左值引用和右值引用

在C 98中的普通引用与const引用在引用实体上的区别

  • 示例:
代码语言:javascript复制
int main()
{
    // 普通类型引用只能引用左值,不能引用右值
    int a = 10;
    int& ra1 = a; // ra为a的别名
    //int& ra2 = 10; // 编译失败,因为10是右值
    //const引用既可以引用左值也可以引用右值
    const int& ra3 = 10;
    const int& ra4 = a;
    return 0;
}
  • 注意:

  1. 普通引用只能引用左值,不能引用右值,const引用既可引用左值,也可引用右值
  2. C 11中右值引用:只能引用右值,一般情况不能直接引用左值
  • 示例:
代码语言:javascript复制
int main()
{
    // 10纯右值,本来只是一个符号,没有具体的空间,
    // 右值引用变量r1在定义过程中,编译器产生了一个临时变量,r1实际引用的是临时变量
    int&& r1 = 10;
    r1 = 100;
    int a = 10;
    int&& r2 = a; // 编译失败:右值引用不能引用左值
    int&& r3=move(a);
    return 0;
}
  • 注意:

  1. 右值引用只能引用右值,不能引用左值
  2. 右值引用可以进行引用move以后的左值,move表示将该变量识别为右值
  3. 右值引用本质上是将引用的右值内容存储到空间中,该右值引用变量具有名称和地址,所以右值引用变量是一个左值

3、右值引用

  • 概念:

  1. 本质上引用都是用来减少拷贝,提高效率的
  2. 左值引用来解决大部分的场景,比如参数引用,返回值引用
  3. 右值引用是堆左值引用在一些盲区的补充,比如将亡值返回
  • 右值引用的引入:

如果一个类中涉及到资源管理,用户必须显式提供拷贝构造、赋值运算符重载以及析构函数,否则编译器将会自动生成一个默认的,如果遇到拷贝对象或者对象之间相互赋值,就会出错

  • 示例:
代码语言:javascript复制
class String
{
public:
	String(const char* str)
	{
		if (nullptr == str)
			return;

		_str = new char[strlen(str)   1];
		strcpy(_str, str);
        cout << "构造" << endl;
	}
	String(const String& s)
		: _str(new char[strlen(s._str)   1])
	{
		strcpy(_str, s._str);
        cout << "拷贝构造" << endl;
	}
	String & operator=(const String & s)
	{
		if (this != &s)
		{
			char* pTemp = new char[strlen(s._str)   1];
			strcpy(pTemp, s._str);
			delete[] _str;
			_str = pTemp;
		}
        cout << "拷贝赋值" << endl;
		return *this;
	}
	String operator (const String& s)
	{
		char* pTemp = new char[strlen(_str)   strlen(s._str)   1];
		strcpy(pTemp, _str);
		strcpy(pTemp   strlen(_str), s._str);
		String strRet(pTemp);
		return strRet;
	}
	~String()
	{
		if (_str) delete[] _str;
	}
private:
	char* _str;
};
int main()
{
	String s1("hello");
	String s2("world");
	String s3(s1   s2);
	return 0;
}
  • 示图:
  • 解释:

  1. 在operator 中:strRet在按照值返回时,必须创建一个临时对象,临时对象创建好之后,strRet就被销毁了,最后使用返回的临时对象构造s3,s3构造好之后,临时对象就被销毁了
  2. 也就是说strRet、临时对象、s3每个对象创建后,都有自己独立的空间,而空间中存放内容也都相同,相当于创建了三个内容完全相同的对象,对于空间是一种浪费,程序的效率也会降低,而且临时对象确实作用不是很大
  • 左值引用的短板:

但是当函数返回对象是一个局部变量,出了函数作用域就不存在了,就不能使用左值引用返回,只能传值返回

4、移动语义

  • 右值引用的应用:

C 11提出了移动语义概念,即:将一个对象中资源移动到另一个对象中的方式,可以有效缓解该问题

  • 示图:
  • 解释:

  1. 对于像strRet本来是左值,但是这样的在函数体内出作用域即销毁的变量,编译器会优化识别为是一种将亡值,即为右值
  2. 此处为值传递,会进行临时变量的拷贝,对于右值来说既能匹配参数类型是
  3. const左值引用的拷贝构造函数,也能匹配参数类型是右值引用的拷贝构造函数,但是编译器会进行匹配类型最合适的函数,即右值引用拷贝构造函数
  4. 这里的参数为右值引用的拷贝构造函数也叫做移动构造,即对将亡值进行资源的转移,转移到新的构造对象上,而对于将亡值是没有影响的
  5. 即在用strRet构造临时对象时,就会采用移动构造。而临时对象也是右值,因此在用临时对象构造s3时,也采用移动构造,将临时对象中资源转移到s3中,整个过程,只需要创建一块堆内存即可,既省了空间,又大大提高程序运行的效率

注:在C 11中如果需要实现移动语义,必须使用右值引用(上述String类增加移动构造)

  • 示例:
代码语言:javascript复制
String(String&& s)
	: _str(s._str)
{
	s._str = nullptr;
    cout << "移动拷贝" << endl;
}
  • 效果:

注:对于连续的两次移动构造,编译器会自动优化成一次,即直接省去了中间的临时变量构造

  • 注意:

  1. 移动构造函数的参数千万不能设置成const类型的右值引用,因为资源无法转移而导致移动语义失效
  2. 在C 11中,编译器会为类默认生成一个移动构造,该移动构造为浅拷贝,因此当类中涉及到资源管理时,用户必须显式定义自己的移动构造

5、右值引用引用左值

  • 概念:

  1. 按照语法,右值引用只能引用右值,但右值引用是可以引用move后左值:有些场景下,可能真的需要用右值去引用左值实现移动语义
  2. 当需要用右值引用引用一个左值时,可以通过move函数将左值转化为右值。C 11中,std::move()函数位于头文件< utility >中,该函数名字具有迷惑性,它并不搬移任何东西,唯一的功能就是将一个左值强制转化为右值引用,然后实现移动语义
  • move的定义:
代码语言:javascript复制
template<class _Ty>
inline typename remove_reference<_Ty>::type&& move(_Ty&& _Arg) _NOEXCEPT
{
    // forward _Arg as movable
    return ((typename remove_reference<_Ty>::type&&)_Arg);
}
  • 注意:

  1. 被转化的左值,其生命周期并没有随着左值的转化而改变,即std::move转化的左值变量lvalue不会被销毁
  2. STL中也有另一个move函数,就是将一个范围中的元素搬移到另一个位置
  • 示例:
代码语言:javascript复制
int main()
{
    String s1("hello world");
    String s2(move(s1));
    String s3(s2);
    return 0;
}
  • 效果:

注:以上代码是move函数的经典的误用,因为move将s1转化为右值后,在实现s2的拷贝时就会使用移动构造,此时s1的资源就被转移到s2中,s1就成为了无效的字符串

  • 示例:
代码语言:javascript复制
class Person
{
public:
	Person(const char* name, const char* sex, int age)
		: _name(name)
		, _sex(sex)
		, _age(age)
	{}
	Person(const Person& p)
		: _name(p._name)
		, _sex(p._sex)
		, _age(p._age)
	{}
		Person(Person&& p)
		: _name(move(p._name))
		, _sex(move(p._sex))
		, _age(p._age)
	{}
private:
	String _name;
	String _sex;
	int _age;
};
Person GetTempPerson()
{
	Person p("prety", "male", 18);
	return p;
}
int main()
{
	Person p(GetTempPerson());
	return 0;
}
  • 效果:
  • 右值引用和移动语义的作用:

移动构造本质是将参数右值的资源窃取过来,占位已有,那么就不 用做深拷贝了,所以它叫做移动构造,就是窃取别人的资源来构造自己,也就是说资源的生命周期被延长了(对象的生命周期不会改变)

  • 移动赋值:

除了移动构造之外,还有移动赋值

代码语言:javascript复制
Person& operator=(Person&& p)
{
	_name=move(p._name);
	_sex=move(p._sex);
	_age=p._age;
}

注意:

STL中的容器都是增加了移动构造和移动赋值

示例:

代码语言:javascript复制
void push_back (value_type&& val);
int main()
{
    list<String> lt;
    String s1("1111");
    // 这里调用的是拷贝构造
    lt.push_back(s1);
    // 下面调用都是移动构造
    lt.push_back("2222");
    lt.push_back(std::move(s1));
    return 0;
}
  • 示图:

6、完美转发

  • 概念及引入:

完美转发是指在函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另外一个函数

  • 示例:
代码语言:javascript复制
void Func(int x)
{
	// ......
}
template<typename T>
void PerfectForward(T t)
{
	Fun(t);
}
  • 解释:

  1. PerfectForward为转发的模板函数,Func为实际目标函数,但是上述转发还不算完美,完美转发是目标函数总希望将参数按照传递给转发函数的实际类型转给目标函数,而不产生额外的开销,就好像转发者不存在一样
  2. 所谓完美就是函数模板在向其他函数传递自身形参时,如果相应实参是左值,它就应该被转发为左值;如果相应实参是右值,它就应该被转发为右值
  3. 这样做是为了保留在其他函数针对转发而来的参数的左右值属性进行不同处理,比如参数为左值时执行拷贝语义;参数为右值时执行移动语义
  • 具体场景:

  1. 对于模板参数中的&&,不仅仅是引用右值,语法规定该中情况为万能引用,既能引用右值也能引用左值
  2. 而这种情况下引用进来的类型变量,都会变成左值,对于引用左值,本身就是左值;对于右值引用,引用后的引用变量会将引用内容储存到空间中,也就是会退化成左值,这里就存在属性的混淆
  3. 对于这种情况,C 11通过forward函数来实现完美转发
  • std::forward 完美转发在传参的过程中保留对象原生类型属性
代码语言:javascript复制
void Fun(int& x) 
{ 
	cout << "lvalue ref" << endl; 
}
void Fun(int&& x) 
{ 
	cout << "rvalue ref" << endl; 
}
void Fun(const int& x) 
{ 
	cout << "const lvalue ref" << endl; 
}
void Fun(const int&& x) 
{ 
	cout << "const rvalue ref" << endl; 
}
template<typename T>
void PerfectForward(T&& t) 
{ 
    // std::forward<T>(t)在传参的过程中保持了t的原生类型属性
	Fun(std::forward<T>(t)); 
}
int main()
{
	PerfectForward(10); // rvalue ref
	int a;
	PerfectForward(a); // lvalue ref
	PerfectForward(std::move(a)); // rvalue ref
	const int b = 8;
	PerfectForward(b); // const lvalue ref
	PerfectForward(std::move(b)); // const rvalue ref
	return 0;
}
  • 效果:

7、右值引用作用

  • C 98中引用作用:

因为引用是一个别名,需要用指针操作的地方,可以使用指针来代替,可以提高代码的可读性以及安全性

  • C 11中右值引用主要有以下作用:

  1. 实现移动语义(移动构造与移动赋值)
  2. 给中间临时变量取别名
  • 示例:
代码语言:javascript复制
int main()
{
    string s1("hello");
    string s2(" world");
    string s3 = s1   s2; // s3是用s1和s2拼接完成之后的结果拷贝构造的新对象
    stirng&& s4 = s1   s2; // s4就是s1和s2拼接完成之后结果的别名
    return 0;
}
  1. 实现完美转发

二、新的类功能

1、默认成员函数

  • C 11之前的C 类中,有6个默认成员函数:

  1. 构造函数
  2. 析构函数
  3. 拷贝构造函数
  4. 拷贝赋值重载
  5. 取地址重载
  6. const 取地址重载
  • 注意:

  1. 默认成员函数就是我们不写编译器会生成一个默认的
  2. C 11 新增了两个:移动构造函数和移动赋值运算符重载

2、移动构造和移动赋值

  • 注意:

  1. 如果你没有自己实现移动构造函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个,那么编译器会自动生成一个默认移动构造
  2. 默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动构造,如果实现了就调用移动构造,没有实现就调用拷贝构造
  3. 如果你没有自己实现移动赋值重载函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个,那么编译器会自动生成一个默认移动赋值
  4. 默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动赋值,如果实现了就调用移动赋值,没有实现就调用拷贝赋值。(和默认移动构造完全类似)
  5. 如果你提供了移动构造或者移动赋值,编译器不会自动提供拷贝构造和拷贝赋值
  • 示例:

注:以下代码在vs2013中不能体现,在vs2019下才能演示体现上面的特性

代码语言:javascript复制
class Person
{
public:
	Person(const char* name = "", int age = 0)
		:_name(name)
		, _age(age)
	{}
	/*Person(const Person& p)
	:_name(p._name)
	,_age(p._age)
	{}*/
	/*Person& operator=(const Person& p)
	{
	if(this != &p)
	{
	_name = p._name;
	_age = p._age;
	}
	return *this;
	}*/
	/*~Person()
	{}*/
private:
	String _name;
	int _age;
};
int main()
{
	Person s1{"zhangsan",18};
	Person s2 = s1;
	Person s3 = std::move(s1);
	Person s4;
	s4 = std::move(s2);
	return 0;
}
  • 效果:

三、可变参数列表

  • 概念:

  1. C 98/03,类模版和函数模版中只能含固定数量的模版参数
  2. C 11的新特性可变参数模板能够让您创建可以接受可变参数的函数模板和类模板

注:由于可变模版参数比较抽象,使用起来需要一定的技巧,比较晦涩,现阶段呢主要掌握一些基础的可变参数模板特性

  • 示例:
代码语言:javascript复制
// Args是一个模板参数包,args是一个函数形参参数包
// 声明一个参数包Args...args,这个参数包中可以包含0到任意个模板参数
template <class ...Args>
void ShowList(Args... args)
{}
  • 解释:

  1. 上面的参数args前面有省略号,所以它就是一个可变模版参数,我们把带省略号的参数称为“参数包”,它里面包含了0到N(N>=0)个模版参数
  2. 我们无法直接获取参数包args中的每个参数的,只能通过展开参数包的方式来获取参数包中的每个参数,这是使用可变模版参数的一个主要特点,也是最大的难点,即如何展开可变模版参数
  3. 由于语法不支持使用args[i]这样方式获取可变参数,所以我们的用一些奇招来一一获取参数包的值

1、参数包的展开

  1. 递归函数方式展开参数包
  • 示例:
代码语言:javascript复制
// 递归终止函数
template <class T>
void ShowList(const T& t)
{
	cout << t << endl;
}
// 展开函数
template <class T, class ...Args>
void ShowList(T value, Args... args)
{
	cout << value << " ";
	ShowList(args...);//递归展开
}
int main()
{
	ShowList(1);
	ShowList(1, 'A');
	ShowList(1, 'A', std::string("sort"));
	return 0;
}
  • 效果:
  1. 逗号表达式展开参数包
  • 示例:
代码语言:javascript复制
template <class T>
void PrintArg(T t)
{
	cout << t << " ";
}
//展开函数
template <class ...Args>
void ShowList(Args... args)
{
	int arr[] = { (PrintArg(args), 0)... };
	cout << endl;
}
int main()
{
	ShowList(1);
	ShowList(1, 'A');
	ShowList(1, 'A', std::string("sort"));
	return 0;
}
  • 解释:

  1. 这种展开参数包的方式不需要通过递归终止函数,是直接在expand函数体中展开的,printarg不是一个递归终止函数,只是一个处理参数包中每一个参数的函数
  2. 这种就地展开参数包的方式实现的关键是逗号表达式,逗号表达式会按顺序执行逗号前面的表达式。expand函数中的逗号表达式:(printarg(args), 0),也是按照这个执行顺序,先执行printarg(args),再得到逗号表达式的结果0
  3. 同时还用到了C 11的另外一个特性——初始化列表,通过初始化列表来初始化一个变长数组, {(printarg(args), 0)…}将会展开成((printarg(arg1),0),(printarg(arg2),0), (printarg(arg3),0), etc… ),最终会创建一个元素值都为0的数组int arr[sizeof…(Args)]
  4. 由于是逗号表达式,在创建数组的过程中会先执行逗号表达式前面的部分printarg(args)打印出参数,也就是说在构造int数组的过程中就将参数包展开了,这个数组的目的纯粹是为了在数组构造的过程展开参数包

2、STL中的emplace

  • 声明示例:
代码语言:javascript复制
template <class... Args>
void emplace_back (Args&&... args);
  • 说明:

  1. emplace系列的接口支持模板的可变参数,并且是万能引用
  2. 万能引用则能够直接拿到参数对象,以便构造类型需要的参数类型
  3. 支持模板的可变参数能够让emplace通过对参数列表的展开进行一个个获取参数,并构造对应需要的参数类型,比如传入int和string构造需要的pair<int,string>类型参数
  4. 总的一个效果就是传入构建对象所需要的参数,在底层直接调用普通构造函数生成对象
  • 示例:
代码语言:javascript复制
int main()
{
    //带有拷贝构造和移动构造的String
    std::list< std::pair<int, String> > mylist;
    mylist.emplace_back(10, "sort");
    mylist.emplace_back(make_pair(20, "sort"));
    mylist.push_back(make_pair(30, "sort"));
    mylist.push_back({ 40, "sort"});
    return 0;
}
  • 效果:
  • 注意:
  1. 传右值对比:push_back是构造 移动构造 emplace_back是直接构造,如果push_back/emplace_back的参数对象及其成员都实现了移动构造,本质区别不大,因为构造出来 移动构造,和直接构造成本差不多
  2. 但是如果push_back/emplace_back的参数对象及其成员没有实现移动构造,那么emplace_back还是直接构造,push_back则是构造 拷贝构造,代价就大了

结论:用emplace_back更好,因为他可以不依赖参数对象是否提供移动构造

0 人点赞