前言
前几天学习了一个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行代码):
这里就有一个问题哇:
student s2=s1; //这里到底能不能调用
s2=s1; //这里到底能不能调用
显然第二行是无法调用的,但是这里都是等号有什么区别吗?别说区别很大
student s2=s1;
这里 s2还没有构造成功,还在娘胎里,这里不是(拷贝)赋值而是在调用(拷贝)构造函数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;
}
测试运行
这里就完美的验证,上面描述的问题。但是对于 构造的探讨还没有结束!!!
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;
}
这里我们使用 explicit
去修饰这个构造函数,意思为 声明 构造器位显示调用! 无法自动帮我们隐士调用!这里加入之后 ,IDE帮我找出了这个问题,student s3 = "yxqin;15";
这样只能显示调用了:
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);
显然调用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;
显然会报错!
错在哪里的呢?原因很简单, 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;
这里只拷贝指针,但是不拷贝,指针所指内存中数据,就叫浅拷贝,显然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;
}
移动构造器
这个概念我是第一次听说,但是又不是第一次遇到,先前我就遇到了这个麻烦----右值引用。
这里简单复习一下 如何判断左值和右值:右值一般没有名字 ,再当前作用域中临时存在(将亡值在·),无法获取他的地址。
为了能够直观的反应出一些问题,对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 <<
的时候犯了一个错误, 没有传入静态引用,导致其他传入静态引用的函数无法调用<<, 编译器报错类型不匹配,所以一定养成传入静态引用的好习惯。
开始测试:
buffer b1(15);
buffer b2(0);
b2 = buffer(11);
看看输出:
这个程序,看起来运行没有问题,但是性能会差很多,分析一下差在哪里?
b2 = buffer(11);
我们仅仅是想把buffer(11)这个临时对象覆盖掉原有的b2 ,这样 buffer11的对象应该只有一个,看看运行结果c++可不是这么想的,大致流程如下:
- 构造临时对象
buffer(11)
- 调用拷贝构造函数传给
operator=
- 然后调用
operator=
赋值给 b2. - 最后临时构造的对象活不过这条代码就又要被析构了
- 最后程序结束,析构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;
,这样就不会让数据也被销毁了。
简单说就是把别人的变成我的,霸占别人的东西,我觉得干脆别叫移动构造器了,叫霸占构造器如何?
现在看看优化过后的代码:
没有多余的拷贝构造啦!
非常有必要
你可能说,不就多调用拷贝构造没事,但是更具我最近写代码的实践,发现了一个致命的问题,观察下面的现象
auto buffers = std::vector<buffer>();
buffers.push_back(buffer(10));
buffers.push_back(buffer(9));
buffers.push_back(buffer(8));
发现这里就需要调用的就是右值的版本,运行一下:
会发现这里调用多次 移动构造器,为啥呢,简单vector要扩容哇,中间就少不了移动。如过没有移动构造器,我的天,那么每一次都会来一个拷贝构造,多出来一个临时对象,还没完,又得释放一次,这实在划不来。
我在写AI五子棋代码的时候调试的时候,就发现我的拷贝构造函数在不断重复调用,当时我就十分迷惑,今天算是找到了原因。
如果没有移动构造器,emm调用的都是拷贝构造器,效率低了不少,但是好像并没有非常多次调用析构,斯,应该是编译器的优化吧ahh. 但是肯定的是 移动构造器只有赋值的动作,一定会比调用std::copy()的拷贝构造函数好很多。
问题还没有结束,我们发现这里有重复代码,有重复的代码就意味着要出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几行就完成了前面的
- 拷贝构造函数
- 移动构造函数
- 拷贝赋值函数
- 移动拷贝赋值函数
这里而且没有重复的代码,很舒服。现在分析一下
先声明一个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)noexcept
和 buffer& 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)
非常典型的右值,那么这里完成了+
之后应该先调用移动拷贝函数,然后再去调用赋值函数
和预期的是一样的,这回中途那么多析构,就不再分析了,可以自行探究一下。
运行结果
auto buffers = std::vector<buffer>();
buffers.push_back(buffer(10));
buffers.push_back(buffer(9));
buffers.push_back(buffer(8));
还是以前的例子,我们现在看一下运行结果:
我们发现中间的析构都是 buffer(0,0) ,这里因为我们移动构造函数,将自己空的值给了将亡值,那么析构的就是空的 buffer(0,0) 了哇。
结语
C++ 天下第一