C++0x的右值和右值引用

来源:互联网 发布:apache 开源框架 编辑:程序博客网 时间:2024/05/19 04:51

这篇博文的原始地址在http://storming.github.io/libllpp/。

我很想把自己的理解分享给大家,并且和大家一起讨论。这篇博文的内容只是我对这个概念的理解,并不是太严谨。

右值和右值引用是c++0x引入的核心概念,在这段学习时间里对它的认知是个渐进的过程,每每认为自己理解它了,每每过段时间否定自己 的认知。到现在,我还是无法用准确的语言来描述它,这里我使用FAQ的方式阐述一下我对它的理解。

Q: 什么是右值?

A: 你无法直接操作的值,可能是个临时对象,也可能是个常量。

int n = 10;int m = n;

等号左边的值一定是个可地址化的东西,赋值的本质是给一块内存或者一个寄存器赋值。我们可以给一个变量赋值但是无法给一个常量赋值。 一个常量永远只能出现在等号的右边。一个函数返回临时对象也永远只能出现在等号的右边。

string foo();string str = foo();

右值就是那些永远只能出现在等号右边的值。有一种判断一个"量"是不是右值很好方法:这个"量"有没有名字!

函数foo()返回的这个临时string实例就是个没有名字的量,存在但没名字。我们写程序只能操纵有名字的变量,没名字的我们无从干涉。干涉不了的就是右值。 常量10,我们也无从干涉它,你能把它改成11么?哈哈!

struct foo {    static constexpr int num = 100;};

如果显然是个常量别名,有名字,但是右值。

Q: 右值是怎么产生的?

A: 只有两种途径,1、常量;2、函数返回的临时变量。

Q: 什么是右值引用?

A: 可以引用右值的引用。比如说引用一个常量。

Q: 右值引用可以引用左值么?

A: 完全可以。

Q: 这么说右值引用是个万能引用?

A: 可以这么说!事实上也是。

Q: 这令人感觉神奇且困惑。

A: 到底是变量还是常量,编译器可以跟踪到。这不用担心,就算你对一个常量的右值引用取地址,它也可以在堆栈里造出个变量让你取地址。 简单的说,对于编译器来说,每次赋值都是不同的变量。常量对于程序员来说是个抽象概念,但是对于编译器来说是实实在在的一个 语法树节点。对常量进行引用,对于编译器来说是很顺其自然合情合理的。

Q: 难道引用不是指向一个内存地址么?

A: 以前的C++引用确实是指向一个内存地址,或者可以把它说成是内存地址的引用描述方式。但是c++0x让引用的概念更加宽泛了。 以前的C++是没法对引用进行引用的,但是c++0x可以,以前是没法对一个常量进行引用的但是c++0x可以。引用在c++0x中可以这么 理解:索引“那个东西”,或者是索引“某个东西”,或者干脆把它理解成alias。c++中不是可以用typedef对类型进行别名么? 引用时某个“量”的别名。

当然跟以前兼容,左值引用&还是指向一个内存地址,增加右值引用&&可以指向一个常量。用“别名”去替换地址的思维方式, 是比较精确的。

Q: 既然右值引用可以引用普通的变量,为什么我把普通变量直接给右值引用赋值会编译错误?

A: 右值引用并不是设计用来替代左值引用的, c++0x也不是用来完全替代以前的c++的,它需要向后兼容。在对它赋值的时候,需要强制转换。 但是你完全可以把一个普通变量赋值给右值引用,并且像个普通引用那样操作它。

Q: 如果右值引用为了可以引用常量而提出,但是我们已经有了const了,而且工作的很好。

A: 确实在类成员变量定义和函数体变量定义里基本不会看到右值引用,除非你非要这么写。而且也确实没有必须这么写的必要。 我们一般不会直接去定义和操作一个右值引用。

Q: 那它经常在什么场合出现?

A: 在函数参数列表里出现,这也是它唯一必须出现的地方。

Q: 什么样的参数需要右值引用?

A: 具有move语意的的函数需要右值引用。

Q: 什么是move语意?

A: 这是跟copy语意对应的一个语意。c++有copy构造和copy赋值语意。比如下面的string类:

struct string {    char *_p;    string() : _p() {}    string(const char *p) {        _p = ::strdup(p);    };    ~string() {        if (_p) {            ::free(_p);        }    }    string &operator=(const string &x) {        if (_p) {            ::free(_p);            _p = nullptr;        }        if (x._p) {            _p = ::strdup(x._p);        }        return *this;    }};inline string operator+(const string &x, const char *y) {    string tmp;    tmp._p = ::strcat(x._p, y);    return tmp;}int main() {    std::string str;    std::string some = "aaaaaa";    str = some + "bbbbb";    return 0;}

这个string类声明了copy赋值,具体的操作就是释放旧的内存,申请内存并copy新的串的内容。some加上"bbbbb"后会产生一个新的string实例。 这个临时string实例在赋值给str的时候调用了string的copy赋值函数。这种实现方式在逻辑上是完全正确的,结果也是正确的,但是它有个 唯一的缺点就是运行效率不佳。最好的办法就是直接把tmp的p和str的p的内容互换。临时string实例tmp在赋值后会被自动析构,这样它的内容 互换给了str,并且析构释放了str的_p所指向的内存。这种语意c++0x称之为move语意,其实说成是swap语意可能更形象一些。

c++0x标准把move语意描述成"偷"。str"偷梁换柱"了some + "bbbbbb"产生的临时实例的内存。但是怎么在程序里描述这个语意呐?以前的c++ 把const type &作为第一个参数的构造函数或者operater=重载称为copy构造和copy赋值函数。在c++0x中第一个参数是type &&的构造函数 和operator=作为move构造和move赋值。

Q: 这是个怎么样个过程?c++0x支持"偷"操作?

A: c++本身不支持"偷"的具体操作,它只是提供了这么个语意,至于怎么"偷"每种类自己决定。就像c++提供了copy构造和copy赋值,至于说怎么 copy由类自己决定。

struct string {    char *_p;    string(string &&x) : _p() {        std::swap(_p, x._p);    }};

在这个例子里类在move构造里交换了_p。其它的类的实现方式也可能是某种复制。比如说std::list的move构造就是把所有的element都pop出来,然后 push到新的链表中。

Q: std::swap是什么?

A: 跟个swap宏差不多,tmp(a), a = b, b = tmp。a需要具有move或者copy构造还有move或者copy赋值,b需要move或者copy赋值。看前面那段代码 就能推测出。你可能没有明确的提供copy或者move构造和赋值,但是编译器会帮你隐含生成的。

Q: 到底什么时候调用copy语意,什么时候调用move语意?

A: 参数是右值时调用move,左值时调用copy。上面的例子中字符串相加产生的临时对象你无法直接的左值引用来引用它,这就是右值, 使用move语意。而str = some;,some是个可以直接的引用的对象,使用copy语意。

Q: 这岂不是很难判断?

A: 不难,参考FAQ第一条,调用构造或者赋值的时候,传入的参数有没有名字。没名字就是move语意,否则copy语意。

Q: 我是个传统的程序员,我只声明了copy语意。会是什么情况?

A: 只会调用copy语意。在函数匹配的过程中,会有个降解判断过程。看一下std::decay,这就是最标准的函数匹配标准降解过程。无论是move 还是copy的调用都没有脱离函数重载匹配的范畴和原则。函数匹配首先会匹配最低品质,如果两个函数的参数品质相同,则选择最佳匹配。

struct foo {    foo(foo &x) {}    foo(const foo &x) {}    foo(foo &&x) {}};

如果不小心定义了foo(foo &x),任何情况都不会调用copy和move构造,因为这个构造的函数参数品质最低。move语意所操作的对象,由于不可 直接触碰,它本身就具有const语意。如果有move构造,根据最佳匹配,会调用move构造。否则,会调用copy构造。

Q: 噢,我对这些语意有点了解了。如果我想强制类的其它实例去"被偷"我该怎么做?

A: 最完美的方法就是使用std::move。比如:

string aaa = "abc";string bbb = "bcd";aaa = std::move(bbb);

这时候会调用move赋值,如果有的话。这种语句在程序体中有时候会出现,基本含义就是bbb不想活了,基本也是这样。这也是基本 语意。按照前面string的实现,aaa和bbb完成了一次互换。所以,不想活了也不是绝对的。

string aaa = "abc";string &&r = std::move(aaa);string bbb(r);

你把一个对象的实例赋值给一个右值引用,不会对这个对象产生任何影响。使用这个引用去构造其它对象或者赋值给其它对象, 不会调用move构造和赋值,而是调用copy构造和赋值。原始对象也不会被析构,它的生命周期跟传统c++一样。

这看上去很让人迷糊吧!在c++0x里一定要时刻记住右值和右值引用是两个概念。右值引用可不一定是右值,它大多数情况下是左值。 为什么?因为它有名字啊,你能操作它啊,你能触碰它啊!在上面的例子里,r虽然是右值引用但是它是个左值,用它去构造只会 调用copy构造。

尽管move后的对象没有消失(析构),但一般在程序里看到std::move(bbb);这样的代码就意味着bbb可能是个易变的或者你准备放弃它了。 它虽然不会被析构,但是它的内容可能会改变,它在后续程序中的作用已经是无关轻重了,其数据已经不可控了。

呵呵,这只是个心理暗示,你如果确信知道类的实际操作,你还是可以使用它。它也还是个类实例。

Q: 既然如此我该怎么写个move构造或者赋值呐?

A: 如果类成员是基础类型,或者叫做标量类型,int啊,指针啊,引用啊,你可以自由决定是copy还是swap还是毫无操作置之不理。 否则,尽量去调用它的move构造或者赋值。也就是使用一次std::move。例如。

struct foo {    foo2 *_p;    string _str;    foo(foo &&x) : _p(), _str(std::move(x._str) {        std::swap(_p, x._p);    }}

上面这个例子算是最无害的标准操作了。在这里x是个左值,它有名字。它的子对象_str也是左值,你可以直接索引到它。 但这是个move构造,你应该希望str也是执行move构造,所以你需要强制转换str成为右值。

Q: c++编译器会像生成默认copy构造那样为程序生成默认move构造么?

A: 如果类符合可默认move构造条件的话,编译器会为它生成默认move构造。

Q: 什么是符合默认构造条件?

A: 没有定义copy构造/copy赋值/move赋值而且没有定义析构函数的类,就是符合条件的类。

Q: 默认move构造是怎么实现的?

A: 简单的内存copy。

Q: 如果把你上面提到的string类改造成可以默认move构造,岂不是会出现double free情况?

A: 确实是这样。其实,你使用这种默认的copy构造,也会double free。

Q: 我实在是不喜欢move语意,我能阻止编译器生成默认move构造么?

A: 可以。

string(string&&x) = delete;

Q: std::move看上去很神奇,它具体怎么弄的?它会不会影响效率?

A: 它就是进行了一次强制转换。它有个修饰符是constexp,这是编译器级的转换,而且很强烈。它基本是无害的,我只是担心你在move 构造和赋值的时候忘记使用它,这会导致你无法达到设计目标。比如前面的类foo的move构造,如果初始化_str的时候没有使用std::move, 就会用string的copy构造来初始化_str,这有可能会达不到你的设计目标。

Q: 尽管你说只是在move语意的构造和赋值函数中出现右值引用,但是经常在模板库的函数参数上看到很多右值引用&&

A: 这是右值引用的另外一个作用:完美参数传递,尽管模板函数上体现的是&&,最终结果一般不是右值引用。

Q: 什么是完美参数传递?

A: 这要提到const以及引用在模板参数中产生的负面效应。比如声明一个函数

template<typename _T>void iniline foo(_T &f){}

如果我们传入一个常量1,就会出现问题。ok,一怒之下改成这样:

template<typename _T>void iniline foo(_T f){}

对于一个正常的类,就会产生构造,如果是指针哪?更不可知。以前的c++一般要完成基于const的重载来解决这个问题。但是,问题是 模板套模板,有时候几乎是无穷无尽的,我怎么知道要重载多少个函数才够用?请注意,在这个例子里只是一个参数,如果是N个就是2的 N次方个函数需要重载。

所以,c++0x的模板一般声明称这样:

template <typename _T>void inline foo(_T &&f){}

这个函数参数里的&&其实是模板参数类型推导的一部分。c++0x制定了一个规则:

a & & = a&;a & && = a&;a && & = a&;a && && = a&&;

以前的c++是不容许引用引用的,c++0x可以引用引用,引用引用的规则就是上面4条。上面的示例函数的参数_T &&f的含义是这是个_T的右值引用。 _T是模板参数,这时候c++要完成参数展开和重载匹配。c++在进行模板函数参数展开的时候,如果传入的是左值它尽量使用左值引用。例如:

int n = 1;foo(n);

这时候类型_T不是int,而是int&,根据上面的引用的引用规则,函数参数f的类型是int& && = int&。如果传入的是个常量,比如:

foo(1);

什么都不需要做,参数f本来就是个右值引用,这时候函数参数f的类型是int&&。这样一个函数就能把以前需要const重载成2个函数的任务完成了。 特别是如果N个参数,用1个函数就概括了2^N个的重载。这可以称之为完美参数。

或许有人会问,就算是你通过这种方式把参数传进来了并且保持了变量的原有本质(品质),但是在函数体内也会出现类型冲突啊!这不是白费功夫么? 确实存在这个问题。但是在很多重型模板库里,模板函数调用模板函数的情况是非常普遍的。这些参数当前模板函数并不想去解释它,这些参数会被 传递到下一个被调用的模板函数中。这就是函数参数传递。

c++0x用std::forward,把参数传递给下一个被调用的函数。例如:

template <typename _T>void inline foo(_T &&f){    foo2(std::forward<_T>(f));}

Q: 引用的引用有点令人困惑,是类似指针的指针那样么?

A: 前面提到,c++0x的引用实际上是个抽象概念,引用可以解释为:alias。在引用的引用情况下就是别名的别名。

读者大怒,特么的,nnd,到底指向了什么东西?咋说呐,这有点像unix文件系统的软连接。int n就像是个文件,引用是对n的软连接,我们操作 引用跟操作n是一样的。引用的引用就是在软连接上再建一个软连接,其结果?引用的引用最终还是指向n。操作引用的引用跟操作引用和操作n是一样的。 所以不要把它想象成指针的指针。

这种软连接的逻辑在程序里是怎么体现的(汇编级)?这个软连接概念是编译器级的,由编译器来维护,在汇编级没有任何体现,该怎么操作n就怎么操作 n。

所以c++0x提出了一个规则来“折叠引用”到最简:

a & & = a&;a & && = a&;a && & = a&;a && && = a&&;

这个规则的名称就叫引用折叠规则。

Q: std::forward的工作原理是什么?它是怎么实现参数传递的?

A: std::forward<_T>(f)的作用就把_T强制转换成_T&&。根据折叠规则,这种操作不会影响_T的原始品质。

Q: 关于std::forward的原理的解释令人迷惑。它好像什么都没做啊!那要它有什么用,还敲那么多字母!

A: 在大多数情况下确实是这样,大多数情况下就算是没有它,直接传递参数程序也会工作的很好。它的主要作用是保护move语意。 这里第三次提醒读者右值和右值引用是两个概念。右值的产生只有两种途径,1、一个常量,2、一个函数返回的临时对象。

template <typename _T>void inline foo(_T &&f){    _T obj(f);}struct object {    object(){}    object(const object&){}    object(object &&) {}};object make_object(object obj) {    return obj;}int main() {    object obj;    foo(make_object(obj));    return 0;}

这个foo函数在展开后,参数f确实是个右值引用。但是右值引用并不等于右值,它是个左值,因为它有名字。从这段代码来看,我们原始设计 是希望调用它的move构造,但是很可惜调用的是copy构造。

传入函数foo的是个右值,因为它是个函数返回值。经过foo函数展开后,变成了左值。但是如果把foo,写成:

template <typename _T>void inline foo(_T &&f){    _T obj(std::forward<_T>(f));}

则会正确的调用object的move构造,为什么?因为forward是个函数,它返回的右值引用就可以作为右值来用。否则就会丢失右值语意。

这看上去超级复杂,但是在实际编程中,我们不需要考虑太多这些问题。c++0x的完美参数forward是个非常非常复杂的概念,但是使用上却是 最最简单的,远远比move语意要简单。那就是只要是模板函数参数,当向下传递的时候,你就老老实实的使用std::forward。这是绝绝对对 不会出错的。甚至,你是否理解参数传递原理都不重要,老老实实的向下forward就行了。

Q: std::forward会影响效率么?

A: 跟std::move一样,它们都有constexpr修饰。这是个编译器级的。

Q: 在这个参数传递的过程中,我需要注意什么么?

A: 完全不必要,只需要忠实的把所有参数forward下去,编译器就会去寻找一个最靠谱的匹配。

Q: 为什么我在编写模板的时候没问题,最后实例化的时候说我传递的参数无法转换成右值?

A: 如果是嵌套模板,参数已经固化,就不存在模板参数推导问题。这时候,参数就只是需要提供右值引用。比如

template<typename ..._Args>struct foo {    void operator()(_Args&&...args) {    }};

这种情况下,实例化后的模板已经知道_Args是什么,这种写法的意思是所有的参数都是右值引用。会报错。其实,不是所有的模板函数在变参上都要写 右值的,你可以很自信的去掉它。直接写成:

template<typename ..._Args>struct foo {    void operator()(_Args...args)   {    }};

还是要记住,在模板函数参数推导中出现的&&是引用的右值引用。在推导和展开的过程中,通过引用的引用规则,其结果不一定是右值引用。上面这个例子 模板在展开的时候_Args的所有类型都已经确定了。这就不存在模板函数参数推导了,_Args&&...args的意思是所有的参数都是右值引用。

在这种情况下,如果我还是想使用函数参数推导,那么就需要开始一个新的模板推导,如下的_Args2

template<typename ..._Args>struct foo {    template <typename ..._Args2>    void operator()(_Args2&&...args) {    }};
原创粉丝点击