天天看点

C++:31---对象引用和赋值

一、对象移动概述

  • C++11标准引入了“对象移动”的概念
  • 对象移动的特性是:可以移动而非拷贝对象
  • 在C++旧标准中,没有直接的方法移动对象。因此会有很多不必要的资源拷贝
  • 标准库容器、string、share_ptr类既支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝

对象移动的特点

  • 在很多情况下会发生对象拷贝的现象,对象拷贝之后就被销毁了,在这种情况下,对象移动而非对象拷贝会大幅度提升性能
  • 使用移动而非拷贝的另一个原因是:类似于IO类或unique_ptr这样的类,这些类都不能被共享资源(如指针或IO缓冲)。因此,这些类型的对象不能拷贝但可以移动

二、右值引用(&&)

  • 为了支持移动操作,C++11标准引入了新的引用类型——右值引用
  • 所谓右值引用就是必须绑定到右值的引用。我们通过&&而不是&来获得右值引用
  • 右值有一个很重要的性质:只能绑定到一个将要销毁的对象

左值引用(&)

  • “引用(reference)”类型介绍参阅:
  • 为了与“右值引用”区别开来,我们本篇文章中将“”引用(reference)称之为“左值引用”

右值引用的使用方法

  • 左值引用:
  • 不能将其绑定到要求“转换的表达式、字面值常量、返回右值的表达式”
  • 返回左值的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式。我们可以将一个左值引用绑定到这类表达式的结果上
  • 右值引用:
  • 则与左值引用相反,我们可以将一个右值引用到上面所述的表达式上,但是不能将一个右值引用直接绑定到一个左值上
  • 返回非引用类型的函数,连同算术、关系、位以及后置递增运算符,都生成右值。我们可以将一个const的左值引用或一个右值引用绑定到这类表达式上
  • 见下面的使用方法:
  1. int i = 42;
  2. int &r = i; //正确,r引用i
  3. int &&rr = i; //错误,不能将一个右值引用到左值上
  4. int &r2 = i * 42; //错误,i*42是一个右值
  5. const int &r3 = i * 42;//正确,我们可以将一个const的引用绑定到一个右值上
  6. int &&rr2 = i * 42; //正确,将rr2绑定到乘法结果上(右值)
  1. int ret(int i) {
  2. return i * 2;
  3. }
  4. int& ret2(int& i) {
  5. return i;
  6. }
  7. int &r = ret(1); //错误
  8. int &&rr = ret(1); //正确
  9. int &r2 = ret2(1); //正确
  10. int &&rr2 = ret2(1); //错误

左值持久、右值短暂

  • 左值一般是绑定到对象身上,因此左值是持久的
  • 而右值要么绑定在字面值常量、要么绑定到表达式求值过程中创建的临时对象身上,因此:
  • 右值引用所引用的对象将要被销毁
  • 该对象没有其他用户
  • 这两个特性意味着,使用右值引用的代码可以自由地接管所引用的对象的资源

变量是左值

  • 变量可以看做只有一个运算对象而没有运算符的表达式。因此不能将一个右值引用绑定到一个右值引用类型的变量上
  • 如下:
  1. int &&rr1 = 42; //正确,42是字面值
  2. int &&rr2 = rr1; //错误,表达式rr1是左值

标准库move()函数

  • 虽然不能将一个右值引用绑定到一个左值上,但是我们可以显式地将一个左值转换成对应的右值引用类型
  • move函数就是实现上面的功能,move函数用来获得绑定到左值上的右值引用
  • 此函数定义在头文件<utility>中
  1. int &&rr1 = 42; //正确,42是字面值
  2. int &&rr2 = std::move(rr1); //正确了

三、移动构造函数和移动赋值运算符

  • 与string一样,我们自己的类也支持移动和拷贝。为了支持移动,我们需要自己定义移动构造函数与移动赋值运算符
  • 下面是一个类的定义,用来作为下面讲解的基础
  1. class StrVec
  2. {
  3. public:
  4. StrVec()
  5. :elements(nullptr), first_free(nullptr), cap(nullptr) {}
  6. ~StrVec()
  7. {
  8. if (elements) { //如果数组不为空
  9. //释放内存
  10. }
  11. }
  12. private:
  13. std::string *elements; //指向数组首元素的指针
  14. std::string *first_free;//指向数组第一个空闲元素的指针
  15. std::string *cap; //指向数组尾后位置的指针
  16. };

移动构造函数

  • 格式如下:
  • 参数为“&&”类型,因为是移动操作
  • 参数不必设置为const,因为需要改变
  • 在构造函数后添加“noexcept”关键字,确保移动构造函数不会抛出异常
  • 针对上面的StrVec类,其移动构造函数的定义如下:
  • noexcept确保移动构造函数不会抛出异常
  • 在参数初始化列表中将参数s的资源移动给自己(先执行)
  • 然后在函数体内释放参数s的资源,这样之后就达到了资源移动的目的(后执行)
  1. StrVec(StrVec &&s) noexcept
  2. :elements(s.elements),first_free(s.first_free),cap(s.cap)
  3. {
  4. s.elements = s.first_free = s.cap = nullptr;
  5. }
  • 几点需要注意:
  • 移动构造函数不分配任何内存,只是简单的资源移动而已
  • 参数s在资源移动之后,其对象还是存在的。当s被销毁时,其会执行析构函数,从上面StrVec的析构函数可以看出我们将elements设置为nullptr之后,析构函数就不会释放资源了(因为资源是被移动了,不应该被释放)

移动赋值运算符

  • 格式如下:
  • 参数为“&&”类型,因为是移动操作
  • 参数不必设置为const,因为需要改变
  • 在函数后添加“noexcept”关键字,确保移动赋值运算符函数不会抛出异常
  • 与拷贝赋值运算符一样,函数返回自身引用
  • 在函数执行前,应该检测自我赋值的情况
  • 针对上面的StrVec类,其移动赋值运算符函数的定义如下:
  • noexcept确保函数不会抛出异常
  • 函数执行之前先判断一下是否为自我赋值
  • 先释放自身资源,再拷贝参数rhs的资源,最后再将rhs置为空
  1. StrVec& operator=(StrVec &&rhs)
  2. {
  3. //检测自我赋值,不能写成*this != s
  4. if (this != &rhs) {
  5. if (this->elements) {
  6. //释放自身的资源
  7. }
  8. //开始接管参数的资源
  9. elements = rhs.elements;
  10. first_free = rhs.first_free;
  11. cap = rhs.cap;
  12. //将参数置为空
  13. rhs.elements = rhs.first_free = rhs.cap = nullptr;
  14. }
  15. return *this;
  16. }
  • 为什么需要检测自我赋值:
  • 我们知道,右值引用只能绑定到一个右值身上,不能绑定到一个对象身上,因此照理说移动赋值运算符不会运用于对象身上,所以检测自我赋值照理说可以取消。但是注意,我们上面介绍的move()函数,可以显式地将一个左值转换成对应的右值引用类型,因此参数可能是move()调用返回的结果,因此我们需要在函数运行前检测自我赋值

四、移动后,对象仍是有效、可析构的

  • 从移动操作可以看出,一个对象(在此称为“源对象”)在被移动之后,源对象仍然保持有效,因此这个对象在操作完成之后仍然可以被销毁

五、合成的移动操作

  • “合成”意为“默认的”
  • 对于移动操作,编译器的规则如下:
  • 如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器不会为自己合成移动构造函数和移动赋值运算符
  • 只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为自己合成移动构造函数或移动赋值运算符(附加:编译器可以移动内置类型成员。如果一个成员是类类型,且该类有对应的移动操作,编译器也能移动这个成员)

演示案例

//编译器会为X和hasX合成移动操作
struct X {
int i;        //内置类型可以移动
std::string s;//string定义了自己的移动操作
};
struct hasX {
X mem; //X有合成的移动操作
};
int main()
{
X x;
X x2 = std::move(x);      //使用合成的移动构造函数
hasX hx;
hasX hx2 = std::move(hx); //使用合成的移动构造函数
return 0;
}      

六、删除的移动操作

  • 对于删除的移动操作有如下规则:
  • 与拷贝操作不同,移动操作永远不会隐式定义为删除的(=delete)函数。
  • 是,如果我们显示地要求编译器生成=default的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数
  • 何时将合成的移动操作定义为删除的函数遵循与定义删除合成的拷贝操作类似的原则:
  • ①与拷贝构造函数不同,移动构造函数被定义为删除的函数的条件是:有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数(移动赋值运算符的情况类似)
  • ②如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的,则类的移动构造函数或移动赋值运算符被定义为删除的
  • ③类似拷贝构造函数,如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的
  • ④类似拷贝赋值运算符,如果有类成员是const的或是引用,则类的移动赋值运算符被定义为删除的
  • 移动操作和合成的拷贝控制成员之间还有最后一个关系:
  • 一个类是否定义自己的移动操作对拷贝构造函数如何合成有影响
  • 如果类定义了一个移动构造函数和/或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符是被定义为删除的
  • 总结:定义了一个移动构造函数或移动赋值运算符的类必须定义自己的拷贝操作。否则,这些成员默认地被定义为删除的

演示案例

//假设Y是一个类,且Y定义了自己的拷贝构造函数但未定义自己的移动构造函数
struct hasY {
hasY() = default;
hasY(hasY &&) = default;
Y mem;
};
int main()
{
hasY hy;
hasY hy2 = std::move(hy); //错误,移动构造函数是删除的
return 0;
}      

演示案例

//StrVec只定义了移动构造函数与移动赋值运算符,但是没有定义拷贝构造函数与拷贝赋值运算符
class StrVec
{
//...
public:
StrVec(StrVec &&s)noexcept{}
StrVec& operator=(StrVec &&rhs){}
//...
};
int main()
{
StrVec v1, v2;
v1 = v2;  //错误,SreVec的拷贝赋值运算符被定义为删除的
return 0;
}      

七、移动右值、拷贝左值

  • 如果类既有“”移动构造函数,也有“拷贝构造函数”,编译器使用普遍的函数匹配机制来缺点使用哪个构造函数

演示案例

//假设SreVec的拷贝构造函数/拷贝赋值运算符,移动构造函数/移动拷贝赋值运算符都定义了
class StrVec{};
StrVec getVec(istream &)
{
//该函数返回一个SreVec对象(右值)  
}
int main()
{
StrVec v1, v2;
v1 = v2;          //v2是个左值,此处调用拷贝赋值运算符
v2 = getVec(cin); //getVec函数返回一个右值,此处调用移动赋值运算符
return 0;
}      

如果没有定义移动构造函数,右值也被拷贝

  • 如果一个类有一个拷贝构造函数但未定义移动构造函数,那么:
  • 因为类有了拷贝构造函数,编译器不会合成移动构造函数
  • 所以,对于右值的移动操作时调用拷贝构造函数的
  • 上面的规则也适用于拷贝赋值运算符
  • 演示案例:
class Foo {
public:
Foo() = default;
Foo(const Foo&); //拷贝构造函数
//未定义移动构造函数
};
int main()
{
Foo x;
Foo y(x);           //调用拷贝构造函数
Foo z(std::move(x));//因为Foo没有定义移动构造函数,所以此处调用的是拷贝构造函数
return 0;
}      
  • 使用拷贝构造函数代替移动构造函数几乎肯定是安全的(赋值运算符情况类似)。一般情况下,拷贝构造函数满足对应的移动构造函数的要求:它会拷贝给定对象,并将源对象置于有效状态。实际上,拷贝构造函数甚至都不会改变源对象的值

八、拷贝并交换赋值运算符和移动操作

class HasPtr {
public:
HasPtr(const std::string &s = std::string())
:ps(new std::string(s)), i(0) {}
HasPtr(const HasPtr& p)  //拷贝构造函数
:ps(new std::string(*(p.ps))), i(p.i) {}
~HasPtr() { delete ps; }
friend void swap(HasPtr&, HasPtr&);
private:
std::string *ps;
int i;
};
inline void swap(HasPtr &lhs, HasPtr &rhs)
{
using std::swap;
swap(lhs.ps, lhs.ps);//交换指针,而不是string数据
swap(lhs.i, lhs.i);  //交换int成员
}      
  • 现在我们为HasPtr类添加了一个移动构造函数和一个赋值运算符(这个赋值运算符比较特殊)
class HasPtr {
public:
//其他内容同上
//移动构造函数
HasPtr(HasPtr &&p)noexcept :ps(p.ps), i(p.i) { p.ps = 0; }
//这个赋值运算符即是移动赋值运算符,也是拷贝赋值运算符
HasPtr& operator=(HasPtr rhs)
{
swap(*this,rhs);
return *this;
}
};      

移动构造函数

  • 移动构造函数接管了给定实参的值、函数体内将p的指针置为0,从而确保销毁源对象是安全的
  • 此函数不会抛出异常,因此将其标记为noexcept

赋值运算符

  • 此处定义的赋值运算符的参数不是引用形式,意味着此参数要进行拷贝初始化
  • 依赖实参的类型,拷贝初始化:
  • 要么使用拷贝构造函数——左值被拷贝
  • 要么使用移动构造函数——右值被移动
  • 因此,此处定义的赋值运算符就实现了拷贝赋值运算符和移动赋值运运算符的两种功能
  • 例如:
  • 第一个赋值中,右侧对象hp2是一个左值,因此使用拷贝构造函数来初始化
  • 第二个赋值中,我们调用std::move()将将一个右值绑定到hp2上。此种情况下,拷贝构造函数和移动构造函数都是可以的。但是由于实参是一个右值引用,移动构造函数时精确匹配的
  1. HasPtr hp;
  2. HasPtr hp2;
  3. //hp2是一个左值。所以先调用拷贝构造函数复制一份HasPtr对象给operator=参数
  4. //再调用operator=函数将hp2赋值给hp
  5. hp = hp2;
  6. //此处hp2显式成为一个右值。所以先调用移动构造函数构造一份HasPtr对象给operator=参数
  7. //再调用operator=函数将hp2赋值给hp
  8. hp = std::move(hp2);
  • 不管使用的是拷贝构造函数还是移动构造函数,赋值运算符的函数体内都swap两个对象的状态。交换HasPtr回交换两个对象的指针(及int)成员。在swap之后,rhs中的指针将指向原来左侧对象所拥有的string(及int)。当rhs离开作用域后,这个对象将会销毁

九、右值引用和成员函数

  • 除了构造函数和赋值运算符之外,成员函数也可能提供两个版本:一个提供拷贝,另一份通过移动
  • 一份提供拷贝:参数为const&
  • 一份提供移动:参数为非const&&
  • 使用规则:
  • 对于拷贝版本:我们可以将任何类型的对象传递给该版本
  • 对于移动版本:只能传递给其非const的右值
  • 一般来说,我们不需要为函数定义接受一个const T&&或是一个(普通的)T&参数的版本。当我们希望从实参“窃取”数据时,通常传递一个右值引用。为了达到这个目的,实参不能使const的。类似的,从一个对象进行拷贝的操作不应该改变该对象,因此,通常不需要定义一个接受(普通的)T&参数的拷贝版本

演示案例

  • 对于push_back的标准库容器提供两个版本:
  • 一个版本有一个右值引用
  • 另一个版本有一个const左值引用
  • 例如:
  1. void push_(const X&); //拷贝版本
  2. void push_(X&&); //移动版本

演示案例

  • 作为更好的例子,我们将StrVec类进行修改,在其中添加了两个push_back()函数
  1. class StrVec
  2. {
  3. public:
  4. //其他同上
  5. void push_back(const std::string&);//拷贝元素
  6. void push_back(std::string&&); //移动元素
  7. private:
  8. static std::allocator<std::string> alloc; //分配元素
  9. //其他同上
  10. };
  11. void StrVec::push_back(const std::string& s)
  12. {
  13. chk_n_alloc(); //自定义函数,用来检测是否空间足够
  14. //在first_free指向的元素中构造s的一个副本,此处construct会调用string的构造函数来构造新元素
  15. alloc.construct(first_free++, s);
  16. }
  17. void StrVec::push_back(std::string&&)
  18. {
  19. chk_n_alloc();
  20. //此处由于参数为std::move()类型,因此construct会调用string的移动构造函数来构造新元素
  21. alloc.construct(first_free++,std::move(s));
  22. }
  • 当我们调用push_back()时,实参类型决定了新元素是拷贝还是移动到容器中:
  1. StrVec vec;
  2. string s = "some string or another";
  3. vec.push_back(s); //s为左值,因为调用push_back(const string&)
  4. vec.push_back("done"); //调用push_back(string&&)

十、右值和左值引用成员函数(引用限定函数)

  • 通常,我们在一个对象上调用成员函数,而不管该对象是一个左值还是一个右值:例如:
  1. string s1 = "a value", s2 = "another";
  2. //s1+s2是一个右值
  3. auto n = (s1 + s2).find('a');
  • 有时候,右值的使用还可能是下面的奇怪形式 
  1. string s1 = "a value", s2 = "another";
  2. s1 + s2 = "wow"; //s1+s2是一个右值,我们此处对一个右值进行了赋值(无意义)
  • 在旧标准中,我们没有办法阻止这种使用方式。为了维持向后兼容性,新标准库类仍然允许向右值赋值。但是,我们可以在自己的类中阻止这种办法。在此情况下,我们希望强制左侧运算对象是一个左值
  • 使用方法:
  • 在参数列表后放置一个引用限定符
  • 引用限定符可以是&或&&,分别该函数可以运用于一个左值对象(&)还是一个右值对象(&&)
  • 与const关键字一样,引用限定符只能作用于(非static)成员函数,且在声明和定义时都需要
  • 引用限定符可以和const一起使用,且const必须在限定符的前面。例如:
  1. class Foo {
  2. public:
  3. Foo someMem()&const; //错误,const必须在&&前面
  4. Foo anotherMem()const&;//正确
  5. };

演示案例

  1. class Foo {
  2. public:
  3. //此参数后面有一个&,因此这个函数只能被一个左值对象调用
  4. Foo &operator=(const Foo&)&;
  5. };
  6. Foo &Foo::operator=(const Foo& rhs)&
  7. {
  8. //执行将rhs赋予本对象的操作(代码省略)
  9. return *this;
  10. }
  • 在上面我们在operatror=的后面添加了一个&,因此operatror=只能运用于一个左值,见下面的代码
  1. Foo& retFoo()
  2. {
  3. //一个函数,返回Foo类,返回左值(引用)
  4. }
  5. Foo retVal()
  6. {
  7. //一个函数,返回Foo类,返回右值
  8. }
  9. int main()
  10. {
  11. Foo i, j;
  12. i = j;
  13. retFoo() = j; //正确,retFoo()返回一个左值
  14. retVal() = j; //错误,retVal()返回一个右值
  15. i = retFoo(); //正确,我们可以将一个左值作为赋值操作的右侧运算对象
  16. i = retVal(); //正确,我们可以将一个右值作为赋值操作的右侧运算对象
  17. return 0;
  18. }

演示案例②

  1. class Foo {
  2. public:
  3. void push_back() && {} //这个函数只能被一个右值Foo对象调用
  4. };
  5. Foo& retFoo()
  6. {
  7. //一个函数,返回Foo类,返回左值(引用)
  8. }
  9. Foo retVal()
  10. {
  11. //一个函数,返回Foo类,返回右值
  12. }
  13. int main()
  14. {
  15. Foo i;
  16. i.push_back(); //错误,i是一个左值
  17. retFoo().push_back(); //错误
  18. retVal().push_back(); //错误(这个没搞懂,retVal应该返回右值的啊,但是编译器报错)
  19. std::move(retVal()).push_back();//正确
  20. return 0;
  21. }

演示案例

  1. class Foo {
  2. public:
  3. //此函数只可以用于右值
  4. Foo sorted() && {
  5. std::sort(data.begin(), data.end());
  6. return *this;
  7. }
  8. //此函数可以用于左值或const类型的右值(因为其带有const,见下面的重载介绍)
  9. Foo sorted()const & {
  10. Foo ret(*this); //拷贝一个副本
  11. std::sort(ret.data.begin(), ret.data.end()); //排序副本
  12. return ret; //返回结果
  13. }
  14. private:
  15. std::vector<int> data;
  16. };
  17. Foo& retFoo()
  18. {
  19. //一个函数,返回Foo类,返回左值(引用)
  20. }
  21. Foo retVal()
  22. {
  23. //一个函数,返回Foo类,返回右值
  24. }
  25. const Foo retVal2()
  26. {
  27. //一个函数,返回Foo类,返回右值,且为const
  28. }
  29. int main()
  30. {
  31. retFoo().sorted(); //调用sorted()const&
  32. retVal().sorted(); //调用sorted() &&
  33. retVal2().sorted();//调用sorted()const&
  34. return 0;
  35. }
  • 对于sorted() &&:
  • 如果对象是一个右值,意味着没有其他用户,因此我们可以在函数内改变对象的内容
  • 对于sorted()const&:

十一、重载和引用函数

  • const成员函数重载时,可以定义两个版本:一个有const、一个没有const
class Foo {
public:
//下面两者形成重载
Foo sorted();
Foo sorted()const;
};      
  • 引用限定函数规则不一样:重载时必须两者都加上引用限定符
  1. class Foo {
  2. public:
  3. Foo sorted()&&;
  4. Foo sorted()const&&; //正确,与上面形成重载
  5. //Foo sorted()const; 这个是错误的
  6. };
  • 附加:如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符

继续阅读