C++、右值引用

1. 左值和右值

左值和右值通常对应着赋值符号 = 的左侧和右侧,但 = 右侧也可以为左值。左值指可以寻址的具名变量,也就是说在其生命期和作用域中我们可以随时获取其存储地址,通过名字来读取或修改其值。右值通常是不可寻址、即将消亡的,一旦脱离了其所出现的位置就会被销毁,再也无法寻址,例如一些字面常量、临时变量等。

在过去的 C++ 标准中,我们可以进行左值的引用,即为一个具名变量增加一个别名,只要该变量的生命期还未结束,我们就可以利用这个别名进行数据的访问和修改。然而,右值是即将消亡的,一旦脱离其出现的位置就会被销毁,即生命期就结束了,所以一般来说对其进行引用增加一个别名是没有意义的,故过去的标准中也无法对右值进行引用(const 引用除外)。然而,由于工程项目应用的发展,一些类对象的数据量越来越大,例如字符串以及图像数据等等,在必须进行对象深拷贝的场景中,如果临时对象(右值)太多,进行深拷贝就会增加太多额外的负担,例如函数按值返回时,都会先把返回值拷贝到一个临时变量,最后才会把临时变量拷贝到目标变量上。所以在 C++11 中,右值引用的机制被引入。为了区别左值引用 &,右值引用使用 && 符号。

int x = 1;     	// x是左值,在其生命期中我们可以通过名字进行读取和修改
               	// 1是右值,当其赋值给x以后立即被销毁
A a = A();     	// 假设A是一个类,那么a也是左值,而A()为右值,属于临时变量
A &b = a;      	// 左值引用,等号右侧必须为左值,相当于给左值a取了一个别名
A &c = A();    	// 错误,因为A()属于临时变量,无法寻址,所以不能进行左值引用
A &&d = A();   	// 右值引用,相当于为临时变量A()取了一个名字,之后我们可以通过d访问A()的内容
int &y = x + 1;	// 错误,x+1也是一个临时变量,所以不能用于左值引用
int &&z = x;   	// 错误,右值引用不能绑定到左值
int &&z = std::move(x); // move()可以将输入的变量强制转换为右值,但没有发生内存的拷贝

右值引用相当于为右值创建了一个名字,使得后续的代码中可以根据这个名字对数据进行访问和修改,这就必然要求本作为右值的数据的生命期有所延长,不会像之前那种离开所出现的位置后就立马被销毁。确实,右值引用会使得右值的生命期延长到其所在作用域作为一个普通局部变量所具有的生命期。例如在以上示例中 A &&d = A(); 按理临时对象 A() 在分号结束之后就会立即被析构销毁,但由于右值引用的原因,其具有延长到和 A a = A();a 对象一样的生命期,因为其和 a 是在同一个作用域的,当然具体的析构顺序也是按照构造时的堆栈顺序。注意,在析构的时候不是右值引用 d 在析构,而是最初的右值 A() 本身在析构。

注意变量通过 move() 转换为右值后不要轻易赋值给其他变量,虽然这对于普通类型一般不会有什么影响,但对于包含了移动构造和移动赋值函数的类类型,右值的属性有可能使其失去对内部动态内存所有权,具体可查看后面的移动语义等章节。以下例子中,因为 string 包含了移动赋值函数,x1 转换为右值后,赋值会导致动态内存所有权转移,x1 变为空字符串。而尽管 x2 是右值引用,但由于具有名字,所以 x2 也是左值,赋值不会导致动态内存所有权转移,x2 的字符串内容不变。

string   x1 = "abc";
string &&x2 = "abc";
string    y = "";
y = move(x1);  // x1 = "", y = "abc"
y = x2;        // x2 = "abc", y = "abc"

右值引用可用于解决移动语义(moving semantics)和完美转发(perfect fowarding)的问题。

2. 右值引用

对于函数,常用的两种参数传递方式为按值传递和左值引用,如下:

void fn1(A a);
void fn2(A &a);

如果采用按值传递,实参会首先被拷贝一份副本再传入函数中,所以如果 A 所占用的内存较大,会导致传参的效率降低。如果采用左值引用,我们无法传入临时变量,即 fn2(A()) 是错误的,因为 A() 属于右值。为了解决右值的问题,我们可以定义 void fn3(const A &a); 因为常量引用可以使用右值,但是这时我们无法在函数体内修改 a,所以我们可以考虑重载 fn2 和 fn3,编译器会根据传入的实参类型来分别处理左值和右值的情况。

然而当参数不止一个时,我们需要指数级的函数重载来分别处理这些参数。为此,C++11 引入了右值引用,我们可以定义 void fn(A &&a); 但这时函数的输入必须为右值,也就是说

fn(A());    // 正确,A()是右值
A a = A();  // 或者 A &&a = A();
fn(a);      // 错误,a是左值,即便a本身是右值引用的,因为a是具名的
fn(std::move(a));    // 正确,通过move函数转换为右值

为了避免使用 move() 进行的显式的右值转换,一般使用模板函数,因为模板函数会自动进行类型推导和折叠。类型折叠:T&& && 等价于 T&&,其他的 T& &&, T&& &, T& & 都等价于 T&。例子如下:

template <typename T>
void fn(T&& a);
int a = 1;
fn(a);  // 实际为fn(int& &&),折叠后为fn(int &),也就是左值引用
fn(std::move(a)); // 实际为fn(int&& &&),折叠后为fn(int &&),也就是右值引用

通过右值引用和类型折叠,我们可以用一个函数来处理左值和右值的参数引用传递问题。以下是使用了右值引用和类型折叠编写拷贝构造函数的一个例子。

class A 
{
public:
	A(int _x = 0) :x(_x) {}
	~A() { cout << this << ' ' << x << endl; }
	template <typename T>
	A(T&& a) { x = a.x;  a.x++; }
private:
	int x;
};

int main()
{
	A a(5);		// 调用了 A(int); 构造函数
	A b(a);		// 调用了模板实例化为 A(A &); 的拷贝构造函数 
	A c(A(1));	// 调用了模板实例化为 A(A &&); 的拷贝构造函数
	return 0;
}

实际上,右值引用的作用并不局限于通过类型折叠节省函数重载的数量,其更普遍的用处在于后面所述的 移动语义完美转发。在类型折叠的例子中,我们是想把左值输入和右值输入的行为进行统一,即只用一个函数就能同时接收左值或者右值的输入,且左值输入和右值输入在函数中的操作是完全一致的,然而在一些对象拷贝的应用场景中,我们却能发现这种做法是比较低效的。

3. 移动语义

以 string 为例,假如要实现两个字符串对象相加 operator+(s1, s2),因为这不是 operator+=,如果 s1 或者 s2 属于左值,它们就有可能被其他的变量所引用,或者在之后被其他的变量所引用,那么我们不应该修改这两个字符串对象的值,所以输入类型一般为 const string&,同时新构造一个字符串对象将两者内容拷贝过来并返回此字符串对象。注意,因为我们返回的是函数内部创建的字符串,所以一般使用按值返回,并且使用友元函数,即

friend string operator+(const string &s1, const string &s2);

因为使用了常量引用作为输入,所以这对于右值字符串对象的输入也是适用的。尽管你也可以选择 new 出一个 string 对象然后返回指针,但函数调用者最后不再需要使用此对象时通常会忘记 delete,造成内存泄漏,当然这个问题也可以通过智能指针来解决。

再看一下 string s = string(“a”) + string(“b”); 这个过程,按照以上的实现,我们首先构造了 string 对象 a=“a”, b=“b”,然后在函数内分别拷贝字符串 “a” 和 “b" 组成一个新的字符串 “ab”,并根据此字符串构造了新的 string 对象 ab=“ab”。由于按值返回的原因对象 ab 会被拷贝到一个临时 string 对象 tmp 中,然后才拷贝到目标 string 对象 s 上。即便通过指针返回,这个过程的开销还是比较大的。

以上的问题主要在于,如果 s1 和 s2 都是右值,那么修改 s1 或者 s2 都不会对后续的程序有影响。这是因为右值是不可寻址且即将消亡的,也就是说现在以及将来都不会再被其他的变量所引用。因此,我们可以直接把 s2 的字符串数据插到 s1 字符串数据的末尾,然后返回 s1 对象即可。在这个过程中我们就节省掉 s1 对象的字符数据拷贝,以及 ab 对象的构造(但返回时还是需要两次拷贝)。相比之下效率明显是要高一些的。

这样,我们就引出了 移动语义 的概念。在传统的实现中,a 对象是 a 对象, b 对象是 b 对象,它们的语义(或者说内容)是不会因为函数的调用而改变的。而引入了右值的概念之后,a 对象因为本身属于右值,在函数调用结束后就会立即被销毁,更改其内容不会造成任何的影响,因此我们可以让其内容更改为 ab 对象的内容,从而省略了 ab 对象的构造以及构造时所需的额外的(s1 对象本身的字符串数据)拷贝操作,即 a 对象的语义此时就转移为 ab 对象的语义了。在这个过程中我们实现的函数的效率提高了,这就是为什么我们需要移动语义的原因。

以上我们讨论了两个右值字符串相加时所用到的移动语义,但实际上右值字符串的拷贝也可以使用移动语义,这就是在一些标准库中经常能看到的 移动拷贝构造函数 以及 移动赋值函数。同样以 string 为例,假如我们需要拷贝一个右值字符串对象,我们只需要把该右值字符串对象的字符串指针赋值给目标字符串对象的字符串指针即可,而不需要发生任何真正的字符拷贝(但是 string 对象本身的数据成员还是要拷贝的,只不过相比于可能很长的字符串数据拷贝所需的时间花销很小)。但要注意的是我们还要记得把右值字符串对象的字符串指针置为空,这样才能避免该右值字符串对象在被销毁时把字符串指针所指内存给释放了。

以下为基于移动语义所实现的右值字符串对象拷贝和相加的例子:

// 移动拷贝构造函数
Str::Str(Str &&right) noexcept
{
    len = right.len;
    buf = right.buf;
    right.len = 0;
    right.buf = NULL;
}
// 移动相加函数
Str operator+(Str &&left, Str &&right)
{
	// 注意left的字符串存储空间需要能够容纳right的字符串
    strcpy(left.buf + left.len, right.buf);
    left.len += right.len;
    return move(left);
}

基于以上实现,当执行 Str s = Str(“a”) + Str(“b”); 时,由于 Str(“a”) 和 Str(“b”) 两者皆为右值,所以会调用我们所定义的移动相加函数,此时字符串 “b” 会直接拷贝到字符串 “a” 的末尾组成字符串 “ab”。在这个过程中,我们无需进行字符串 “a” 的拷贝,也无需为字符串 “ab” 构造一个新的字符串对象 Str(“ab”)。注意当 operator+ 函数返回语义转移后的对象 left 时,必须通过 move() 函数重新转换为右值,因为 left 这个名字就意味着 left 是一个左值,但 left 所表达的语义却是来自于右值的。通过 move() 函数把 left 重新转换为右值后,operator+ 函数返回的就是一个右值,这时就会调用我们所定义的移动构造函数 Str::Str(Str &&right),这个过程中并没有发生真正的字符串数据拷贝,因此右值对象的拷贝是十分高效的。

我们前面提到过,在返回 left 对象时,其首先会被拷贝到一个临时对象 tmp 上,然后 tmp 对象才会被拷贝到目标对象 s 上,即发生了两次拷贝操作。在这个过程中,因为 move(left) 是右值,所以拷贝到临时对象 tmp 时调用的是移动拷贝构造函数;同样临时变量 tmp 本身也是右值,因此 tmp 被拷贝到目标对象 s 时同样调用的是移动拷贝构造函数。因此,尽管这里发生了两次拷贝构造,但字符串数据本身并没有发生任何一次拷贝。从这里我们就能看出移动语义对于对象拷贝所能带来的效率提升,这就是为什么我们需要右值引用以及移动语义。

注意,尽量不要使用右值引用作为该函数的返回,因为右值的生命期虽然被延长了,但不能超出其作为普通局部变量的生命期,而普通局部变量的生命期在离开其作用域时结束。以下例子中,Str(“a”) 和 Str(“b”) 并不与目标对象 a1 同属一个作用域,而是在一个由这条语句的分号所隔断的嵌套作用域中,当离开分号结束这条语句时,Str(“a”) 和 Str(“b”) 两者都会被析构,其所占用的内存也会被释放,这时 a1 引用的是被释放后的内存。因此,如果后面再对 a1 进行操作以及手动析构(引用不会析构,但一般来说右值对象会自动析构,并不需要手动析构),就可能会造成内存访问错误而导致崩溃。不过,如果返回后直接调用移动构造函数构造出新的对象如 a2,由于 a2 的构造也是发生在嵌套作用域内的,这时临时变量还没有被析构,这种复制是合法的,而且复制完成后 a2 是一个独立的对象,临时变量的数据指针也已经被置为空,析构也不会影响 a2 后续的操作。同理,如果返回函数内部的局部变量,也同样存在这个问题,甚至其生命期更加短,当函数返回时,该局部变量就已经被释放,即便是移动构造一个新的对象也无法保留该变量的内容。

Str &&operator+(Str &&left, Str &&right)
{
    strcpy(left.buf + left.len, right.buf);
    left.len += right.len;
    return move(left);
}
Str &&a1 = Str("a") + Str("b"); // 危险,表达式结束后a.buf指向已经释放的内存
a1.~Str();                      // 错误,删除已经释放的内存,程序崩溃
Str a2 = Str("a") + Str("b");   // ok,此时调用移动构造函数复制临时对象

4. 完美转发

完美转发 主要是配合 移动语义 的,假设分别有常量左值引用和右值引用两种版本的重载函数,我们通常会通过类型折叠来合并封装这两个函数,从而提供由一个统一的调用接口函数,例如

void fn(const int& a);
void fn(int&& a);

template<class T>
void pack(T&& x) {
    //fn(x);                // 实际上始终都会调用fn(const int&),因为x此时是具名的,所以属于左值
    fn(std::forward<T>(x)); // 通过forward函数,如果x是左值引用,那么会转换为左值;如果是右值引用会转换为右值
}

以上例子中,如果不使用 forward() 函数,因为 x 本身是有名字的,所以 x 是左值,无论调用 pack(x) 时所传入的参数是左值还是右值,这时始终会调用 fn(const int&) 即左值常量引用的版本。forward() 函数相比于 move() 函数更加智能,对于右值引用的输入会转换为右值,而对于左值引用的输入则保持为左值,从而选择合适的重载函数。在这个过程中,我们就完成了接口函数对于左值或者右值输入的完美转发。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值