前言

前几天学习了一个c++ 新用法 名曰 Copy& Swap ,对这里我就要讨论C++中的拷贝操作!

C++哇,可是值类型和指针类型以及引用类型都兼顾了的!!!这里一个简单的拷贝就有很多道道!!

当然这里讨论的是C++11。

=到底是 赋值还是构造

class student
{
    student(const char* name, int age):name(name),age(age)
    {}
    student(student const& other) = default;
    student& operator=(student const& other) = delete;
    }
		friend  std::ostream& operator <<(std::ostream& os, student& student);
private:
    std::string name;
    int age;
};
int main()
{
  student s1("xyqin",5);
  student s2=s1;             //这里到底能不能调用
  s2=s1;                     //这里到底能不能调用
  std::cout<<"s1:"<<s1<<std::endl;
  std::cout<<"s2:"<<s2<<std::endl;
}

观察上面的代码,我将C++默认会自动生成的赋值拷贝函数删除掉了,我的IDE告诉我如下信息(标记的是第19行代码):

image-20211005225627065

这里就有一个问题哇:

  student s2=s1;             //这里到底能不能调用
  s2=s1;                     //这里到底能不能调用

显然第二行是无法调用的,但是这里都是等号有什么区别吗?别说区别很大

  1. student s2=s1; 这里 s2还没有构造成功,还在娘胎里,这里不是(拷贝)赋值而是在调用(拷贝)构造函数
  2. s2=s1; 这里当然实在调用赋值的=,但是默认的被我们删除了,这里就提示错误了

为了验证这些,我补充一些代码, 将拷贝构造函数和拷贝赋值函数重写一遍

    student(student& other)
    {
        std::cout << "调用拷贝构造函数" << std::endl;
        this->name = other.name;
        this->age = other.age;
    }
    student& operator=(student const& other)
    {
        std::cout << "调用赋值拷贝" << std::endl;
        this->name = other.name;
        this->age = 999;  //加一点料,标记一下
        retrun *this;
    }

测试运行

image-20211005230736546

这里就完美的验证,上面描述的问题。但是对于 构造的探讨还没有结束!!!

explicit 阻止隐式的转换

我们在C++中经常会写一个参数的构造函数,但是这会导致一个看不见的问题:隐式的类型转换

继续补充student的代码, 一个构造函数,可以通过 yxqin;15 的形式构造出 student(name:yxqin,age:15),但这里我的目的是写了一个逼格的构造,但是但是他还有一层含义 :将 字符类型 转成 student,我是不希望这样的,并且这有一定的二义性。

student(char const* info)
    {
  			std::cout << "使用字符串构造" << std::endl;
        auto tmp = std::string(info);
        int index = tmp.find(';');
        
        if (index != std::string::npos)
        {
            this->name = tmp.substr(0, index);
            try
            {
                this->age = stoi(tmp.substr(index + 1, tmp.length() - index));
            }
            catch(...)
            {
                this->age = 0;
            }
        }
        else
        {
            this->name = "someone";
            this->age = 0;
        }
    }
int main()
{
  student s3 = "yxqin;15";
  return  0;
}

image-20211005232228696

这里我们使用 explicit去修饰这个构造函数,意思为 声明 构造器位显示调用! 无法自动帮我们隐士调用!这里加入之后 ,IDE帮我找出了这个问题,student s3 = "yxqin;15";

image-20211005232435222

这样只能显示调用了:

student s3 = student("yxqin;15");

传值VS传引用

值得注意的是 我声明的拷贝赋值=的重载

student& operator=(student const& other)

这里使用传入引用,那么如果是传入值类型的呢?

perator=(student other)

我们看一下下面操作中的调用过程:

    student s4 = student("name",15 );
    s4 = "yxqin;15";//没有 加  explicit

第2行中,先会调用 字符构造函数,构造出student("yxqin",15 )对象,operator=函数没有传入引用,是一种值类型传入,就会调用拷贝构造函数,将这个临时的对象拷贝一份,再传入operator=中 ,这可不得了的 ,有两个临时对象

当然这是书(《Effective C++》)说的,我认为就是这样的,但是实际编译测试中,没有这样(换一个编译器也是这个结果),难度书上错了,还是我举得例子不合理?也许是编译器的优化结果吧。当然C++ 传入值类型会调用一次拷贝构造函数,构造出该对象的一次临时备份,这是不可争议的事实,我随便写一个函数测试一下,安抚一下我那不可思议的心:

    student s1("yxqin", 5);
    test_student(s1);

image-20211006151644630

显然调用test_student(s1) ,C+ +就拷贝s1,再传入。嗯这还是C+ +,ahh。

不说闲话,一般定义中operator=是务必传入引用的,这很合乎常理,正确的定义如下:

    student& operator=(student const& other)  //const 表示不能修改
    {
      //代码
      retrun *this;//返回 &*this
    }

在这样标准的定义下,咱们只会出现一次字符串构造,一次拷贝赋值。所以传入参数,不修改参数的时候,传入 const & 静态的引用是很有必要的!可以提高不少的性能。

深拷贝这么麻烦,我也不能说C+ +垃圾

一个类具有指针类型数据成员 ,那么务必要写深拷贝! ,为了验证这一句话,对此我写了一个测试类,buffer,缓冲byte的正整数的buffer。

class buffer
{
public:
    //explicit 声明 构造器位显示调用! 阻止隐式转换, 万一不小心把int 类型转换成了 buf类型
    explicit buffer(int capacity) :capacity(capacity), len(0), buf(new unsigned char[capacity] {0})
    {}
    ~buffer()
    {
        delete[] buf;
    }
    bool push(unsigned char value)
    {
        if (len == capacity) return false;
        buf[len++] = value;
        return  true;
    }

    buffer(buffer const& other) = default;
    buffer& operator=(buffer const& other) = default;
    friend  std::ostream& operator <<(std::ostream& os, buffer& buffer);

private:
    int capacity;
    int len;
    unsigned char* buf;
};

std::ostream& operator <<(std::ostream& os, buffer& buffer)
{
    os << "buffer(" << buffer.len << ',' << buffer.capacity << ")[";
  if(buffer.buf)
    for (int i = 0; i < buffer.capacity; i++)
    {
        os << (int)buffer.buf[i] << ',';
    }
    os << ']';
    return os;
}

如果运行下面代码

    buffer b1(15);
    buffer b2 = b1;

显然会报错!

image-20211006000428681

错在哪里的呢?原因很简单, b1.buf和b2.buf两个指针指向了同一块内存,由于存在两次析构,那么一样的buf被delete两次。

验证一下:

    buffer b1(15);
    buffer b2 = b1;
    b1.push(1);
    std::cout<<"b1:"<<b1<< std::endl;
    std::cout << "b2:" << b2 << std::endl;

image-20211006000951209

这里只拷贝指针,但是不拷贝,指针所指内存中数据,就叫浅拷贝,显然c++默认的他两就是浅的。

    buffer(buffer const& other) = default;
    buffer& operator=(buffer const& other) = default;

完善一下:

    buffer(buffer const& other)
    {
        this->capacity = other.capacity;
        this->len = other.len;
        this->buf = new unsigned char[capacity] {};
        std::copy(other.buf, other.buf + other.capacity, this->buf);
    }
    buffer& operator=(buffer const& other)
    {
        if (this == &other)return *this;
        this->capacity = other.capacity;
        this->len = other.len;
        delete[] this->buf;// 注意删除原来的数据
        this->buf = new unsigned char[capacity] {};
        std::copy(other.buf, other.buf + other.capacity, this->buf);
        return *this;
    }

image-20211006002332086

移动构造器

这个概念我是第一次听说,但是又不是第一次遇到,先前我就遇到了这个麻烦----右值引用

这里简单复习一下 如何判断左值和右值:右值一般没有名字 ,再当前作用域中临时存在(将亡值在·),无法获取他的地址。

为了能够直观的反应出一些问题,对buffer做出一下修改:

    explicit buffer(int capacity) :capacity(capacity), len(0), buf(new unsigned char[capacity] {0})
    {
        std::cout << "调用构造函数" <<*this <<std::endl;
    }
    ~buffer()
    {
        std::cout << "调用析构函数" << *this << std::endl;
        delete[] buf;
    }
    buffer(buffer const& other)
    {
        std::cout << "调用拷贝构造函数" << other << std::endl;
        this->capacity = other.capacity;
        this->len = other.len;
        this->buf = new unsigned char[capacity] {};
        std::copy(other.buf, other.buf + other.capacity, this->buf);
    }
    buffer& operator=(buffer const& other)
    {
        std::cout << "调用拷贝赋值函数" << other << std::endl;
        if (this == &other)return *this;
        this->capacity = other.capacity;
        this->len = other.len;
        delete[] this->buf;// 注意删除原来的数据
        this->buf = new unsigned char[capacity] {};
        std::copy(other.buf, other.buf + other.capacity, this->buf);
        return *this;
    }
    friend  std::ostream& operator <<(std::ostream& os,buffer const& buffer); //传入静态引用

加入了一些输出,当然前面写bufferoperator << 的时候犯了一个错误, 没有传入静态引用,导致其他传入静态引用的函数无法调用<<, 编译器报错类型不匹配,所以一定养成传入静态引用的好习惯。

image-20211006154636402

开始测试:

    buffer b1(15);
    buffer b2(0);
    b2 = buffer(11);

看看输出:

image-20211006215845195

这个程序,看起来运行没有问题,但是性能会差很多,分析一下差在哪里?

b2 = buffer(11); 我们仅仅是想把buffer(11)这个临时对象覆盖掉原有的b2 ,这样 buffer11的对象应该只有一个,看看运行结果c++可不是这么想的,大致流程如下:

  1. 构造临时对象buffer(11)
  2. 调用拷贝构造函数传给 operator=
  3. 然后调用operator= 赋值给 b2.
  4. 最后临时构造的对象活不过这条代码就又要被析构了
  5. 最后程序结束,析构b2

可以发现,这里的2和4 是多余的,也就是这里的拷贝构造是一个多余的步骤,性能就差在这里!!

你以为C+ +会让这种情况存在的吗?你别忘了,这可是C+ +哇

这里临时对象就是右值,我们只需要给operator= 重载一个处理右值的,这样叫移动构造器,这样可以极大的优化程序的运行效率!

    buffer& operator=(buffer && other) noexcept
    {
        std::cout << "调用移动赋值函数" << other << std::endl;
        if (this == &other)return *this;
        //直接将 other 变成我的
        this->capacity = other.capacity;
        this->len = other.len;
        this->buf = other.buf;

        //other 会被析构掉 别忘了 该 other.buf的指向
        other.buf = nullptr;
        return *this;
    }
    buffer(buffer && other)noexcept
    {
        std::cout << "调用移动构造器" << other << std::endl;
        //直接将 other 变成我的
        this->capacity = other.capacity;
        this->len = other.len;
        this->buf = other.buf;

        //other 会被析构掉 别忘了 该 other.buf的指向
        other.buf = nullptr;
    }

移动构造器,传入本来就是一个即将被销毁的,所以我们只需要把他所有的值拷贝过来即可,直接将 other 变成我的,但是注意的是other即将要被销毁了,别忘了other.buf = nullptr;,这样就不会让数据也被销毁了。

简单说就是把别人的变成我的,霸占别人的东西,我觉得干脆别叫移动构造器了,叫霸占构造器如何?

现在看看优化过后的代码:

image-20211006222852114

没有多余的拷贝构造啦!

非常有必要

你可能说,不就多调用拷贝构造没事,但是更具我最近写代码的实践,发现了一个致命的问题,观察下面的现象

    auto buffers = std::vector<buffer>();
    buffers.push_back(buffer(10));
    buffers.push_back(buffer(9));
    buffers.push_back(buffer(8));

image-20211006223436396

发现这里就需要调用的就是右值的版本,运行一下:

image-20211006223939849

会发现这里调用多次 移动构造器,为啥呢,简单vector要扩容哇,中间就少不了移动。如过没有移动构造器,我的天,那么每一次都会来一个拷贝构造,多出来一个临时对象,还没完,又得释放一次,这实在划不来。

我在写AI五子棋代码的时候调试的时候,就发现我的拷贝构造函数在不断重复调用,当时我就十分迷惑,今天算是找到了原因。

如果没有移动构造器,emm调用的都是拷贝构造器,效率低了不少,但是好像并没有非常多次调用析构,斯,应该是编译器的优化吧ahh. 但是肯定的是 移动构造器只有赋值的动作,一定会比调用std::copy()的拷贝构造函数好很多

image-20211006224402359

问题还没有结束,我们发现这里有重复代码,有重复的代码就意味着要出bug,能否优化呢?

经典的swap & copy 模式

参考 :https://blog.csdn.net/xiajun07061225/article/details/7926722

    static void Swap(buffer& lhs, buffer& rhs)noexcept
    {
        std::swap(lhs.buf, rhs.buf);
        std::swap(lhs.capacity, rhs.capacity);
        std::swap(lhs.len, rhs.len);
    }
    buffer(buffer& buffer)
        :capacity(buffer.capacity), len(buffer.len),
        buf(capacity ? new unsigned char[capacity] {0} : nullptr)
    {
        if(capacity)
            std::copy(buffer.buf, buffer.buf + buffer.capacity, this->buf);
        std::cout << "调用拷贝构造函数" << *this << std::endl;
    }
    buffer& operator=(buffer other)noexcept
    {
        std::cout << "调用拷贝赋值函数" << other << std::endl;
        Swap(*this, other);
        return *this;
    }
    buffer(buffer&& buffer):noexcept :capacity(0), len(0), buf(nullptr)
    {
        Swap(*this, buffer);
        std::cout << "调用移动构造器" << *this << std::endl;
    }

我们使用这端段20几行就完成了前面的

  1. 拷贝构造函数
  2. 移动构造函数
  3. 拷贝赋值函数
  4. 移动拷贝赋值函数

这里而且没有重复的代码,很舒服。现在分析一下

先声明一个Swap函数,很简答就是为了交换俩个buffer。

赋值构造

buffer(buffer& buffer)

这个函数就老老实实的把开辟空间和拷贝数据任务就好了

移动构造函数

buffer(buffer&& buffer)noexcept

传入的时将亡值,我们直接霸占就ok了,当然需要给this.buf=nullptr,要不然析构将亡值的时候,别析构了一个意想不到的的位置。

operator=

    buffer& operator=(buffer other)noexcept
    {
        std::cout << "调用拷贝赋值函数" << other << std::endl;
        Swap(*this, other);
        return *this;
    }

这里并没有直接传入引用或者右值引用,直接传入值类,这里同时把buffer(buffer && other)noexceptbuffer& operator=(buffer const& other) 所干的活都干了。

拷贝赋值

buffer& operator=(buffer const& other) 需要开辟一块内存,这里意味有可能开辟失败,如果开辟失败了还失败的结果交换给other吗?显然这不是的,这里最巧妙的就是传入值类型,传入值类,c++会拷贝一份other,开辟内存的活给了拷贝构造函数,我们交换了other的备份,那不是赋值了other吗,并且还不用判断了。

移动拷贝赋值

对于buffer& operator=(buffer && other) noexcept,你可能回想到,该函数原本只需要和将亡值直接交换即可,但是现在传入的是值类,需要拷贝构造一下哇,拷贝构造就要多一个std::copy过程,是不是逆向优化了呢,不不不,仔细思考一下,这里传入值类型的时候调用的是移动构造函数,也只是多了一次交换过程,所以称不上逆向优化。

该套路好处蛮多,减少了冗余代码避免潜藏的bug,并且让该函数成为不会抛出异常的函数,旧空间释放交给了析构函数,不得不称赞这个套路的完美! 同时编译器也会帮我们优化这一个过程,如果我们将移动赋值函数单独重写一下,我们就失去了一次编译器优化机会。

对于该函数,我们最好遵循比较有用的规则是:不要传递左引用和右引用。你应该按值传递参数,让编译器来完成拷贝工作。

验证一下

为了验证这一个效果,我重载了operator+功能,这个功能很简单

    buffer operator+(buffer const& other)
    {
        buffer tbuf(0);
        tbuf.len = this->len + other.len;
        tbuf.capacity = this->capacity + other.capacity;
        tbuf.buf = new unsigned char[tbuf.capacity]{ 0 };
        if (tbuf.buf)
        {
            std::copy(this->buf, this->buf + this->len, tbuf.buf);
            std::copy(other.buf, other.buf + other.len, tbuf.buf + this->len);
        }
        else
            tbuf.buf = nullptr;
        return tbuf;
    }

观察下面代码动作:

    buffer b(0);
    b= buffer(16) + buffer(15);

简单预测一下,buffer(16) + buffer(15)非常典型的右值,那么这里完成了+之后应该先调用移动拷贝函数,然后再去调用赋值函数

image-20211007153816511

和预期的是一样的,这回中途那么多析构,就不再分析了,可以自行探究一下。

运行结果

    auto buffers = std::vector<buffer>();
    buffers.push_back(buffer(10));
    buffers.push_back(buffer(9));
    buffers.push_back(buffer(8));

还是以前的例子,我们现在看一下运行结果:

image-20211006235923249

我们发现中间的析构都是 buffer(0,0) ,这里因为我们移动构造函数,将自己空的值给了将亡值,那么析构的就是空的 buffer(0,0) 了哇。

结语

C++ 天下第一

努力成长的程序员