从零开始的移动构造函数,拷贝构造函数详解(C++)

本文主要作为自己零散笔记进行记录,仍需要一定的C++知识,至少菜鸟相关的知识得看完。本文会尽量让刚入门的小白都能读懂,以便自己再来回顾的时候也能够读懂。如果有可以补充而外知识恳请评论或私信告诉,我会第一时间查缺补漏。

1:左值与右值

1.1:什么是左右值

参考网站博客

在C++常用的赋值过程中,等号左右两边可以认为左值和右值

char s[]="csdn";   or    int a=13;

左值(loactor value),可以看作是存储在内存中的,有明确存储地址(可寻址)的数据,一般在函数内部声明的所有变量都将占用栈内存;
右值(read value),指的是可以提供数据值的数据(不一定可以寻址,比如常量是存储于寄存器中的数据)。

左值以变量的形式存在,指向内存,生命周期比较长,我们可以对左值进行各种操作;而右值通常以常量的形式存在,是一个临时值,不能被程序的其它部分访问,生命周期很短。

【注意】 C++中的左值也可以当作右值使用。通常来说,语言构造一个对象的值要求右值作为它的参数。例如,二元加运算符 ‘+’ 要求两个右值作为它的参数并且返回一个右值:

int a,b=13; // a,b 为左值
int c=a+b; //此时a+b为右值赋值给c,c为左值,a+b先转换为右值赋值给c
#注意,此时b的地址和a的地址是不同的,改变b不会改变a的数值

从先前分析可以看到,ab都是左值。因此,在代码第三行,它们经历了一次从左值到右值的转换。所以的左值不能是数组,函数或不完全类型都可以转换成右值。

1.2左值引用

引用是什么如果不知道的话可以上菜鸟了解一下。

在第一次对变量定义的过程中会先给变量安排内存空间,安排好空间便可以对其进行引用。如下所示,“&”表示的引用又称为左值引用,可以理解为只能对左值使用。

【注意】 左值引用就是通过&符号标识的变量,左值引用声明时必须指向一个已经存在的地址,如下第4行就是非法的表达式,123为寄存器上数据没有内存,这与引用声明冲突。

【特别的】虽然 C++98/03 标准不支持为右值建立非常量左值引用,但允许使用常量左值引用操作右值。也就是说,常量左值引用既可以操作左值,也可以操作右值。

int  a=10; //为a申请内存,内存数据为10
int &b=a; //b为一个引用,指向a的存储内存。要求a也为左值才能进行引用
b=12;    //b为左值,12为右值,成立
int &er =123; //非法
const int &er =123; //正确
int* bad_addr = &(var + 1); //错误:‘&’运算符要求一个左值
int* addr = &var;           //正确:var是左值
&var = 40;                  //错误:赋值运算符的左操作数要求一个左值

1.3右值引用

右值往往是没有名称的,因此要使用它只能借助引用的方式。这就产生一个问题,实际开发中我们可能需要对右值进行修改(实现移动语义时就需要),显然左值引用的方式是行不通的。

C++11中引入了右值引用,表示一个没有名称的临时对象即右值,可以修改这个临时对象的值;右值引用使用2个&符号(&&)。

与左值引用不同的是,以下代码不会报错

int && a = 10;
a = 11;
cout << a << endl;   //输出结果为11

【注意】右值引用不支持引用左值;非常量右值引用可以引用的值的类型只有非常量右值,常量右值引用非常量右值、常量右值

const int num2 = 100;
int&& a = num;	//编译失败,非常量右值引用不支持引用非常量左值
int&& b = num2;	//编译失败,非常量右值引用不支持引用常量左值
int&& c =10;	//编译成功,非常量右值引用支持引用非常量右值
const int&& d = num;	//编译失败,常量右值引用不支持引用非常量左值
const int&& e = num2;	//编译失败,常量右值引用不支持引用常量左值
const int&& f = 100;	//编译成功,常量右值引用支持引用右值

在实际当中,类似于上述代码的右值引用和普通的左值区别不大,右值引用主要用于移动语义和完美转发,其中前者需要有修改右值的权限,常量右值引用的作用就是引用一个不可修改的右值,这项工作完全可以交给常量左值引用完成。但这类用法在类中有着极大用处。

接下来我们对右值做一个总结:

具体的来说,以下几种情况会产生右值:

  • 常量表达式的结果,例如字面量、常量变量等等;
  • 函数调用的结果;
  • 解引用 nullptr 或者空指针;
  • 特定类型的临时对象;
  • 算术运算的结果。

右值分为两类:纯右值和左值转过来的右值(也称为“引自左值”的右值)。纯右值就是上面提到的第一种到第四种情况,而第五种情况是左值通过某种方式转化为右值的,比如通过解引用 NULL 指针或者使用 const_cast 将非 const 对象转化为 const 对象,static_cast等等。 右值引用允许我们绑定到右值,从而可以访问右值的资源并将其移动给其他对象,从而提高了程序的效率。

2.移动拷贝函数

在解释为什么要使用右值引用时,先要了解一下移动拷贝函数。

拷贝构造函数的实现原理是为新对象复制一份和其它对象一模一样的数据。而当类中拥有指针类型成员变量时,拷贝构造函数中需要以深拷贝的方式复制该指针成员。

#include 
using namespace std;
class demo{
public:
    //构造函数
    demo():num(new int(0)){
        cout<<"construct!"<

该函数在拷贝 d.num 指针成员时,必须采用深拷贝的方式,即拷贝该指针成员本身的同时,还要拷贝指针指向的内存资源。当多个对象中的指针成员指向同一块堆空间,这些对象析构时就会对该空间释放多次,导致不必要的错误。

整体流程如下所示: 

  1. 执行 get_demo() 函数内部的 demo() 语句,即调用 demo 类的默认构造函数。
  2. 执行 return demo() 语句,会调用拷贝构造函数复制一份之前生成的对象,并将其作为 get_demo() 函数的返回值,此对象为匿名对象,没有地址为纯右值。(函数体执行完毕之前,匿名对象会被析构销毁);
  3. 执行 a = get_demo() 语句,再调用一次拷贝构造函数,将之前拷贝得到的临时对象复制给 a(此行代码执行完毕,get_demo() 函数返回的匿名对象会被析构);
  4. 程序执行结束前,会自行调用 demo 类的析构函数销毁 a。

【注意】目前多数编译器都会对程序中发生的拷贝操作进行优化,因此看到的往往是优化后的输出结果: 

construct!
class destruct!

而同样的程序,在 Linux 上使用g++ demo.cpp可以看到完整的输出结果:

construct!                <-- 执行 demo()
copy construct!       <-- 执行 return demo(),此处可以看做没有调用默认构造函数,而是调用了拷贝构造函数新建一个demo类的匿名对象,该对象没有函数拥有地址,是pure RValue,不能作为引用使用。
class destruct!         <-- 销毁get_demo()函数中 demo() 产生的局部变量。
copy construct!       <-- 执行 a = get_demo(),此时传递的是匿名对象右值。
class destruct!         <-- 销毁 get_demo() 返回的临时对象,离开构造匿名对象的哪行代码后立即调用析构函数。
class destruct!         <-- 销毁 a

上述代码使用拷贝构造函数实现对 a 对象的初始化,底层实际上进行了 2 次深拷贝操作。如果临时对象中的指针成员申请了大量的堆空间会影响 a 对象初始化的执行效率。

为了避免当类中包含指针类型的成员变量,使用其它对象来初始化同类对象时深拷贝导致的效率问题。可以采用右值引用的语法,借助它可以实现移动语义提高效率。

3.C++移动构造函数(移动语义的具体实现)

3.1移动构造简例

所谓移动语义,是以移动而非深拷贝的方式初始化含有指针成员的类对象。简单的理解,移动语义指的就是将其他对象(通常是临时对象)拥有的内存资源“移为己用”

以前demo 类为例,该类的成员都包含一个整形的指针成员,其默认指向的是容纳一个整形变量的堆空间。当使用 get_demo() 函数返回的临时对象初始化 a 时,我们只需要将临时对象的 num 指针直接浅拷贝给 a.num,然后修改该临时对象中 num 指针的指向(通常另其指向 NULL),这样就完成了 a.num 的初始化。

以上存在的问题是,对于程序执行过程中产生的临时对象,往往只用于传递数据(没有其它的用处),并且会很快会被销毁。因此在使用临时对象初始化新对象时,我们可以将其包含的指针成员指向的内存资源直接移给新对象所有,无需再新拷贝一份,这大大提高了初始化的执行效率。

因此,通过使用右值引用直接传递地址可以避免无用的初始化。先来个简单的例子:

#include
using namespace std;
 
int main()
{
	string str1="OK";
	string str2=str1;
	cout<<"str1:"<

#输出结果

str1:OK

str2:OK

下面使用移动构造函数:

#include
using namespace std;
 
int main()
{
	string str1="OK";
	string str2=move(str1);//move 将str1左值强制转化为右值(即将内存中的值转移到寄存器中)
                            //再讲move寄存器中的数据传输给str2内存中
	cout<<"str1:"<

#输出结果

str1:

str2:OK

3.2类的移动构造函数解析

那么在类中,移动构造函数发挥的作用更大,以如下代码为例

#include 
using namespace std;
class Intvec
{
public:
    //构造函数
    explicit Intvec(size_t num = 0)
        : m_size(num), m_data(new int[m_size])
    {
        log("constructor");
    }
    //析构函数
    ~Intvec()
    {
        log("destructor");
        if (m_data) {
            delete[] m_data;
            m_data = 0;
        }
    }
    //拷贝函数
    Intvec(const Intvec& other)
        : m_size(other.m_size), m_data(new int[m_size])
    {
        log("copy constructor");
        for (size_t i = 0; i < m_size; ++i)
            m_data[i] = other.m_data[i];
    }
    //重载运算符号
    Intvec& operator=(const Intvec& other)
    {
        log("copy assignment operator");
        Intvec tmp(other);
        std::swap(m_size, tmp.m_size);
        std::swap(m_data, tmp.m_data);
        return *this;
    }
private:
    void log(const char* msg)
    {
        cout << "[" << this << "] " << msg << "\n";
    }

    size_t m_size;
    int* m_data;
};
int main()
{
Intvec v1(20);
Intvec v2,v3;

cout << "assigning lvalue...\n";
v2 = v1;
cout << "ended assigning lvalue...\n";

cout << "assigning lvalue...\n";
v3 = Intvec(10);
cout << "ended assigning lvalue...\n";
}

输出结果:

[0x7fffad9c3410] constructor        //Intvec v1构造函数
[0x7fffad9c3400] constructor        //Intvec v2构造函数
[0x7fffad9c3400] constructor        //Intvec v3构造函数

assigning lvalue...
[0x7fffad9c3400] copy assignment operator        //运用重载构造函数
[0x7fffad9c33d0] copy constructor                //调用拷贝函数,赋值temp
[0x7fffad9c33d0] destructor                  //析构temp
ended assigning lvalue...
assigning lvalue...
[0x7fffe5afb1e0] constructor        //运行默认构造函数,构造Intvec(10)匿名
[0x7fffe5afb1c0] copy assignment operator         //Intvec(10)的匿名函数调用重载构造函数
[0x7fffe5afb190] copy constructor        //调用拷贝构造函数,赋值temp
[0x7fffe5afb190] destructor        //析构temp
[0x7fffe5afb1e0] destructor        //析构Intvec匿名
ended assigning lvalue...

[0x7fffad9c3400] destructor

[0x7fffad9c3410] destructor

[0x7fffad9c3410] destructor

上述代码中有一对额外的构造/析构调用。不幸的是,这是个额外工作,没有任何用,因为在拷贝赋值运算符的内部,另一个临时拷贝的对象在被创建和析构。

C++11给我们右值引用可以实现“移动语义”,特别是一个“移动赋值运算符”。来添加另一个 operator= 到 Intvec

Intvec& operator=(Intvec&& other)
{
    log("move assignment operator");
    std::swap(m_size, other.m_size);
    std::swap(m_data, other.m_data);
    return *this;
}

assigning rvalue...
[0x28ff08] constructor
[0x28fef8] move assignment operator
[0x28ff08] destructor
ended assigning rvalue...

&& 语法是新的右值引用。如它名字一样给我们一个右值的引用,在调用之后将被析构。我们可以使用我们只是“偷”这个内部的右值这个事实-我们根本不需要它们。

临时对象Intvec(10)创建的构造和析构调用任然需要,但是另一个在赋值运算符内部的临时对象不再需要。移动运算符只是简单的切换右值的内部缓冲区为己用,分配它所以右值析构器将会释放我们对象自己不再使用的缓冲区。很紧凑。

3.3移动构造函数中一些注意事项(施工)

你可能感兴趣的:(c++,开发语言)