右值引用只不过是一种新的C++语法,真正理解起来有难度的是基于右值引用引申出的2种C++编程技巧,分别为移动语义和完美转发。
左值、右值、将亡值:
概念:
从以下几个概念逐步深入:
表达式:
要说清“三值”,首先要要说明表达式。
表达式的定义:由运算符和运算对象构造的计算式,称为表达式。
举例:
字面值和变量是最简单的表达式,“a+b”也是表达式,函数的返回值也被认为是表达式。
值类别:
表达式是可求值的,对表达式求值将得到一个结果。这个结果有两个属性:“类型”和“值类别”。(“类型”指:int、char等数据类型;“值类别”指:左值、右值等)
在C++11以后,如果表达式按“值类别”划分,则必然属于以下三者之一:左值、纯右值、将亡值。 其中,左值和将亡值合成“泛左值”,纯右值和将亡值合称“右值”。
详细说明:
左值:
能够出现在“赋值号(=)”左侧,能够取地址 的表达式,称为左值表达式。
举例:
函数名、变量名(实际上是“函数指针”和“具名变量”)、前置自增/自减运算符连接的表达式(++i、–i)、由赋值运算符或符合赋值运算符连接的表达式(a=b、a+=b、a%=b)等。
纯右值:
满足以下条件之一:
- 本身就是赤裸裸的、纯粹的“字面值”,如:3、false;
- 求值结果相当于 字面值 或是一个 不具名的临时对象。
举例:
除字符串字面值以外的字面值、返回非引用类型的函数调用(此时函数返回的是临时对象)、后置自增/自减运算符(i++、i–)、取地址表达式(&a)、算数表达式(a+b、a&b、a<<b,这些都是返回的临时对象)、逻辑表达式(a&&b、a||b)、比较表达式(a==b、a>=b、a<b等,同样是返回临时对象)等。
将亡值:
在C++11之前,右值和纯右值是等价的。在C++11中的将亡值是随着右值引用的引入而新引入的。 也就是说,将亡值与右值引用息息相关。
所谓的“将亡值表达式”,就是下列表达式:
- 返回一个右值引用的表达式;
- 转换为右值引用的转换函数的调用表达式。
将亡值到底指的是什么:
在C++11中,我们用 左值 去初始化一个对象或为一个已有对象赋值时,会调用 拷贝构造函数 或者 拷贝赋值运算符 来 “拷贝资源”(所谓资源,就是new出来的东西);
当我们用一个 右值 (包括纯右值和将亡值)来初始化或赋值时,会调用 移动构造函数 或者 移动赋值运算符 来 “移动资源”,从而避免拷贝,提高效率。 当该右值完成初始化或赋值的任务时,它的资源已经移动给了被初始化者或被赋值者,同时该右值也将马上被销毁(析构)。
也就是说,当一个右值准备完成初始化或赋值任务时(用这个右值去初始化其他变量或者给其他变量赋值),它已经“将亡”了。
又因为:
这种右值是与C++11新生事物 ---- “右值引用”相关的新右值;
这种右值常用来完成“移动构造”或者“移动赋值”的特殊任务,扮演着“将亡”的角色;
所以,C++11给这类右值起了一个新的名字 ------ 将亡值。
即:将亡值是C++11中的一种特殊的右值,是一个准备将自己的值移动、赋值给其他人的右值。
(右值 = 纯右值 + 将亡值)
左值持久;右值短暂
考察左值和右值表达式的列表,两者相互区别之处就很明显了:左值有持久的状态, 而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。
由于右值引用只能绑定到临时对象,我们得知
- 所引用的对象将要被销毁
- 该对象没有其他用户
这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源。
右值引用指向将要被销毁的对象。因此,我们可以从绑定到右值引用的对象“窃取”状态。
左值引用、右值引用:
在C++98/03标准中就有左值引用(&),此时只能操作C++中的左值,无法对右值添加引用:
int num = 10;
int &b = num; //正确
int &c = 10; //错误!
注意,虽然C++98/03标准不支持为右值建立非常量左值引用,但允许使用常量左值引用操作右值,也就是说,常量左值引用既可以操作左值,也可以操作右值:
int num = 10;
const int &b = num; //正确
const int &c = 10; //正确,常量左值引用可以操作右值
在C++11标准中引入了另一种引用方式,称为右值引用,用 “&&” 表示。
和左值引用一样,右值引用也必须立即进行初始化操作,且只能使用右值进行初始化:
就算对象是个右值引用也不行
int num = 10;
int &&a = num; //错误!右值引用不能初始化为左值
int &&b = 10; //正确
int&& c=a; //错误,不能将一个右值引用绑到另一个右值引用上
和使用常量左值引用操作右值的方式不同的是,右值引用还可以对右值进行修改:
int &&a = 10;
a = 100; //正确
C++语法上也支持定义 常量右值引用,虽然这种定义出来的右值引用并无实际用处:
const int&&a = 10;
虽然不能将一个右值引用直接绑定到一个左值上,但是可以显式的将一个左值转换为对应的右值引用类型,即:
int val = 5;
int &&r3 = static_cast<int&&>(val);
cout << r3 << endl;
右值引用在C++11中的应用:
C++11中与右值引用相关的几个函数:
std::move();
std::forward();
emplace_back();
通过这些函数,我们可以避免不必要的拷贝,提高程序性能。
std::move
除了使用强制类型转换的方法,c++11中引入了一个新的标准库函数 std::move ,专门用来获得绑定到左值的右值引用,例如:
int val = 5;
int &&r3 = std::move(v);
std::move() 函数模板的原型为:
#include <utility>template<typename T>
typename remove_reference<T>::type&& move(T&& arg) noexcept;
//返回值类型为右值引用
std::move() 是将对象的状态或者所有权从一个对象转移到另一个对象,只是转移,没有内存的搬迁或者内存拷贝。
move 调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。我们必须认识到,调用move就意味着承诺:除了对v赋值或销毁它外,我们将不再使用它。在调用move之后,我们不能对移后源对象的值做任何假设。
我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。
如前所述,与大多数标准库名字的使用不同,对move 我们不提供using声明。我们直接调用std:move而不是move,其原因是move的名字冲突太多了,直接使用std::move可以避免错误。
使用move的代码应该使用 std:move而不是move。这样做可以避免潜在的名字冲突。
深拷贝和std::move() 的区别:
深拷贝和std::move是两种不同的操作。
深拷贝是指创建一个新的对象,并将原始对象的内容复制到新对象中。这个过程会分配新的内存空间,并复制原始对象的所有数据,包括值和指针所指向的内容。深拷贝操作后,原始对象和新对象是完全独立的,对其中一个对象的修改不会影响另一个对象。
std::move是C++11引入的一个新特性,它并不会创建新的对象或者复制数据,而是将原始对象的资源所有权转移到新的对象中。这个过程只是简单地将原始对象的指针设置为nullptr,同时新对象获得了原始对象的指针。这样做的目的是为了避免不必要的数据复制,提高程序的性能。
总结起来,深拷贝是创建一个完全独立的新对象,而std::move只是将资源的所有权转移给新对象。深拷贝适用于需要创建独立对象的情况,而std::move适用于需要高效地转移资源所有权的情况。
另外需要注意:与大多数的标准库名字的使用不同,对 std::move 函数不提供 using声明,需要直接调用 std::move 而不是 move。 《C++ Primer》中的解释是因为“move(以及forward)的名字冲突比其他标准库函数的冲突频繁的多,我们建议最好使用它们的带限定语句的完整版本,这样就能明确的知道想要使用的是函数的标准库版本”。
std::forward() :
右值引用类型是独立于值的,一个右值引用参数作为参数的形参,在函数内部再转发该参数的时候,它已经变成一个左值了,并不是它原来的类型了。
因此,我们需要一种方法能够按照参数原来的类型转发到另一个函数,这种转发被称为“完美转发”。
所谓完美转发(perfect forwarding),是指在函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另外一个函数。
C++11中提供了这样的一个函数 std::forward(),它是为转发而生的,它会按照参数本来的类型来转发出去,不管参数类型是 T&& 这种未定的引用类型还是明确的左值引用或者右值引用。
std::forward() 函数模板的原型:
template <typename T>
T&& forward(typename remove_reference<T>::type& arg) noexcept;template <typename T>
T&& forward(typename remove_reference<T>::type&& arg) noexcept;
移动构造函数和移动赋值运算符:
为了让我们自己的类型支持移动操作,需要为其定义移动构造函数和移动赋值运算符。这两个成员类似对应的拷贝操作,但它们从给定对象“窃取”资源而不是拷贝资源
与拷贝构造函数不同,移动构造函数不分配任何新内存;它接管给定对象中的内存。
- 类似拷贝构造函数,移动构造函数的第一个参数是该类类型的一个引用。不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个右值引用。
- 与拷贝构造函数一样,任何额外的参数都必须有默认实参。
除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源一这些资源的所有权已经归属新创建的对象。
在接管内存之后,它将给定对象中的指针都置为nullptr。这样就完成了从给定对象的移动操作,此对象将继续存在。最终,移后源对象会被销毁,意味着将在其上运行析构函数。
使用右值引用进行对象的移动构造时,有一点必须注意:
我们使用右值引用,就意味着要接管源对象的内存,在右值引用完成后(资源完成移动后),源对象中的指针必须立即置为nullptr,源对象必须不再指向被移动的资源, 因为这些资源的所有权已经归属新创建的对象,必须防止程序后序操作中通过源对象释放资源导致出错。
举例:
//StrVec类原型:
class StrVec {
public:StrVec(StrVec &&s); //移动构造函数
private:string *elements;string *first_free;string *cap;
};//移动构造函数不应抛出任何异常
StrVec::StrVec(StrVec &&s) noexcept : elements(s.elements), first_free(s.first_free), cap(s.cap)
{ s.elements = nullptr;s.frst_free = nullptr;s.cap = nullptr; //令源对象s进入这样的状态---对其运行析构函数是安全的
}
移动赋值运算符
移动赋值运算符执行与析构函数和移动构造函数相同的工作。
与移动构造函数一样,如果我们的移动赋值运算符不抛出任何异常,我们就应该将它标记为noexcept。
类似拷贝赋值运算符,移动赋值运算符必须正确处理自赋值:
移动赋值运算符 的实现与移动构造函数的原理相似:
//移动赋值运算符:
StrVec& StrVec::operator=(StrVec&& rhs) nonexcept {//先进行异常判断:如果是自赋值,则直接返回*this即可if(this != &rhs) {free(); //释放已有元素elements = rhs.elements;first_free = rhs.first_free;cap = rhs.cap;rhs.elements = rhs.first_free = rhs.cap = nullptr; //将rhs置为可析构状态}return *this;
}
移动操作、标准库容器和异常
由于移动操作“窃取”资源,它通常不分配任何资源。
因此,移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库。
我们将看到,除非标准库知道我们的移动构造函数不会抛出异常,否则它会认为移动我们的类对象时可能会抛出异常,并且为了处理这种可能性而做一些额外的工作。
一种通知标准库的方法是在我们的构造函数中指明noexcept。
noexcept是我们承诺一个函数不抛出异常的一种方法。我们在一个函数的参数列表后指定noexcept。在一个构造函数中,noexcept出现在参数列表和初始化列表开始的冒号之间:
StrVec::StrVec(StrVec &&s) noexcept : elements(s.elements), first_free(s.first_free), cap(s.cap)
{ s.elements = nullptr;s.frst_free = nullptr;s.cap = nullptr; //令源对象s进入这样的状态---对其运行析构函数是安全的
}
我们必须在类头文件的声明和定义中(如果定义在类外的话)都指定noexcept。
不抛出异常的移动构造函数和移动赋值运算符都必须标记为noexcept
移后源对象必须可析构
从一个对象移动数据并不会销毁此对象,但有时在移动操作完成后,源对象会被销毁。
因此,当我们编写一个移动操作时,必须确保移后源对象进入一个可析构的状态。
我们的stzvec的移动操作满足这一要求,这是通过将移后源对象的指针成员置为nullptr来实现的。
除了将移后源对象置为析构安全的状态之外,移动操作还必须保证对象仍然是有效的。
- 一般来说,对象有效就是指可以安全地为其赋予新值或者可以安全地使用而不依赖其当前值。
- 另一方面,移动操作对移后源对象中留下的值没有任何要求。
因此,我们的程序不应该依赖于移后源对象中的数据。
例如,当我们从一个标准库string或容器对象移动数据时,我们知道移后源对象仍然保持有效。因此,我们可以对它执行诸如empty或size这些操作。但是,我们不知道将会得到什么结果。我们可能期望一个移后源对象是空的,但这并没有保证。
我们的StrVec类的移动操作将移后源对象置于与默认初始化的对象相同的状态。因此,我们可以继续对移后源对象执行所有的StrVec操作,与任何其他默认初始化的对象一样。而其他内部结构更为复杂的类,可能表现出完全不同的行为。
在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。
合成的移动操作
与处理拷贝构造函数和拷贝赋值运算符一样,编译器也会合成移动构造函数和移动赋值运算符。但是,合成移动操作的条件与合成拷贝操作的条件大不相同。
回忆一下,如果我们不声明自己的拷贝构造函数或拷贝赋值运算符,编译器总会为我们合成这些操作。拷贝操作要么被定义为逐成员拷贝,要么被定义为对象赋值,要么被定义为删除的函数。
与拷贝操作不同,编译器根本不会为某些类合成移动操作。特别是,如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。
只有当一个类没有定义任何自己版本的拷贝成员(类没有定义任何拷贝构造函数),且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
所以大多数类如果没有显式定义移动构造函数和移动赋值运算符的话,它们都是不会支持移动操作的。
因此,某些类就没有移动构造函数或移动赋值运算符。如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作来代替移动操作
只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static 数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。编译器可以移动内置类型的成员。如果一个成员是类类型,且该类有对应的移动操作,编译器也能移动这个成员:
//编译器会为X和hasX合成移动操作
struct X{
int i; // 内置类型可以移动
std::string s; // string定义了自己的移动操作
};
struct hasX{
X mem; //X有合成的移动操作
};
X x, x2 = std::move(x); // 使用合成的移动构造函数
hasX hx, hx2 = std::move(hx); // 使用合成的移动构造函数
只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都Note 能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
与拷贝操作不同,移动操作永远不会隐式定义为删除的函数。
但是,如果我们显式地要求编译器生成=default的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。
除了一个重要例外,什么时候将合成的移动操作定义为删除的函数遵循与定义删除的合成拷贝操作类似的原则:
- 与拷贝构造函数不同,移动构造函数被定义为删除的函数的条件是:有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符的情况类似。
- 如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的,则类的移动构造函数或移动赋值运算符被定义为删除的。
- 类似拷贝构造函数,如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的。
- 类似拷贝赋值运算符,如果有类成员是const的或是引用,则类的移动赋值运算符被定义为删除的。
例如,假定y是一个类,它定义了自己的拷贝构造函数但未定义自己的移动构造函数
//假定Y是一个类,它定义了自己的将贝构造离数但未定义自己的移动构造函数
struct hasY {
hasY() = default;
hasY(hasY&&) = default;
Y mem; // hasY将有一个删除的移动构造函数
);
hasy hy, hy2 = std:move(hy);//错误:移动构造函数是删除的
编译器可以拷贝类型为x的对象,但不能移动它们。
类hasy显式地要求一个移动构造函数,但编译器无法为其生成。
因此,hasy会有一个删除的移动构造函数。如果hasy忽略了移动构造函数的声明,则编译器根本不能为它合成一个。如果移动操作可能被定义为删除的函数,编译器就不会合成它们。
移动操作和合成的拷贝控制成员间还有最后一个相互作用关系:一个类是否定义了自己的移动操作对拷贝操作如何合成有影响。如果类定义了一个移动构造函数和/或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除的。
定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作,否则,这些成员默认地被定义为删除的。
移动右值,拷贝左值·····
如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数。赋值操作的情况类似。
例如,在我们的Strvec类中,拷贝构造函数接受一个const StrVec的引用。因此,它可以用于任何可以转换为Strvec的类型。而移动构造函数接受一个StrVec&&,因此只能用于实参是(非static)右值的情形:
StrVec v1, v2;
v1 = v2; // v2是左值;使用拷贝赋值
StrVec getvec(istream &); // getVec返回一个右值
v2 =getVec(cin); // getVec(cin)是一个右值;使用移动赋值
在第一个赋值中,我们将v2传递给赋值运算符。v2的类型是StrVec,表达式v2是一个左值。因此移动版本的赋值运算符是不可行的,因为我们不能隐式地将一个右值引用绑定到一个左值。因此,这个赋值语句使用拷贝赋值运算符。
在第二个赋值中,我们赋予v2的是getvec调用的结果。此表达式是一个右值。在此情况下,两个赋值运算符都是可行的——将getVec的结果绑定到两个运算符的参数都是允许的。调用拷贝赋值运算符需要进行一次到const的转换,而StrVec&&则是精确匹配。因此,第二个赋值会使用移动赋值运算符。
...…但如果没有移动构造函数,右值也被拷贝
如果一个类有一个拷贝构造函数但未定义移动构造函数,会发生什么呢?
在此情况下,编译器不会合成移动构造函数,这意味着此类将有拷贝构造函数但不会有移动构造函数。
如果一个类没有移动构造函数,函数匹配规则保证该类型的对象会被拷贝,即使我们试图通过调用move来移动它们时也是如此:
class Foo
{
public:
Foo()= default;
Foo(const Foo&);//拷贝构造函数
//其他成员定义,但Foo未定义移动构造函数
};
Foo x;
Foo y(x); //拷贝构造函数;x是一个左值
Foo z(std::move(x));//拷贝构造函数,因为未定义移动构造函数
在对z进行初始化时,我们调用了move(x),它返回一个绑定到x的Foo&&。Eoo的拷贝构造函数是可行的,因为我们可以将一个Foo&&转换为一个const Foo。因此,z的初始化将使用Foo的拷贝构造函数。
值得注意的是,用拷贝构造函数代替移动构造函数几乎肯定是安全的(赋值运算符的情况类似)。一般情况下,拷贝构造函数满足对应的移动构造函数的要求:它会拷贝给定对象,并将原对象置于有效状态。实际上,拷贝构造函数甚至都不会改变原对象的值。
如果一个类有一个可用的拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来“移动”的。拷贝赋值运算符和移动赋值运算符的情况类似。
移动迭代器
新标准库中定义了一种移动迭代器适配器。
一个移动送代器通过改变给定迭代器的解引用运算符的行为来适配此选代器。
一般来说,一个迭代器的解引用运算符返回一个指向元素的左值。
与其他迭代器不同,移动迭代器的解引用运算符生成一个右值引用。
我们通过调用标准库的 make_move_iterator函数将一个普通迭代器转换为一个移动迭代器。此函数接受一个迭代器参数,返回一个移动迭代器。
原迭代器的所有其他操作在移动迭代器中都照常工作。
由于移动迭代器支持正常的选代器操作,我们可以将一对移动迭代器传递给算法。特别是,可以将移动迭代器传递给uninitialized_copy:
void StrVec::reallocate()
{
// 分配大小两倍于当前规模的内存空间
auto newcapacity = size() ? 2 * size() : 1;
auto first = alloc.allocate (newcapacity);
// 移动元素
auto last = uninitialized_copy (make_move_iterator (begin () ) , make_move_iterator(end()),
first);
free(); // 释放旧空间
elements = first; // 更新指针
first_free = last;
cap = elements + newcapacity;
}
uninitialized_copy对输入序列中的每个元素调用construct来将元素“拷贝”到目的位置。
此算法使用送代器的解引用运算符从输入序列中提取元素。由于我们传递给它的是移动送代器,因此解引用运算符生成的是一个右值引用,这意味着construct将使用移动构造函数来构造元素。
值得注意的是,标准库不保证哪些算法适用移动迭代器,哪些不适用。由于移动一个对象可能销毁掉原对象,因此你只有在确信算法在为一个元素赋值或将其传递给一个用户定义的函数后不再访问它时,才能将移动迭代器传递给算法。
建议:不要随意使用移动操作
由于一个移后源对象具有不确定的状态,对其调用std::move是危险的。当我们调用move时,必须绝对确认移后源对象没有其他用户。
通过在类代码中小心地使用 move,可以大幅度提升性能。而如果随意在普通用户代码(与类实现代码相对)中使用移动操作,很可能导致莫名其妙的、难以查找的错误,而难以提升应用程序性能。
在移动构造函数和移动赋值运算符这些类实现代码之外的地方,只有当你确信Practices
需要进行移动操作且移动操作是安全的,才可以使用std::move。
右值引用和成员函数
除了构造函数和赋值运算符之外,如果一个成员函数同时提供拷贝和移动版本,它也能从中受益。这种允许移动的成员函数通常使用与拷贝/移动构造函数和赋值运算符相同的参数模式——一个版本接受一个指向const的左值引用,第二个版本接受一个指向非 const的右值引用。
例如,定义了push_back的标准库容器提供两个版本一个版季在一个右值引用参数,而另一个版本有一个const左值引用。
假定×是元素类型,那么这些容器就会定义以下两个push back版本:
void push back(const X&); //拷贝:绑定到任意类型的×
void push back(X&&); // 移动:只能绑定到类型×的可修改的右值
我们可以将能转换为类型×的任何对象传递给第一个版本的push_back。此版本从其参数拷贝数据。
对于第二个版本,我们只可以传递给它非const的右值。此版本对于非一个可修改的右值(参
const的右值是精确匹配(也是更好的匹配)的,因此当我们传递一个可修改的右值时,编译器会选择运行这个版本。此版本会从其参数窃取数据。
一般来说,我们不需要为函数操作定义接受一个const X&&或是一个(普通的)X&参数的版本。
当我们希望从实参“窃取”数据时,通常传递一个右值引用。为了达到这一目的,实参不能是const的。
类似的,从一个对象进行拷贝的操作不应该改变该对象。因此,通常不需要定义一个接受一个(普通的)X&参数的版本。
区分移动和拷贝的重载函数通常有一个版本接受一个const T&,而另一个版本接受一个T&&。
作为一个更具体的例子,我们将为Strvec类定义另一个版本的push back:
class StrVec {
public:
void push back (const std::string&); //拷贝元素
void push back(std::string&&); //移动元素
// 其他成员的定义,如前
};void StrVec::push back(const string& s)
chk_n_alloc();//确保有空间容纳新元素
//在first free指向的元素中构造s的一个副本
alloc.construct(first_free++,s);
}void StrVec::push back(string &&s)
{
chk_n_alloc();// 如果需要的话为StrVec 重新分配内存
alloc.construct(first_free++,std::move(s));
}
这两个成员几乎是相同的。差别在于右值引用版本使用move来将其参数传递给construce。
如前所述,construct函数使用其第2个和随后的实参的类型来确定使用哪个构造函数。
由于move返回一个右值引用,传递给construct 的实参类数是string&&。
因此,会使用string的移动构造函数来构造新元素。
当我们调用push_back时,实参类型决定了新元素是拷贝还是移动到容器中:
StrVec vec; //空StrVec
string s = "some string or another";
vec.push back(s); //调用 push back(const stringa)
vec.pushback("done”); // 调用push_back(string&4)
这些调用的差别在于实参是一个左值还是一个右值(从"done"创建的临时 atring),具你调用哪个版本据此来决定。
右值和左值引用成员函数
通常,我们在一个对象上调用成员函数,而不管该对象是一个左值还是一个右值。
例如:
string sl = "a value",s2 = "another";
auto n =(sl + s2).find('a’);
此例中,我们在一个string右值上调用find成员,该string右值是通过连接两个string而得到的。
有时,右值的使用方式可能令人惊讶:
sl + s2 = "wow!";
此处我们对两个string的连接结果——一个右值,进行了赋值。
在旧标准中,我们没有办法阻止这种使用方式。为了维持向后兼容性,新标准库类仍然允许向右值赋值。但是,我们可能希望在自己的类中阻止这种用法。
在此情况下,我们希望强制左侧运算对象(即,this指向的对象)是一个左值。
我们指出this的左值/右值属性的方式与定义const成员函数相同,即,在参数列表后放置一个引用限定符:
class Foo (
public:
Foo &operator=(const Foo&) &;// 只能向可修改的左值赋值
// Foo的其他参数
};
Foo &Foo::operator=(const Foo &rhs) &
{
//执行将rhs赋予本对象所需的工作
return *this;
}
引用限定符可以是&或&&,分别指出this可以指向一个左值或右值。
类似const限定符,引用限定符只能用于(非static)成员函数,且必须同时出现在函数的声明和定义中。
对于&限定的函数,我们只能将它用于左值:对于&&限定的函数,只能用于右值:
Foo &retFoo(); //返回一个引用;xetFoo调用(这个表达式)是一个左值Foo retVal(); // 返回一个值;retVal调用(这个表达式)是一个右值Foo i, j; //i和j是左值i=j;retFoo()=j; // 正确:i是左值
//正确:retFoo()返回一个左值retVal()=j; //错误:retVal()返回一个右值i=retVal(); // 正确:我们可以将一个右值作为赋值操作的右侧运算对象
一个函数可以同时用const和引用限定。在此情况下,引用限定符必须跟随在const限定符之后:
class Foo (
public:
Foo someMem()&const; //错误:const限定符必须在前
Foo anotherMem()const &; // 正确:const限定符在前
};
可能大家还不是很了解,我们再来梳理一下
左值引用成员函数是用
&
引用限定符来定义的成员函数。它只能在左值上被调用,即只能通过左值对象或左值引用来调用。这种成员函数可以修改对象的状态,并且允许返回非常量引用,从而支持链式赋值和修改对象的成员。
例子:
class MyClass {
public:void modify() & {// 左值引用成员函数// 只能在左值对象/引用上调用该函数// 可以修改对象的状态}void print() const & {// 常量左值引用成员函数// 只能在左值对象/引用上调用该函数// 不会修改对象的状态}
};MyClass obj;
obj.modify(); // 通过左值对象调用modify()成员函数
右值引用成员函数是用
&&
引用限定符来定义的成员函数。它只能在右值上被调用,即只能通过右值对象或右值引用来调用。这种成员函数可以修改对象的状态,并且支持移动语义,允许返回非常量右值引用。
例子:
class MyClass {
public:void modify() && {// 右值引用成员函数// 只能在右值对象/引用上调用该函数// 可以修改对象的状态}void print() const && {// 常量右值引用成员函数// 只能在右值对象/引用上调用该函数// 不会修改对象的状态}
};MyClass().modify(); // 通过右值对象调用modify()成员函数
左值引用和右值引用成员函数可以共存于同一个类中,并根据对象的值类别来选择调用相应的成员函数。
MyClass obj;
obj.modify(); // 调用左值引用成员函数
MyClass().modify(); // 调用右值引用成员函数
需要注意的是,右值引用成员函数不会自动调用移动构造函数或移动赋值运算符,需要显式调用std::move()
来进行移动操作。
重载和引用函数
就像一个成员函数可以根据是否有const来区分其重载版本一样,引用限定符也可以区分重载版本。
而且,我们可以综合引用限定符和const来区分一个成员函数的重载版本。
例如,我们将为Foo定义一个名为 data的vector成员和一个名为sorted的成员函数,sorted 返回一个Foo对象的副本,其中vector已被排序:
class Foo {
public:
Foo sorted()&&; //可用于可改变的右值
Foo sorted()const &; //可用于任何类型的Foo
// Foo的其他成员的定义
private:vector<int> data;
};//本对象为右值,因此可以原址排序
Foo Foo::sorted() &&
{
sort(data.begin(), data.end());
return *this;
}//本对象是const或是一个左值,哪种情况我们都不能对其进行原址排序
Foo Foo::sorted() const &
{
Foo ret(*this); //拷贝一个副本
sort(ret.data.begin(), ret.data.end()); // 排序副本
return ret; //返回副本
}
当我们对一个右值执行sorted时,它可以安全地直接对data成员进行排序。对
象是一个右值,意味着没有其他用户,因此我们可以改变对象。
当对一个 const右值或一个左值执行sorted时,我们不能改变对象,因此就需要在排序前拷data,
编译器会根据调用sorted的对象的左值/右值属性来确定使用哪个sorted版本,
retVal().sorted();//retval()是一个右值,调用Foo::sorted() &&
retFoo(),sorted(); //retFoo()是一个左值,调用 Foo::sorted() const &
当我们定义const成员函数时,可以定义两个版本,唯一的差别是一个版本有const限定而另一个没有。引用限定的函数则不一样。如果我们定义两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有函数都加上引用限定符,或者所有都不加,
class Foo {
public:
Foo sorted() &&;
Foo sorted() const;//错误:必须加上引用限定符
// Comp是函数类型的类型别名(参见6.7节,第222页)
// 此函数类型可以用来比较int值
using Comp = bool(const int&, const int&);
Foo sorted(Comp*); // 正确:不同的参数列表
Foo sorted(Comp*) const; // 正确:两个版本都没有引用限定符
};
本例中声明了一个没有参数的const版本的sorted,此声明是错误的。
因为Foo类还有一个无参的sorted版本,它有一个引用限定符,因此const版本也必须有引用定符。
另一方面,接受一个比较操作指针的sorted版本是没问题的,因为两个函数都有引用限定符。
如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有用限定符。