"C++11 标准新特性之 右值"
什么是右值, 用来做什么的, T &&是什么鬼?
第一节 教你分分钟学会什么是右值
今天我们聊一聊c++11中引入的一个新概念 - 右值. 在C++98的年代里,其实我们也有这个概念,只不过当时没有明确的給抽象出这么一个词出来。那么你可能要问了,何为右值? 我能给出的最简单的答案应该就是“不可以出现在等号左边的值都是右值!”
怎么理解这句话呢,比如
/*1-1*/
//! b 和 c 都是 int 型
int a = b + c;
其中 b + c
就是右值, 因为我们不能写出 b + c= ...
这样的代码。 这个解释简单吗?
如果觉得不过瘾,我们再举个稍微复杂点的例子.
/*1-2*/
class A{};
A func(){
return A();
}
//! 变量a是左值,因为它可以放到等号左边
//! func 返回的是个临时对象,我们无法写出 func()=...的代码
//! 所以 func() 是右值
A a = func();
给出我们可以意会的定义之后,我们来看看C++中对右值的定义。
C++中的右值由两个概念组成,一个称为xvalue, 另外一个叫prvalue。 xvalue(eXpiring Value)呢,通常是指生命周期很快就要结束的值,比如1-2
中的func()的返回值这种临时对象, prvalue呢就是我们的第一个例子(b+c),翻译成中文叫纯右值(Pure Rvalue)。
第二节 右值用来做什么?
右值的设计依我看来,主要目的有两个
第一呢,用来实现move语义
第二个目的则是用于完美转发.
好吧,说人话!
在我解释move语义之前呢,我们看一个非常简单的例子,可以运行的代码哦~。
为了确保你可以跟上我的节奏,首先你要准备好一个使着顺手的文本编辑器,我用的是VIM。 你可以用emac, 或者其他随便吧,只要能敲代码,任何工具都可以。 其次你要确保电脑上安装了gcc, 或者clang 也可以。 因为我们要写真正的c++11标准的代码。 我不建议你使用高度集成的开发IDE, 这些工具或许在你开发项目的时候可以提升效率,但是在你学习语言的阶段,因为它们帮你做了太多的工作,不利于你学习和掌握最基本的知识。
好了,让我们开始准备撸起袖子,敲代码吧!
先建立一个文件夹吧,专门用来存放我们的测试代码,就叫Project吧。在Project 下新建两个文件 build.sh
和RValue.cpp
.
build.sh
的内容如下所示
#!/bin/sh
g++ ./RValue.cpp -o RValue && {
./RValue
}
脚本中首先我们用g++编译RValue.cpp(我们一会就会写), 通过-o
给编译出的可执行文件起个好听点的名字,如果不指定的话,默认叫a.out。 如果编译成功的话, 我们让脚本自动执行RValue。 等会我们编写完RValue.cpp之后,就可以通过运行./build.sh
编译并查看运行结果了。简单吧
哦,对了,运行之前不要忘了修改./build.sh
文件的属性,给它添加一个可执行的属性, 否则系统不知道它可以运行
chmod +x ./build.sh
终于该写我们的主角了,真是不容易。需要休息一会,喝个咖啡神马的
歇了十分钟,开始撸代码
RValue.cpp
的内容如下所示:
#include <iostream>
using namespace std;
class MyString{
public:
//! 构造函数, 初始化的时候給_ptr分配空间
MyString()
:_ptr(new char[10]){
}
//! 拷贝构造函数
MyString(const MyString &s)
:_ptr(s._ptr){
}
//! 析构函数中,我们小心的清除的_ptr的内存
~MyString(){
delete[] _ptr;
}
private:
char *_ptr;
};
int main(){
MyString s1;
MyString s2(s1);
return 0;
}
代码写完了,试试我们的脚本吧,执行./build.sh吧,看看运行顺利不。
Ohh, My God!! 程序崩溃了, 我们拿到了这样的提示信息
error for object 0x7fc533c02870: pointer being freed
was not allocated
不要慌~ 我们来看看为什么crash了。 MyString
中有个char *_ptr
的成员变量, 在MyString对象销毁的时候,同时也会调用delete[] _ptr
释放内存。 但是我们发现, 执行 MyString s2(s1)
时,其实会调用到MyString的拷贝构造函数,这时会把s1的_ptr赋值給s2的_ptr, 也就是说s1的_ptr其实和s2的_ptr指向了同一块内存空间。 s2 在生命周期结束时会释放一次自己的_ptr, s1同样也会释放自己的_ptr, 二这两个对象的_ptr又是同一块内存空间,这意味着这块空间会被释放两次,第二次释放触发了crash。
如果你尝试着删除MyString的拷贝构造函数,然后编译运行会发现,仍然有这个错误。 这是因为默认情况下,编译器会为我们生成一个拷贝构造函数,默认的拷贝构造就是按位拷贝,和我们实现是一样的。这就是通常说到的浅拷贝.
知道crash原因之后,我们尝试着修复这个bug, 修改MyString的拷贝构造函数。
#include <iostream>
using namespace std;
class MyString{
public:
MyString()
:_ptr(new char[10]){
}
//! 浅拷贝改为深拷贝
//! 为_ptr分配新的内存,然后把s._ptr的内容拷贝到新的内存空间
MyString(const MyString &s)
:_ptr(new char[10]){
memcpy(_ptr, s._ptr, 10);
}
~MyString(){
delete[] _ptr;
_ptr = nullptr;
}
private:
char *_ptr;
};
int main(){
MyString s1;
MyString s2(s1);
return 0;
}
新的版本中,如同代码注释中加的那样,我们把浅拷贝的方式改成了深拷贝,然后编译运行,一切都是那么的平静~. 没有消息就是最好的消息,不是吗?
接下来,我们要看看各个函数都被执行了几次,代码里加一些计数器来帮助我们统计这些数据。
#include <iostream>
using namespace std;
class MyString{
public:
MyString()
:_ptr(new char[10]){
cout<<__func__<<": "<<++n_c<<endl;
}
MyString(const MyString &s)
:_ptr(new char[10]){
memcpy(_ptr, s._ptr, 10);
cout<<__func__<<": "<<++n_cp<<endl;
}
~MyString(){
cout<<__func__<<": "<<++n_d<<endl;
delete[] _ptr;
_ptr = nullptr;
}
private:
char *_ptr;
static int n_c;
static int n_cp;
static int n_d;
};
int MyString::n_c = 0;
int MyString::n_cp = 0;
int MyString::n_d = 0;
MyString temp_string(){
return MyString();
}
int main(){
MyString a = temp_string();
return 0;
}
上面的代码中,我们使用了n_c来统计构造函数的调用次数, n_cp统计拷贝构造的次数, n_d统计析构函数的调用次数。
新的代码逻辑更是简单的不要不要的, 就通过调用 temp_string()创建了一个
MyString 对象
为了阻止编译器做优化,便于我们查看最原始的c++运作情况,我们需要修改一下编译脚本build.sh
, 添加一个
-fno-elide-constructors
编译选项。
#!/bin/sh
g++ ./RValue.cpp -fno-elide-constructors -o RValue && {
./RValue
}
代码输出结果: 对照输出,想想为什么?
MyString: 1
MyString: 1
~MyString: 1
MyString: 2
~MyString: 2
~MyString: 3
整个过程分解为三个步骤:
- 第一步 temp_string函数中使用构造函数,构造出一个MyString对象
- 第二步 将这个对象通过拷贝构造构造出一个临时对象作为返回值
- 第三步 把临时对象通过拷贝构造函数构造出对象a。
整个过程中涉及到3个对象,所以我们看到三次构造三次析构函数的调用。 对于内存小的对象来说,这样的工作方式勉强可以接受,但是对于比较耗时的构造来说,这种方式就会带来非常大的效率问题。
那么,有没有一种方式可以让临时对象产生时,不进行耗时的内存分配或者拷贝操作呢, 因为临时对象的出现本身就是对程序员透明的,除了带来性能问题,对与程序员来说,并没有其他比较直观的感知。我们能不能定义一种构造函数,让通过临时对象构造的时候调用的它,于是但凡是在这个构造函数中的耗时操作,我们都可以改成浅拷贝这种快捷的方式。
抛出这个问题之后,聪明的你一定想到了,之前咱们讨论过右值的问题,这个临时对象不就是右值吗,那么我们是不是可以定一个右值引用构造函数呢?
为了区别于左值引用, c++11中使用T && 的方式来表示右值引用。 所以,在刚才的例子里,我们可以愉快的加一个右值引用构造函数了。
#include <iostream>
using namespace std;
class MyString{
public:
MyString()
:_ptr(new char[10]){
cout<<__func__<<":<()> "<<++n_c<<endl;
}
MyString(const MyString &s)
:_ptr(new char[10]){
memcpy(_ptr, s._ptr, 10);
cout<<__func__<<":<&> "<<++n_cp<<endl;
}
//! 新加的右值构造函数
MyString(MyString&& s)
:_ptr(s._ptr){
cout<<__func__<<":<&&> "<<++n_rc<<endl;
_ptr = nullptr;
}
~MyString(){
cout<<__func__<<": "<<++n_d<<endl;
delete[] _ptr;
_ptr = nullptr;
}
private:
char *_ptr;
static int n_c;
static int n_cp;
static int n_d;
static int n_rc;
};
int MyString::n_c = 0;
int MyString::n_cp = 0;
int MyString::n_d = 0;
int MyString::n_rc = 0;
MyString temp_string(){
return MyString();
}
int main(){
MyString a = temp_string();
return 0;
}
为了更加清晰的看到各种构造函数调用,我们在log内容上加以区分。执行结果如下所示:
MyString:<()> 1
MyString:<&&> 1
~MyString: 1
MyString:<&&> 2
~MyString: 2
~MyString: 3
各位看官自己根据运行结果分析调用过程吧,如果你认真读了前面部分的内容,对于这个结果应该不会感到意外。
MyString(MyString&& s)
:_ptr(s._ptr){
cout<<__func__<<":<&&> "<<++n_rc<<endl;
_ptr = nullptr;
}
在右值构造函数中,我们做了浅拷贝,就如同把s的资源移动到了_ptr, 所以就有了move的概念。进一步的,我们不仅可以让临时对象,即真正的右值调用到这个函数,c++11标准库中还提供了一个std::move
提供一个move语意.
看个例子就一目了然了。
int main(){
/* MyString a = temp_string(); */
MyString a;
MyString b(std::move(a));
return 0;
}
我们稍微修改了下main函数, 可以看到a本身是个左值,但是我想人为的通过
移动构造来构造b,这样不需要额外的申请10个字节的内存空间,于是我们借助了
std::move(a)
返回一个右值,进而会触发b对象的右值引用构造。 运行结果如下所示:
MyString:<()> 1
MyString:<&&> 1
~MyString: 1
~MyString: 2
讲到这里,基本上右值引用和move语义的关系就明朗了。 在下一篇中,我们聊一聊C++中令人兴奋的完美转发,不要错过这个话题,你不会失望的!