C++右值引用和移动构造函数

对象的拷贝

C++新标准之前对象的拷贝控制由拷贝构造函数,重载的拷贝赋值运算符,析构函数三个函数决定。
新标准之后新增两个函数:移动构造函数,移动赋值运算符

移动构造函数和移动赋值运算符

为什么会有移动构造函数和移动赋值运算符?我们需要拷贝的场景有两种,第一种就是被拷贝的对象还要时候,第二种就是被拷贝的对象不再使用(即对右值的拷贝)。而移动构造函数就可以在对象发生拷贝时不需要重新分配空间而是被拷贝对象的内存。

移动构造函数

移动构造函数的第一个参数必须是自身类型的右值引用,不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个右值引用。

A::A(A &&a) noexcept
:x(a.x)
{
	a.x=nullptr;
}

与拷贝构造函数不同,移动构造函数不分配任何新内存。它将接管给定的对象的内存,在接管内存之后,将给定对象的指针都置位nullptr,这样就完成了移动操作,最终移后源对象会被销毁。又因为不分配内存,所以不会抛出任何异常,因此用noexcept指定。

移动赋值运算符

移动赋值运算符是一个重载的赋值运算符,参数为自身类的右值引用,返回值是自身类的左值引用,与移动构造函数一样,不抛出任何异常,用noexcept指定。

A& A::operator=(A &&a) noexcept
{
    if(!this != &a)
    {
		free();	//释放已有元素
        elements = a.elements;
        a.elements = nullptr;
    }
    return *this;
}

右值引用

左值(lvalue) 指持久存在的对象或返回值类型为左值引用的返回值,是不可移动的。

右值(rvalue) 包含了临时对象或者返回值为右值引用的返回值,是可移动的。

  • 形式为cast-name(exp),其中type是转换的类型,exp是转换的值,如果type是引用类型,则结果是左值
  • 取地址符作用于一个左值运算对象,返回一个指向该运算对象的指针,这个指针是一个右值
  • 内置的解引用运算符、下标运算符、迭代器解引用运算符、容器的下标运算符的求值结果都是左值
  • 内置类型和迭代器的递增递减运算符作用于左值对象,所得的结果也是左值

为了支持移动操作,C++新标准引入了一种新的引用类型——右值引用&&,即必须绑定到右值的引用

int &&i = 42;		//正确
int j = 42;
int &&k = j;		//错误,右值引用不能绑定到左值
const int &r = j*42;	//正确,const会创建一个临时的const变量
int &r1 = j*42;		//错误,右值引用不能绑定到左值
int &&r2 = j*42;	//正确,j*42是一个右值
int &&r3 = i;		//错误,表达式i是一个左值

返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符都是返回左值表达式的例子。
返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值,不能将左值引用绑定到这类表达式,但可以将一个const的左值引用或者一个右值引用绑定到这类表达式上。

强制转换右值move函数

有时候我们需要将左值像右值一样转移所有权

void func(){
	A res;
    //.....
    if(xxx)
        ans = res;	//我希望将res转移到外部变量ans上
    return;
}

在上述代码中,res赋值给ans之后不再被使用,我们希望调用的是移动赋值构造函数。

但是res是一个左值,因此ans = res调用的是赋值构造函数。

为了将某些左值当成右值使用,C++新标准提供了 std::move 函数以用于将某些左值转成右值,以匹配右值引用类型。

void func(){
	A res;
    //.....
    if(xxx)
        ans = std::move(res);	//这时候调用的是移动赋值构造函数
    return;
}

函数参数传递

void func1(A a) {return;}
void func2(A &&a) {return;}

int main() {
	A a;
	A &b = a;
	A c;
	A d;

	func1(a);		//调用拷贝构造函数
	func1(b);		//调用拷贝构造函数
	func1(std::move(c));	//调用移动构造函数
	func2(std::move(d));	//都不调用
}

实际上在不开优化的版本下,如果实参为右值,调用func1的开销只比func2多了一次移动构造函数和析构函数。

实参传递给形参,即形参会根据实参来构造。其结果是调用了移动构造函数;函数结束时则释放形参。

倘若说对象的移动构造函数开销较低(例如内部仅一个指针属性),那么使用无引用类型的形参函数是更优雅的选择,而且还能接受左值引用类型或无引用的实参(尽管这两种实参都会导致一次Copy Consturct)。可以说,这种情况下,只提供非引用类型的版本,也是可以接受的。

从极致的优化角度来看,如果参数有支持移动构造(或移动赋值)的类型,应该同时提供左值引用(匹配左值)和右值引用(匹配右值)两种重载版本。

函数返还值传递

A func1()
{
    A a;
    return a;
}

A func2()
{
    A a;
    return std::move(a);
}

A &&func3()
{
    A a;
    return std::move(a);
}

int main()
{
    A test1 = func1();
    //test1调用情况 Construct func
    A test2 = func2();
    //test2调用情况 Construct func Move func Destroy func
    A test3 = func3();
    //test3调用情况 Construct func Destroy func Move func
    return 0;
}

执行这3行代码实际上都没有任何Copy Construct的开销,并且func3是危险的。因为局部变量释放后,函数返还值仍持有它的右值引用。

因此,不建议函数返还右值引用类型,同前面传递参数类似的,移动构造开销不大的时候,直接返还非引用类型就足够了(在某些特殊场合有特别作用,准确来说一般用于表示返还成一个右值,如std::move的实现)。

结论:

1. 我们应该首先把编写右值引用类型相关的任务重点放在对象的构造、赋值函数上。从源头上出发,在编写其它代码时会自然而然享受到了移动构造、移动赋值的优化效果。

2. 形参:从优化的角度上看,若参数有支持移动构造(或移动赋值)的类型,应提供左值引用和右值引用的重载版本。移动开销很低时,只提供一个非引用类型的版本也是可以接受的。

3. 返还值:不要且没必要编写返还右值引用类型的函数,除非有特殊用途。

参考:
1. <<C++ Primer>>第五版
2. 透彻理解C++11 移动语义:右值、右值引用std::move、std::forward

posted @ 2021-10-23 15:18  Astray_M  阅读(373)  评论(0)    收藏  举报