C++ 左值右值,用于移动(move)和转发(forward)

本文深入探讨了C++中的移动语义和完美转发技术,解释了它们如何提高代码性能,特别是在处理右值引用时。通过具体的代码示例说明了移动构造函数和移动赋值操作符的作用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

    新标准重新定义了lvalue和rvalue,并允许函数依照这两种不同的类型进行重载。通过对于右值(rvalue)的重新定义,语言实现了移动语义(move semantic)和完美转发(perfect forwarding),通过这种方法,C++实现了在保留原有的语法并不改动已存在的代码的基础上提升代码性能的目的。作为一个C++0x标准的初学者,理解这些概念其实还有有一定的困难的,加上网上能够找得到的中文资源有比较的少,少有的资源写的也都不是那么的通俗易懂,多少有点晦涩,这也为学习设置了一定的障碍。同样作为初学者,我就花了不少时间研究这些概念,终于算是有所体悟。这里就把我粗浅的理解记录于此,希望能够给后来接触这些内容的同侪们以帮助,也供我日后参考。谬误之处在所难免,还望不吝赐教。

移动语义解决了什么问题

我们先看一段代码:

class Test {
    int * arr{nullptr};
public:
    Test():arr(new int[5000]{1,2,3,4}) { 
    	cout << "default constructor" << endl;
    }
    Test(const Test & t) {
        cout << "copy constructor" << endl;
        if (arr == nullptr) arr = new int[5000];
        memcpy(arr, t.arr, 5000*sizeof(int));
    }
    ~Test(){
        cout << "destructor" << endl;
        delete [] arr;
    }
};

这是一段常见的类的定义。在其中我们定义了一个int类型数组arr,它一共有5000个元素。考虑到我们可以使用一个已有的Test对象来初始化一个新的Test对象,我们实现了复制构造函数(copy constructor)。

接下来,我们考虑一个这样的应用场景:

int main() {
	Test reusable;
	// do something to reusable
	Test duplicated(reusable);
	// do something to reusable
}

我们创建了一个reusable变量并对其做了某一些操作,之后我们使用这个更改过的reusable变量初始化一个duplicated变量,在对其进行初始化之后,我们依然需要对reusable做其他的操作。在这个情境下,reusableduplicated变量各自有自己的用处,没有谁是为谁附带产生的。所以我们看到,在这个情境下,我们的复制构造函数是合情合理的。

现在我们考虑另外一个场景:

Test createTest() {
    return Test();
}

int main() {
    Test t(createTest());
}

在这个场景当中,我们需要使用一个工厂函数来构造Test的实例。那么在这个场景下,我们的复制构造函数被调用了2次。这两次调用相当于复制了10000个元素,是一个不小的开销。可是我们的这个开销有意义吗?我们知道,在工厂函数当中建立的Test实例在函数返回时就会被析构,而用于返回值的Test的临时实例也会在将值赋给main函数当中的t之后被析构。也就是说,这两个临时对象事实上并没有什么意义。由于构造他们而产生的复制的开销其实完全没有必要(事实上,编译器一般会对这种情况进行(N)RVO,但不见得每次都能很好的优化)。所以我们就在考虑,有没有可能我们可以将在工厂函数当中构造的成员变量的那块内存“偷”过来,而不是重新开辟一块内存,然后再将之前的内容复制过来呢?

移动语义(move semantic)

铛铛铛铛!移动语义登场了!移动语义就是为了解决上面的这种问题而产生的。通过移动语义,我们可以在没有必要的时候避免复制。那么在接下来,我们就重点来谈一谈移动构造函数(move constructor)。相信到这里你已经意识到了,移动构造函数的出现就是为了解决复制构造函数的这个弊病。所以,其实移动构造函数应该和复制构造函数实现差不多的功能。那么,它也应该是一种构造函数的重载(好废的废话……)。所以,我们可以想象出来,其实移动构造函数大概就会是这个样子:

Test(<KEYWORD> t):arr(t.arr){t.arr = nullptr;}

这里解释一下,通过移动构造函数,事实上我们是做了一个浅拷贝(shallow copy)。至于要将之前的指针置为空的原因在于,我们的类会在析构的时候delete掉我们的数组。那么我们浅拷贝出来的这个对象的成员变量(arr指针)就变成了一个悬挂指针(dangling pointer)。

好了,现在的问题变成了,这个<KEYWORD>究竟是什么?编译器如何自动判断到底应该调用复制构造函数(我突然想起来这个东西的翻译貌似应该是拷贝构造函数,但是既然都已经写了这么多了,我就不改了)还是移动构造函数呢?

左值(lvalue)、右值(rvalue)、左值引用(lvalue-reference)和右值引用(rvalue-reference)

左值和右值

为了回答上面的这个问题,我们首先需要明确左值和右值的概念。C++定义了与C不相同的左值和右值的判断方法,不过说起来非常简单: 凡是真正的存在内存当中,而不是寄存器当中的值就是左值,其余的都是右值。其实更通俗一点的说法就是: 凡是取地址(&)操作可以成功的都是左值,其余都是右值。现在相信大家都已经知道左值和右值的关系了。我们来看几个例子:
// lvalues:
int i = 42;
i = 43; // ok, i is an lvalue 
int* p = &i; // ok, i is an lvalue 
int& foo();
foo() = 42; // ok, foo() is an lvalue
int* p1 = &foo(); // ok, foo() is an lvalue
// rvalues: 
int foobar(); 
int j = 0;
j = foobar(); // ok, foobar() is an rvalue
int k = j + 2; // ok, j+2 is an rvalue
int* p2 = &foobar(); // error, cannot take the address of an rvalue 
j = 42; // ok, 42 is an rvalue

那么,函数是不是就只可以作为右值呢?其实不是。考虑一个我们司空见惯的例子:

vector<int> vec = {1,2,3,4,5};
vec[1] = 99; // overloaded operator[]

我们看到,其实 operator[]是一个函数,其返回值依然可以作为左值。

左值引用和右值引用

好了,在明确了左值和右值的关系之后,左值引用而右值引用的概念也就显而易见了。对于左值的引用就是左值引用,而对于右值的引用就是右值引用。虽然这么说,但是其实这个概念还并不是那么好理解。

事实上,不好理解的原因是我们之前从来没有真正的去区分过这两个概念,因为我们曾经将左值引用直接称为“引用”。也就是说,我们曾经一直用的int&事实上是对于int类型左值的引用。而对于右值呢?在新标准当中我们使用int&&来表示。我们不妨看看几个例子:

void foo(const int & i) { cout << "const int & " << i << endl; }
void foo(int & i) { cout << "int & " << i << endl; }
void foo(int && i) { cout << "int && " << i << endl; }
void foo(const int && i) { cout << "const int && " << i << endl; } // 这是个奇葩,我一会说
我们在以往使用的时候大多会使用第一种形式。其实,第一种形式是一种神奇的形式,因为 const int &既可以绑定左值,也可以绑定右值。所以在没有后面三个重载函数的情况下,我们调用一下语句:
int i = 2;
foo(i);
foo(2);

他们的输出都是 const int & 2。而如果在只有第二个函数而没有其他函数的时候,第三条语句是违法的。在只有第三个函数没有其它函数的时候,第二条语句是违法的。所以我们总结一下:const reference可以绑定所有的值,而其他类型的引用只能绑定自己类型的值。在这四种函数都存在的情况下,每一种函数都会绑定与自己最接近的那个值。也就是说,在四个函数都存在的情况下,当我们再次运行上面的这段代码,输出的结果就将变成:
int & 2
int && 2

所以,当我们运行下面的语句:

foo(i);
foo(j);
foo(2);
foo([]()->const int && {return 2;}());

我们得到的结果将会是:

int & 2
const int & 2
int && 2
const int && 2

这里解释一下第四个。第四条语句编译的时候会有Warning,提示”Returning reference to local temporary object”。想想也确实是这么个事情,不过它让我通过了,而且结果没错误。我觉得这个是不靠谱的。不过其实仔细考虑一下,常量右值引用其实不太能想出什么应用场景。所以个人认为,这只是贯彻C++标准当中”不应当组织程序员拿起枪射自己的脚“的精神,到不一定有什么实际意义,所以这个就不要纠结了。

相信现在大家已经能够对于左值、右值、左值引用和右值引用有一个准确的认识了。

回到之前的问题

现在我们可以知道上面那个 Test类当中的神奇的 <KEYWORD>到底是什么了。其实他就是 Test &&。由于左值和右值是两种不同的类型,所以可以依照这个类型进行重载。所以我们的 Test类就变成了这样:

class Test {
    int * arr{nullptr};
public:
    Test():arr(new int[5000]{1,2,3,4}) { 
    	cout << "default constructor" << endl;
    }
    Test(const Test & t) {
        cout << "copy constructor" << endl;
        if (arr == nullptr) arr = new int[5000];
        memcpy(arr, t.arr, 5000*sizeof(int));
    }
    Test(Test && t): arr(t.arr) {
        cout << "move constructor" << endl;
        t.arr = nullptr;
    }
    ~Test(){
        cout << "destructor" << endl;
        delete [] arr;
    }
};

所以,当我们再次考虑下面这个应用场景的时候:

Test createTest() {
    return Test();
}

int main() {
    Test t(createTest());
}

我们会发现,打印的结果变成了:

default constructor
move constructor
destructor
move constructor
destructor
destructor

也就是说,我们的Test实例在工厂函数当中被使用默认构造函数(default constructor)构造一次之后,调用的全部都是移动构造函数,因为我们发现其实所有的这些值都是右值。这极大地节省了开支。

这里有一个编译器的trick。gcc是一个丧心病狂的编译器,他会强制进行(N)RVO。如果你不做任何设置直接用GCC编译运行上面的代码,你将看到的是:

default constructor

这个时候不要怀疑我上面说的东西有问题或者你写错了。请直接在gcc后面添加编译参数-fno-elide-constructors。所以整个的编译语句应该是:

g++ -std=c++11 -fno-elide-constructors test.cpp # for instance

移动语义再多说几句

现在我们再来看看一开始那个reusable的例子。

int main() {
	Test reusable;
	// do something to reusable
	Test duplicated(reusable);
	// do something to reusable
}

如果现在我们不想复制reusable了,我们也想在构造duplicated的时候使用转移构造函数,那么应该怎么做呢?新标准给我们提供了一个解决方案:

	Test duplicated(std::move(reusable));

这个std::move()的作用是将左值转换为右值。不过这里要注意的一点是,如果我们在这里使用了move的话,那么后面我们就不能再对reusable进行操作了。因为转移构造函数已经将reusable的成员变量arr指针置为空了。

讲解完了转移构造函数,其实转移赋值语句(move assignment)与之同理,各位就自己研究一下吧。由于STL已经默认对所有的代码进行了右值引用的改写,所以现在当你运行你之前写过的代码时,你不需要做任何的更改,就会发现似乎更快了一些。

进一步探讨左值和右值

我们来考虑下面的情景:

void doWork(TYPE&& param) {
	// ops and expressions using std::move(param)
}

这个代码是从Scott Meyers的演讲当中摘取的。现在的问题是:** param是右值吗? **答案是:不!param是一个左值。

这里牵扯到一个概念,即事实上左值和右值与类型是没有关系的,即int既可以是左值,也可以是右值。区别左值和右值的唯一方法就是其定义,即能否取到地址。在这里,我们明显可以对param进行取地址操作,所以它是一个左值。也就是说,但凡有名字的“右值”,其实都是左值。这也就是为什么上面的代码当中鼓励大家对所有的变量使用std::move()转成右值的原因。

完美转发(perfect forward)又是在做什么

我们依然考虑一个例子:

template <typename T>
void func(T t) {
    cout << "in func" << endl;
}

template <typename T>
void relay(T&& t) {
    cout << "in relay" << endl;
    func(t);
}

int main() {
    relay(Test());
}

在这个例子当中,我们的期待是,我们在main当中调用relayTest的临时对象作为一个右值传入relay,在relay当中又被转发给了func,那这时候转发给func的参数t也应当是一个右值。也就是说,我们希望:relay的参数是右值的时候,func的参数也是右值;当relay的参数是左值的时候,func的参数也是左值

那么现在我们来运行一下这个程序,我们会看到,结果与我们预想的似乎并不相同:

default constructor
in relay
copy constructor
in func
destructor
destructor

我们看到,在relay当中转发的时候,调用了复制构造函数,也就是说编译器认为这个参数t并不是一个右值,而是左值。这个的原因已经在上一节将结果了,因为它有一个名字。那么如果我们想要实现我们所说的,如果传进来的参数是一个左值,则将它作为左值转发给下一个函数;如果它是右值,则将其作为右值转发给下一个函数,我们应该怎么做呢?

这时,我们需要std::forward<T>()。与std::move()相区别的是,move()会无条件的将一个参数转换成右值,而forward()则会保留参数的左右值类型。所以我们的代码应该是这样:

template <typename T>
void func(T t) {
    cout << "in func " << endl;
}

template <typename T>
void relay(T&& t) {
    cout << "in relay " << endl;
    func(std::forward<T>(t));
}

现在运行的结果就成为了:

default constructor
in relay
move constructor
in func
destructor
destructor

而如果我们的调用方法变成:

int main() {
    Test t;
    relay(t);
}

那么输出就会变成:

default constructor
in relay
copy constructor
in func
destructor
destructor

完美地实现了我们所要的转发效果。

通用引用(universal reference)

现在一定有同学感到奇怪了,既然我刚才讲的完美转发就是怎么传进来怎么传给别人,那么也就是说在后面这个例子当中我们传进来的这个参数t竟然是一个左值!可是我们的参数表里不是写着T&&,要求接受一个右值吗?其实不是这样的。这里就牵扯到一个新的概念,叫做通用引用。

通用引用(universal reference)是Scott Meyers在C++ and Beyond 2012演讲中自创的一个词,用来特指一种引用的类型。构成通用引用有两个条件:

  1. 必须满足T&&这种形式
  2. 类型T必须是通过推断得到的

所以,在我们完美转发这个部分的例子当中,我们所使用的这种引用,其实是通用引用,而不是所谓的单纯的右值引用。因为我们的函数是模板函数,T的类型是推断出来的,而不是指定的。那么相应的,如果有一段这样的代码:

template <typename T>
class TestClass {
	public:
		void func(T&& t) {} //这个T&&是不是一个通用引用呢
}

上面的这个T是不是通用引用呢?答案是不是。因为当这个类初始化的时候这个T就已经被确定了,不需要推断。

所以,可以构成通用引用的有如下几种可能:

  1. 函数模板参数(function template parameters)

     template <typename T>
     void f(T&& param);
    
  2. auto声明(auto declaration)

     auto && var = ...;
    
  3. typedef声明(typedef declaration)
  4. decltype声明(decltype declaration)

那么,这个通用引用与其他的引用有什么区别呢?其实最重要的一点就是引用类型合成(Reference Collapsing Rules)。规则很简单:

  1. T& & => T&
  2. T&& & => T&
  3. T& && => T&
  4. T&& && => T&&

简单一点说,就是传进来的如果是左值引用那就是左值引用,如果是右值引用那就是右值引用。但是注意,这个合成规则用户是不允许使用的,只有编译器才能够使用这种合成规则。这就是为什么上面的通用引用当中有一条要求是类型必须可以自动推导。这个合成规则其实就是类型推倒的规则之一。

这样,我们就可以知道为什么Scott Meyers在演讲中建议大家在通用引用的情境下,尽可能使用forward()了,因为这样可以在不改变语义的情况下提升性能。

template <typename T>
void doWork(T && param) {
	// ops and expressions using std::forward<T>(param)
}

后记

C++0x通过引入许多新的语言特性来实现了语言性能的提升,使得本来就博大精深的一门语言变得更加的难以学习。但是一旦了解,就会被语言精妙的设计所折服。参考资料中给出了更多的关于左值、右值、左值引用、右值引用、移动语义和完美转发的例子。我自己实在是没有精力看完所有的这些资料了,各位有兴趣的话可以参阅。

参考资料

  1. http://thbecker.net/articles/rvalue_references/section_01.html#section_01
  2. http://blog.youkuaiyun.com/pongba/article/details/1697636
  3. http://channel9.msdn.com/Shows/Going+Deep/Cpp-and-Beyond-2012-Scott-Meyers-Universal-References-in-Cpp11
  4. https://isocpp.org/blog/2012/11/universal-references-in-c11-scott-meyers
  5. https://onedrive.live.com/view.aspx?resid=F1B8FF18A2AEC5C5!1062


<think>嗯,用户现在想了解C++中的引用引用的概念及区别。我需要先回顾一下这些基本概念,确保自己理解正确,然后再组织语言用中文清晰解释。 首先,的定义。通常指的是有明确内存地址的对象,可以取地址,比如变量、类对象等。而则是临时的,无法取地址,比如字面量、临时对象或表达式结果。比如,int a = 5中的a是,5是。 接下来是引用引用。引用用&符号,只能绑定,比如int& b = a;而引用用&&,只能绑定,比如int&& c = 5。不过,用户提供的引用内容中提到,当使用auto&&或模板参数推导时,可能会有引用折叠的情况,这时候引用可能推导为引用,比如当传递一个引用变量时,因为它有名字,所以被当作处理,这可能让用户容易混淆。 然后需要解释移动语义完美转发,但用户的问题主要集中在概念区别,可能需要简要提及这些高级特性,但重点还是放在基础区别上。根据引用中的例子,比如引用在传递时会被推导为,导致调用引用的函数,这说明理解引用折叠规则类型推导很重要。 另外,用户提供的引用内容中提到了引用折叠的规则,比如T&&在类型推导时,如果传递的是,会被折叠为引用。例如,auto&& bb = a1,其中a1是引用变量,但因为它本身是,所以推导为引用。这点需要强调,因为引用变量本身是,只有表达式才是。 用户可能混淆的地方在于引用变量虽然是引用类型,但其本身作为具名变量是,所以传递给函数时会被当作处理。例如,在引用1中的例子,传递引用变量t时,t是,所以调用引用的函数。这可能涉及到移动语义中的std::move,将转换为引用。 总结起来,回答的结构应该包括定义、区别、示例代码以及常见误区。需要确保每个概念都清晰,并且对比引用引用的使用场景语法差异。同时,引用折叠类型推导的规则需要简明解释,但不过于深入以免让初学者困惑。 最后,检查是否有遗漏点,比如const对引用的影响,或者移动构造函数的作用,但根据用户的问题,可能不需要深入这些高级主题,除非必要。确保回答符合用户的需求,帮助他们建立基础概念,并能够区分及其引用的不同。</think>--- ### C++、引用类型全解析 --- #### 一、核心定义与关系图谱 ```mermaid graph TD A[表达式] --> B[] A --> C[] B --> D[可获取地址] C --> E[临时/字面量] B --> F[可修改] C --> G[不可直接修改] H[引用类型] --> I[引用 &] H --> J[引用 &&] ``` --- #### 二、基础概念对比 | **类型** | **特征** | **生命周期** | **典型示例** | |----------------|---------------------------------------------|--------------------------|-------------------------| | **** | 具名对象,可获取地址 | 持续到作用域结束 | `int a = 10;` 中的`a` | | **** | 匿名临时,不可取地址 | 立即销毁(可能延长) | `5`, `a+3`的结果 | | **引用** | 绑定的引用(`&`) | 与绑定对象同步 | `int &b = a;` | | **引用** | 绑定的引用(`&&`) | 接管资源所有权 | `int &&c = 10;` | --- #### 三、关键区别详解 ##### 1. ** vs ** - **地址访问性** ```cpp int x = 5; int* p1 = &x; // ✅ 可获取地址 int* p2 = &5; // ❌ 编译错误:无法取地址 ``` - **生命周期控制** ```cpp std::string s1 = "hello"; // s1持续存在 std::string s2 = s1 + " world"; // 临时对象立即销毁 ``` ##### 2. **引用 vs 引用** - **绑定规则** ```cpp int a = 10; int &lr = a; // ✅ 引用绑定 int &&rr1 = 10; // ✅ 引用绑定 int &&rr2 = a; // ❌ 编译错误:引用不能绑定 ``` - **移动语义实现** ```cpp class MyString { public: // 移动构造函数(接管资源) MyString(MyString&& other) noexcept { data = other.data; other.data = nullptr; // 原对象置空[^1] } }; ``` --- #### 四、引用折叠规则(重点) 当出现**未定引用类型**时(如模板参数推导、`auto&&`),通过引用折叠确定最终类型: | **原始类型** | **推导类型** | **折叠规则** | **示例** | |--------------|-------------|--------------------------|----------------------------| | `T& &` | `T&` | 双引用折叠为引用 | `auto&& a = x;`(x为) | | `T& &&` | `T&` | 混合引用折叠为引用 | `auto&& b = get_lref();` | | `T&& &` | `T&` | 混合引用折叠为引用 | `auto&& c = std::move(y);` | | `T&& &&` | `T&&` | 双引用折叠为引用 | `auto&& d = 10;` | **示例分析**(来自引用[2]): ```cpp int&& a1 = 1; // a1是引用(绑定) auto&& bb = a1; // bb推导为int&(a1是具名引用→视为) auto&& bb1 = 2; // bb1推导为int&&(绑定) int a2 = 1; auto&& cc = a2; // cc推导为int&(a2是) ``` --- #### 五、实战场景与技巧 ##### 1. **完美转发(Perfect Forwarding)** ```cpp template<typename T> void wrapper(T&& arg) { // 万能引用(可绑定/) process(std::forward<T>(arg)); // 保留原始类型 } // 调用示例 int x = 5; wrapper(x); // 传递→调用process(int&) wrapper(10); // 传递→调用process(int&&) ``` ##### 2. **移动语义优化** ```cpp std::vector<int> createBigData() { std::vector<int> tmp(1000000); return tmp; // 触发移动构造(避免深拷贝)[^1] } auto v = createBigData(); // 高效资源转移 ``` --- #### 六、常见误区与调试 - **引用变量是** ```cpp int&& rref = 10; int& lr = rref; // ✅合法:rref是具名变量→视为 ``` - **`const`修饰符的影响** ```cpp const int&& crr = 10; crr = 20; // ❌错误:常量引用不可修改 ``` --- #### 七、总结对比表 | **特征** | **** | **** | **引用** | **引用** | |------------------|------------------------|------------------------|-----------------------|-----------------------| | **地址可获取性** | ✅ | ❌ | 继承绑定对象属性 | 继承绑定对象属性 | | **主要用途** | 存储持久数据 | 临时计算/资源转移 | 别名/避免拷贝 | 移动语义/完美转发 | | **典型绑定对象** | 变量、数组元素 | 字面量、返回临时对象 | | | ---
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值