c++中的左值引用、右值引用和移动构造函数、移动赋值操作符

1 引用

引用表示一个已存在对象的别名

一般变量初始化时,是将一个值复制到变量所在的内存中。而引用的初始化是将其绑定到一个对象,而不是将对象的初始值复制给对象,一旦初始化完成,引用就和对象一直绑定在一起。因此,引用不允许重新绑定,引用定义时必须进行初始化

定义一个引用后,对其进行的操作都是在其绑定的对象上进行操作的

1.1 左值 vs 右值

左值(lvalue):指向指定内存的一个东西,生命周期是长久的,可以看作是一个容器;
右值(rvalue):不指向任何地方的东西,生命周期是短暂的,可以看作是容器中的事物。

int x = 666;

x是一个左值,根据该语句出现的位置决定x指向栈中或全局区的一块内存;666是一个右值,存放在常量区,不指向任何对象。

赋值操作符=要求其左操作数必须为左值,下面的代码是不允许的:
在这里插入图片描述同理,&操作符是取地址操作,因为只有一个左值才指向一块内存,也才拥有地址。而右值是没有地址的,因此&操作符也只能操作左值。如:
在这里插入图片描述函数返回左值和右值

int x = 6;

int func1()
{
    return 6;
}

int& func2()
{
    return x;
}

按照下述方式进行使用:

    func1() = 5;
    func2() = 5;

第一句编译不通过,因为func1()返回的是右值,不能对右值进行赋值;第二句因为func2返回左值可以正常编译。

左值隐式转换为右值

    int x = 6;
    int y = x;

上面的代码中,=操作符的右操作数需要是右值,但x是左值,代码之所以能正常使用是因为进行了隐式的左值转换为右值。

返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符都是返回左值
返回非引用类型的函数,连同算术、关系、位及后置递增/递减运算符都是返回右值

1.2 左值引用

左值引用的形式是:

int ival = 1024;
int& ref1 = ival;

即使用&表示定义了一个引用,且将ref1绑定到了ival对象,因为ival是左值,因此这里定义的ref1是左值引用。

同一个语句中可以定义多个引用,每一个引用之前都需要使用&表示其为一个引用,如:
在这里插入图片描述根据其地址,说明了ref1ref2两个引用都和x有同样的地址,因此ref1ref2都是x的别名。

左值引用要求引用绑定的对象和引用的类型完全匹配,否则编译会报错,如:
在这里插入图片描述明确提示了无法用double类型变量初始化int&类型的引用。

不能将非常量引用绑定到右值,如:
在这里插入图片描述上述示例代码中,65行可以编译通过,66行编译失败,明确告知了“非常量引用的初始值必须为左值”。至于常量引用,可以用右值初始化,具体见下节。

1.3 常量左值引用

常量左值引用的形式为const int &,含义是对常量的引用表示引用指向的对象是一个常量,因此常量引用支持下述三种初始化方式:

	const int& ref1 = 1024;
    
    int x = 0;
    const int& ref2 = x;

    double y = 5.;
    const int& ref3 = y;

第一种方式,因为定义的是常量引用,自然可以引用到常量,也就是支持对右值定义常量引用;

第二种方式,允许将常量引用绑定到非常量对象,这种方式时编译器内部执行的操作为:const int temp = x;const int &ref2 = temp;,即先定义了一个临时的常量对象,将x转换为常量对象,然后将该临时常量对象绑定到常量引用。这种方式的一个隐患是,因为常量引用绑定的是非常量对象,如果执行x = 5;后,会发现ref2的值也会变成5。但ref2 = 5;则会编译失败,因为不允通过指向常量的引用给对象赋值。常量引用只是规定无法通过该引用改变引用的对象,如果该对象有其他非常量引用或者指针,通过这些改变该对象仍然是合法的

第三种方式的基本思路和第二种方式一样,增加了一个数据类型的转换,在将y转换为临时常量时进行了数据类型的转换,即执行const int temp = y;的过程中进行了数据类型转换,其余细节和第二种方式一致。

因为常量引用可以绑定到右值,因此下面的代码也是合法的:

void fnc(const int& x)
{
}

int main()
{
    fnc(10);  // OK!
}

c++中经常使用常量引用将值传递到函数中,避免了不必要的临时对象的创建和数据拷贝。

1.4 右值引用

右值引用的形式为&&,允许将右值绑定到引用,如:

int func1()
{
    return 6;
}

int func2()
{
    int x = 6;
    return x;
}

int&& ref1 = 3;
int&& ref2 = func1();
int&& ref3 = func2();
int&& ref4 = 2 * ref3;
int&& ref5 = ref3++;

从上面我们知道,字面值、返回非引用类型的函数和算术、关系、位、后递增/递减运算符返回的都是右值,因此可以将其绑定到右值引用。

右值要么是字面值量,要么是在表达式计算过程中创建的临时对象。右值的生命周期是短暂的,右值具有两个特点:

  • 所引用的对象即将被销毁;
  • 该对象没有其他用户。

这两个特点决定了,可以窃取右值对象的资源,这也为后面的移动构造函数和移动赋值操作符的实现奠定了数据基础。

右值引用中有个特例,即表达式是左值,如下面的代码:
在这里插入图片描述69行的错误明确提示了,ref5是一个左值。ref5是一个右值引用,但其是一个左值。回顾下左值和右值的定义,右值都是表示临时对象,ref5是一个变量,其本身是持久存在的,因此其是左值。

2 std::move函数

存在于头文件<utility>中的move函数,可以获取左值中绑定的右值引用。将上面的代码改成int&& ref6 = std::move(ref5);就是合法的了。

使用std::move明确的告诉编译器,我们对于一个左值想像右值一样进行使用。ref5作为一个移后源对象,可以对其进行赋值和销毁,但其被移动后的值是不可信的,不能直接使用其现在的值,具体的原因解释见下节

3 移动构造函数和移动赋值操作符

3.1 移动构造函数和移动赋值操作符基本含义

先来看下面的代码:

class Holder
{
public:
    Holder(int size)
    {
        m_data = new int[size];
        m_size = size;
    }

    ~Holder()
    {
        delete[]m_data;
        m_data = nullptr;
        m_size = 0;
    }

private:
    int* m_data;
    size_t m_size;
};

这个类中进行了自定义的动态内存管理,定义了析构函数,但未定义拷贝构造函数和拷贝赋值操作符,这违反了rule of threerule of three

  • 析构函数
  • 拷贝构造函数
  • 拷贝赋值操作符

这三个函数应该一起提供或者一起不提供。如果一个类需要用户自定义析构函数,那么就表示合成析构函数无法解决类内成员变量的析构,那么在类对象拷贝时,合成拷贝构造函数和合成赋值操作符也无法处理类内成员变量的拷贝,只能进行简单的类成员变量的浅拷贝,对这里而言,就是会让两个Holder对象的m_data指向了同一块内存,这样在一个对象析构后另一个对象的m_data就变成了野指针,因此也需要用户定义拷贝构造函数和赋值操作符以进行数据的深拷贝。

因此,按照rule of three,给上面的类定义拷贝构造函数和拷贝赋值操作符,代码如下:

class Holder
{
public:
    Holder(int size)
    {
        m_data = new int[size];
        m_size = size;
    }

    ~Holder()
    {
    	std::cout << "use destructor!" << std::endl;
        delete[]m_data;
        m_data = nullptr;
        m_size = 0;
    }

    Holder(const Holder& holder)
    {
        m_data = new int[holder.m_size];
        std::copy(holder.m_data, holder.m_data + holder.m_size, m_data);
        m_size = holder.m_size;
        std::cout << "use copy contructor!" << std::endl;
    }

    Holder& operator=(const Holder& rhs)
    {
        if (this != &rhs)
        {
            m_data = new int(rhs.m_size);
            std::copy(rhs.m_data,rhs.m_data+rhs.m_size,m_data);
            m_size = rhs.m_size;
        }

        std::cout << "use assignment operator!" << std::endl;
        return *this;
    }

private:
    int* m_data;
    size_t m_size;
};

上面的代码是一个完整的自定义类了,假如按照下面的用法使用这个类,如:

Holder createHolder(int size)
{
    return Holder(size);
}

int main()
{
    Holder holder = createHolder(1000);
    return 0;
}

执行这段代码,可以明确看到输出了:

use copy contructor!
use destructor!

也就是createHolder函数创建的临时变量通过调用拷贝构造函数拷贝到了main函数中的holder对象中,然后调用了析构函数进行临时变量的析构。但是Holder对象中拥有动态内存,在拷贝构造函数中明确给新对象进行了内存申请,并对临时变量的动态内存进行了数据拷贝,在析构函数中进行了临时对象的内存析构。

上面的代码执行过程中,我们明知道createHolder函数中创建的临时变量马上就要析构,同时又要新申请内存进行新对象的数据存储,执行这两个过程都比较耗时,有没有办法将临时变量的数据移动到新对象中呢?答案是,c++11之前的标准的确没办法,只能拷贝,但c++11引入了右值引用、移动构造函数和移动赋值操作符,也就有办法了。

移动构造函数是指将一个右值对象的数据移动到新对象中的构造函数,移动赋值操作符是指将一个右值对象赋给新对象时执行数据的移动而非拷贝

给上述Holder类添加移动构造函数和移动赋值操作符,实现如下:

class Holder
{
public:
    Holder(int size)
    {
        m_data = new int[size];
        m_size = size;
    }

    ~Holder()
    {
        std::cout << "use destructor!" << std::endl;
        delete[]m_data;
        m_data = nullptr;
        m_size = 0;
    }

    Holder(const Holder& holder)
    {
        m_data = new int[holder.m_size];
        std::copy(holder.m_data, holder.m_data + holder.m_size, m_data);
        m_size = holder.m_size;
        std::cout << "use copy contructor!" << std::endl;
    }

    Holder(Holder&& holder) noexcept
    {
        m_data = holder.m_data;
        m_size = holder.m_size;

        holder.m_data = nullptr;
        holder.m_size = 0;

        std::cout << "use move copy contructor!" << std::endl;
    }

    Holder& operator=(const Holder& rhs) noexcept
    {
        if (this != &rhs)
        {
            m_data = new int(rhs.m_size);
            std::copy(rhs.m_data,rhs.m_data+rhs.m_size,m_data);
            m_size = rhs.m_size;
        }

        std::cout << "use assignment operator!" << std::endl;
        return *this;
    }

    Holder& operator=(Holder&& rhs)
    {
        if (this != &rhs)
        {
            delete[]m_data;
            m_size = 0;

            m_data = rhs.m_data;
            m_size = rhs.m_size;

            rhs.m_data = nullptr;
            rhs.m_size = 0;
        }

        std::cout << "use move assignment operator!" << std::endl;
        return *this;
    }

private:
    int* m_data;
    size_t m_size;
};

执行如下代码:

Holder createHolder(int size)
{
    return Holder(size);
}

int main()
{
    Holder holder = createHolder(1000);
    return 0;
}

输出如下:

use move copy contructor!
use destructor!

可以看出,执行了移动构造函数和析构函数,移动构造函数中没有进行数据拷贝,析构函数中因为临时对象的m_data已经在移动构造函数中设置为了nullptr,所以析构函数并未进行太多的实际操作。

移动构造函数的实现要点:

  • 形参为Holder&&,右值引用,表示一个即将被清理的临时对象;
  • 函数内部直接将临时对象的数据指针和size值赋给当前对象;
  • 设置临时对象的数据指针为nullptr,size值为0,关键的是设置m_datanullptr,原因是新对象的m_data和临时对象的m_data是同样的内容,如果不将临时对象的m_data设为nullptr,那么在临时对象析构时,会释放其m_data所指向的内存空间,这样新对象的m_data就变成了野指针,不能继续使用了。

移动赋值操作符的实现要点和移动构造函数基本一致,唯一多的一点是:因为赋值操作符是用新的对象赋给原有对象,需要首先析构原有对象的内存,即首先执行了delete []m_data;

给出了移动构造函数和移动赋值操作符之后,也就将rule of three扩充到了rule of five,也就是:

  • 析构函数
  • 拷贝构造函数
  • 拷贝赋值操作符
  • 移动构造函数
  • 移动赋值操作符

这五个函数应该一起提供或不提供。

总结下移动构造函数和移动赋值运算符的实现关键点:

  • 对于移动构造函数,函数的第一个参数是该类的右值引用,其余参数都应有默认实参;

3.2 移动操作和异常

移动操作一般进行指针的赋值,并不进行空间的申请和数据的拷贝,因此移动操作一般不会发生异常。当一个类成员函数不会发生异常时,我们应该使用noexcept关键字明确告知编译器该函数不会发生异常。

C++11引入的noexcept就是明确告诉编译器一个函数不会发生异常,noexcept应该标明在函数的参数列表之后,如果是构造函数,noexcept关键字应该写在函数的参数列表和初始化列表的冒号之间。如果函数在类外定义,那么需要在函数声明和函数定义上都标明noexcept

很多标准库的容器对异常发生时其自身行为提供保障,例如vector保证在push_back过程中如果发生异常,可以保证vector自身不会发生改变。那么除非一个vector明确知道自己包含的元素类型的移动构造函数不会发生异常,否则其会使用拷贝构造函数替代移动构造函数以保证若push_back过程中发生异常可以维持vector的原有状态不变。因此,如果一个移动操作不会发生异常,我们应该明确标明noexcept以告知编译器可以放心使用该函数

3.3 移后原对象

被移动后的对象称为移后源对象,移后原对象可能被销毁,也可能被赋予新值继续使用,所以对于移后原对象,应该保证:

  • 其处于可析构的状态,例如上一节代码中在Holder的移动构造函数中设置rhs.m_data = nullptr;,就是为了保证对移后源对象进行析构不影响移动得到的对象;
  • 移后源对象仍然可使用,比如我们可以对其赋予新值;
  • 移后源对象中留下的值没有任何要求,因此我们不应该使用移后源对象的当前值。

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

3.4 合成移动构造函数和合成移动赋值操作符

如果我们没有显式定义拷贝构造函数和拷贝赋值运算符,那么编译器就会为我们合成这两个函数。但移动构造函数和移动赋值运算符的合成条件则相对更高,即:只有在一个类没用定义任何拷贝控制成员,且类的任何非static数据成员都是可移动时,编译器才会为我们合成移动构造函数和移动赋值运算符。相反:

  • 如果一个类定义了拷贝构造函数和拷贝赋值运算符,那么即便该类的所有非静态数据成员都可移动,编译器也不会合成移动构造函数和移动赋值运算符,在需要对象移动的时候,也会使用拷贝操作代替移动操作;
  • 如果一个类并非所有的非静态数据成员都可移动时,编译器也不会合成移动操作函数。

在下列情况下,类的移动构造函数或移动赋值运算符被定义为删除的:

  • 有类成员定义了拷贝构造函数但未定义移动构造函数,或是有类成员未定义自己的拷贝构造函数且编译器无法为其合成移动构造函数。移动赋值运算符的情况类似;
  • 如果有类成员的移动构造函数或移动赋值运算符定义为删除的或不可访问的,那么类的移动构造函数和移动赋值运算符定义为删除的;
  • 类似拷贝构造函数,如果类的析构函数定义为删除的或不可访问的,则类的移动构造函数也定义为删除的;
  • 类似于拷贝赋值运算符,如果类包含const或引用类型的成员,则类的移动赋值运算符定义为删除的。

特别要注意:如果一个类定义了移动构造函数和/或移动赋值运算符,则该类的合成拷贝构造函数和合成拷贝赋值运算符被定义为删除的。因此,如果我们定义了类的移动构造函数和移动赋值运算符,也要定义类的拷贝构造函数和拷贝赋值运算符。

3.5 拷贝、移动函数及匹配

如果一个类即定义了移动构造函数,又定义了拷贝构造函数,那么在实参是右值时,调用移动构造函数。在实参是左值时,调用拷贝构造函数。赋值运算符规则一致。如:

StrVec s1,s2;
s1 = s2; //s2是左值,调用拷贝赋值

StrVec getVec();
v2 = getVec();//函数返回的是右值,调用移动赋值

getVec()返回的是右值,其既可以适配const StrVec&,也可以适配StrVec&&,但是后者适配度更高,更加匹配,因此这里调用了移动赋值。

但如果一个类没有定义移动操作,那么即便实参类型为右值,也会调用拷贝版本。如:

class Foo
{
	public:
		Foo() = default;
		Foo(const Foo&) = default;//拷贝构造函数
		//
};

Foo x;
Foo y(x);//拷贝构造函数
Foo z(std::move(x));//拷贝构造函数

Foo类已经定义了拷贝构造函数,编译器不会再合成移动构造函数。那么在使用std::move(x)显式要求调用移动构造函数时,因为其不存在,也会调用拷贝构造函数。

使用拷贝构造函数代替移动构造函数肯定是安全的,只是效率更低而已,赋值运算符同理

在移动构造函数和移动赋值运算符这些类实现代码之外的地方,只有当你确认需要进行移动操作且移动操作是安全的,才可以使用std::move

参考:
理解C++中的左值和右值
C++右值引用和移动
《c++ primer》第五版

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值