欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

C++类-对象移动、移动构造函数、移动赋值运算符

程序员文章站 2022-03-22 20:58:17
...

一、对象移动的概念

C++11新标准中一个最主要的特性就是提供了移动而非拷贝对象的能力。如此做的好处就是,在某些情况下,对象拷贝后就立即被销毁了,此时如果移动而非拷贝对象会大幅提升性能。

二、右值引用

为了支持移动操作,C++11引入了一种新的引用类型——右值引用(rvalue reference)。所谓的右值引用指的是必须绑定到右值的引用。使用&&来获取右值引用。

这里给右值下个定义:只能出现在赋值运算符右边的表达式才是右值。相应的,能够出现在赋值运算符左边的表达式就是左值,注意,左值也可以出现在赋值运算符的右边。对于常规引用,为了与右值引用区别开来,我们可以称之为左值引用(lvalue reference)。下面是左值引用与右值引用示例:

int i=42;
int& r=i;           //正确,左值引用
int&& rr=i;         //错误,不能将右值引用绑定到一个左值上
int& r2=i*42;       //错误,i*42是一个右值
const int& r3=i*42; //正确:可以将一个const的引用绑定到一个右值上
int&& rr2=i*42;     //正确:将rr2绑定到乘法结果上,右值引用

从上面可以看到左值与右值的区别有: 
(1)左值一般是可寻址的变量,右值一般是不可寻址的字面常量或者是在表达式求值过程中创建的可寻址的无名临时对象; 
(2)左值具有持久性,右值具有短暂性。

左值持久,右值短暂

左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。

右值引用指向将要被销毁的对象。因此,可以从绑定到右值引用的对象“窃取”状态,即使用右值引用的代码可以*接管所引用的对象的资源。

不可寻址的字面常量一般会事先生成一个无名临时对象,再对其建立右值引用。所以右值引用一般绑定到无名临时对象,无名临时对象具有如下两个特性: 
(1)临时对象将要被销毁; 
(2)临时对象无其他用户。 
这两个特性意味着,使用右值引用的代码可以*地接管所引用的对象的资源。

一般而言,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。

左值到右值引用的转换:

虽然不能直接将右值引用直接,但是我们可以显示地将一个左值转换为对应的右值引用类型。我们可以通过调用新标准库中的模板函数move来获得绑定到左值的右值引用。

int&& rr1=42;
int&& rr2=rr1;              //error,表达式rr1是左值
int&& rr2=std::move(rr1);   //ok

上面的代码说明了右值引用也是左值,不能对右值引用建立右值引用。move告诉编译器,在对一个左值建立右值引用后,除了对左值进行销毁和重新赋值,不能够再访问它。

move的参数是接收一个任意类型的右值引用,通过引用折叠,此参数可以与任意类型实参匹配。特别的,我们既可以传递左值,也可以传递右值给move。

string s1("hi");
string&& s2=std::move(string("bye"));   //正确:从一个右值移动数据  
string&& s3=std::move(s1);              //正确:在赋值之后,s1的值是不确定的

三、右值引用的作用---实现移动构造函数和移动赋值运算符

右值引用的作用是用于移动构造函数(Move Constructors)和移动赋值运算符( Move Assignment Operator)。为了让我们自己定义的类型支持移动操作,我们需要为其定义移动构造函数和移动赋值运算符。这两个成员类似对应的拷贝操作,即拷贝构造和赋值运算符,但它们从给定对象窃取资源而不是拷贝资源。

移动构造函数:C++11 进一步提高程序效率

(1)A移动B,那么对象A我们就不能再使用了

(2)移动:并不是把内存中的数据从一个地址复制到另一个地址,数据所有者变更。

Time::Time(const Time &tmpTime){} // 拷贝构造函数 左值引用
Time::Time(const Time &&tmpTime){} // 移动构造函数 右值引用

移动构造函数和移动赋值运算符应该完成的功能

(1)完成必要的内存移动,斩断原对象和内存的联系

(2)确保移动后原对象处于一种即便被销毁也没有什么问题的一种状态。A-->B确保不再使用A,而应该使用B。

四、移动构造函数

移动构造函数类似于拷贝构造函数,第一个参数是该类类型的一个右值引用,同拷贝构造函数一样,任何额外的参数都必须有默认实参。完成资源移动后,原对象不再保留资源,但移动构造函数还必须确保原对象处于可销毁的状态。

移动构造函数的相对于拷贝构造函数的优点:移动构造函数不会因拷贝资源而分配内存,仅仅接管源对象的资源,提高了效率。

class B
{
public:
    // 默认构造函数
	B():m_bm(100)
    {
	    cout << “类B的构造函数执行了”<<endl;
    }

    // 拷贝构造函数
    B(const B& tmp):m_bm(tmp.m_bm)
    {
	    cout <<”类B的拷贝构造函数执行了”<<endl;
    }

    virtual ~B()
    {
	    cout << “类B的析构函数执行了”<<endl;
    }
    
    int m_bm;
}

class A
{
public:
	A():m_pb(new B()) // 这里调用类B的构造函数
    {
	    cout<<”调用了类A的构造函数”<<endl;
    }
    // 拷贝构造函数
    A(const A& tmp): m_pb(new B(*(tmp.m_pb))) // 调用类B的拷贝构造函数
    {
	    cout <<”类A的拷贝构造函数执行了”<<endl;
    }

    virtual ~A()
    {
	    delete m_pb;
	    cout << “类A的析构函数执行了”<<endl;
    }
    // 移动构造函数
    //noexcept 通知标准库这个移动构造函数不抛出任何异常
    A(A &&tmpa) noexcept :m_pb(tmpa.m_pb) //原来对象A指向的内存m_pb,我直接让这个临时对象直接指向这段内存
    {
	    tmpa.m_pb = nullptr;
	    cout <<”类A的移动构造函数执行了”<<endl;
    }

private:
	B *m_pb;
}

static A getA()
{
	A a;
	return a;  // 临时对象
    // 如果类A中有移动构造函数,那么会调用移动构造函数
}

//main()函数
B *pb = new B();
pb->m_bm = 19;
B *pb2 = new B(*pb);  // 调用B类的拷贝构造函数

delete pb;
delete pb2;

//main函数
A a = getA();
// 调用了一次构造函数、一次拷贝构造函数,2此析构函数程序执行完毕

五、移动赋值运算符

移动赋值运算符类似于赋值运算符,进行的是资源的移动操作而不是拷贝操作从而提高了程序的性能,其接收的参数也是一个类对象的右值引用。移动赋值运算符必须正确处理自赋值。

拷贝赋值运算符

A &operator=(const A& src)
{
	if(this == &src)
		return *this;
	delete m_pb;  // 将自己这块内存干掉
	m_pb = new B(*(src.m_pb));  // 重新分配一块内存
	std::cout<<“类A的拷贝赋值运算符执行了”<<endl;
	return *this;
}

移动赋值运算符

A &operator=( A&& src)noexcept
{
	if(this == &src)
		return *this;
	delete m_pb;  // 将自己这块内存干掉
	m_pb =src.m_pb;  // 对方的内存直接拿过来直接使用
    src.m_pb = nullptr;//斩断原(也就是对方和当前内存的联系要斩断)
	std::cout<<“类A的移动赋值运算符执行了”<<endl;
	return *this;
}

移动操作、标准库容器和异常

由于移动操作“窃取”资源,它通常不会分配任何资源。因此,移动操作通常不会抛出任何异常。

当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库。除非标准库知道不会抛出异常,否则它会为了处理可能抛出异常这种可能性而做一些额外的工作。

一种通知标准库的方法是将构造函数指明为 noexcept。这个关键字是新标准引入的。

不抛出异常的移动构造函数和移动赋值运算符都必须标记为noexcept.

 

移后源对象必须可析构

从一个对象移动数据并不会销毁此对象,但有时在移动操作完成后,源对象会被销毁。当我们编写一个移动操作时,必须确保移后源对象进入一个可析构的状态。即将移后源对象的指针成员置为nullptr来实现的。

在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。

 

六、合成的移动操作

在某些条件下,编译器能合成移动构造函数,移动赋值运算符

a)有自己的构造函数,自己的拷贝构造赋值运算符,或者自己的析构函数,那么编译器就不会为它合成移动构造函数和移动赋值运算符

所以有一些类是没有移动构造函数和移动赋值运算符的;

b)没有我们没有自己的移动构造函数和移动赋值运算符,那么系统会调用我们自己写的拷贝构造函数和拷贝复制运算符来代替;

c)只有一个类没有定义任何自己版本的拷贝构造成员(没有拷贝构造函数也没有拷贝赋值运算符),且类的每个非静态成员都可以移动时,

编译器才会为该类合成移动构造函数或者移动赋值运算符。

什么叫做成员可以移动呢?

(1)内置类型是可以移动的

(2)类类型成员,则这个类要有对应的移动操作相关的函数,就可以移动。

此时编译器就能为我们合成移动构造函数和移动赋值运算符。

七、总结

(1)尽量给类增加移动构造函数和移动赋值运算符

(2)noexcept

(3)该给nullptr就要给nullptr,让被移动对象随时处于一种能被析构的状态

(4)没有移动时,会调用拷贝代替。