拷贝作为内存管理的最常的行为操作,c++中很多隐藏的内存问题和bug都是由于不正确的拷贝行为引发的,这里我们以最基本的拷贝构造函数,拷贝赋值运算符和析构函数作为切入点来谈一谈如何正确的进行c++的拷贝控制。
拷贝构造函数:如果一个构造函数的第一个参数时自身的引用(通常是const引用),且任何额外的参数都有默认值,则称作拷贝构造函数,如果我们没有定义拷贝构造函数编译器会在需要的时候为我们生成一个,合成的拷贝构造函数会将其参数的成员(非静态)逐个拷贝到正在创建的对象中,成员的类型决定可它如何拷贝,类类型的成员会使用期拷贝构造函数,内置类型直接拷贝,如果是数组会逐个拷贝数组的元素,如果是指针只会拷贝指针自己不会拷贝指针指向的内容,这一点我们一定要注意。
什么时候会调用拷贝构造函而不是构造函数呢?
1 用等号来创建一个新的对象, string str1 = "13213123",string str1 = str2。
2 将对象作为实参传递给一个非引用类型的形参。
3 从一个返回类型为非引用的函数中返回一个对象。
4 用列表初始化数组中的元素或者一个聚合类中的成员,struct a = {......}。
5 当我们初始化标准库容器或是调用insert或者push时容器会对其元素进行拷贝初始化,当然如果我们用emplace(c++11)成员创建元素将会直接初始化(如我我们插入时要插入的元素不存在尽量用emplace代替push等操作效率会更高).
不过有时候编译器会进行优化很聪明直接跳过拷贝/移动构造函数(后面会说),直接初始化对象,比如,string str1 = "13213123",编译器可能会优化为string str1( "13213123"),但是即使编译器做了这样的优化,此时的拷贝构造函数也必须是可用的。
拷贝赋值运算符:重载运算符的本质是函数,其名字是有operator关键字后表示要定义的运算符的符号组成,因此赋值运算符就是名为operator=的函数,类似于其他函数它也有一个返回类型和一个参数列表。某些运算符包括赋值运算符必须定义为成员函数,如果一个运算符是一个成员函数,其左侧运算对象就绑定到this指针,对于一个二元运算符其右侧对象显式函数传递。如果我们没有定义赋值运算符编译器同样会为我们生成一个,有些时候我们会自己重载赋值运算符,此时一定要注意两点:
1 如果将一个对象赋予它自身,赋值运算符必须能正确工作:错误的写法
HasPtr& HasPtr::operator=(const HasPtr& rhs)
{
deldte ps; //ps是HasPtr的一个T类型成员指针
//如果rhs和*this是一个对象,此时ps指向的内存已经被释放了
ps = new T(*rhs.ps)
return *this;
}
正确的写法我们不仅要解决自赋值问题,还要考虑如何解决赋值过程中抛异常的问题,具体的写法这里不在叙述了。
最后一个是析构函数:我们知道成员的初始化是在构造函数体执行之前完成的,构造函数中的操作实际上是赋值操作,而不是真正意义上的初始化。在一个析构函数中首先执行函数体然后销毁成员,成员按初始化顺序逆序销毁,在对象最后一次使用之后析构函数的函数体可按照我们的意愿进行收尾工作,在释放成员变量时同样根据其类型而定,类类型分别调用其构造函数,这里同样要注意指针类型,销毁指针如果我们不显示的调用delete其指向的对象并不会释放。
另外说到默认的析构函数我们一定要意识到析构函数并不直接销毁成员函数,成员是在析构函数体之后隐含的析构阶段中被销毁的,在整个对象销毁过程中,析构函数是作为成员销毁步骤之外的另一部分进行。
在新标准c++11标准中我们可以有五个函数来控制拷贝,拷贝构造,拷贝赋值,析构,移动构造和移动赋值,c++并不强制要求我们显示的定义这些函数但是我们要时刻提醒自己,编译器生成的是不可靠的,我们往往忘记了这一点。我们什么时候要自己实现这些函数呢?
1 要析构的函数也一定需要拷贝构造和赋值运算符。当我们要决定是否要自己定义拷贝控制成员时,首先要看的就是我们是否需要一个析构函数如果需要那么我们也需要拷贝构造和赋值运算符,当然如果你自己定义的和默认的没有什么区别那我们就不需要,或者说你的类是以个单例并且你不允许且不会发生拷贝和赋值那同样我们也不需要拷贝构造和赋值运算符。
2 需要拷贝构造函数的同样也需要赋值操作。如果你为一个类定义了自己的拷贝构造函数,那么你一定要也定义一个同样逻辑的赋值操作符,反过来也是一样的。
3 如果你的成员中有指针或者引用类型的成员,那么九成你是需要定义自己的析构函数,拷贝构造和赋值操作符的,还有一成是什么情况呢,一是你预期的这些函数功能和编译器生成的一致,是有就是我们生面所说的你的类是一个单例,并且你不允许且不会发生拷贝和赋值。
还有一点要注意,如果我们定义这些函数并不是一定都是可用的,有可能是删除的(delete关键字c++ 11)
1 如果类的某个成员的析构函数不可用,则类的合成析构函数是删除的,拷贝构造函数也是删除的。
2 类的某个成员的拷贝构造函数是删除的或者不可访问则类的拷贝构造函数是删除的。
3 如果类的某个成员你的赋值运算符不可访问的,或者有一个const的或者引用成员,则类的拷贝赋值运算符为删除的
4 如果类的某个成员的析构函数删除的或者不可访问,或者类有一个引用成员没有默认构造函数,并且没有显示的定义默认构造函数则类的默认构造函数是删除的。
具有引用和const成员的类编译器无法默认构造默认构造函数,因为构造函数体内的操作是赋值,const成员的值是不能改变的,引用类型声明时必须被初始化,所以这两种类型的变量必须在初始化列表中初始化。同样的当这两种变量存在是编译器同样不会生成拷贝构造赋值操作符。虽然我们可以将一个新值赋予一个引用成员但是这样的改变的是引用绑定的对象的值而不是引用本身,如果为这样的类生成拷贝赋值操作符则赋值后左侧运算符对象仍然指向与赋值前一样的对象,这样显然不是我们期望的。所以拷贝赋值操作符被定义为delete的。
最后我们说到移动操作,移动操作是c++11标准中新的特性。
如果我们要自己写一个长度可变的数组,如果发现之前数组的容量不够了,为了保证数组元素的地址是连续的,我们不得不重新申请更大的空间数组来保存,此时我们之前的做法是拷贝旧数组中的元素到新数组中,然后销毁之前的旧数组。但是在新标准中我们可以选择移动操作,避免额外的拷贝操作。此时我们会用到移动构造函数和移动赋值,移动构造函数是将资源从给定对象移动而不是拷贝到正在创建的对象中。
类似于拷贝构造和拷贝赋值,移动构造和移动赋值与之区别就是自身的参数类型为一个右值引用而不是一个引用,还有他们的生成规则,当我们没有定义以上的函数时编译器会为我们生成默认的,但是移动构造和移动赋值不是这样,他们只有在我们没有定义任何自己版本的拷贝控制成员是才会生成,也就是说如果我们定义了自己的拷贝构造或者拷贝赋值时编译器不会生相关的移动构造和移动赋值函数。如果一个类没有移动构造和移动赋值那么相应的移动操作会转换为拷贝来进行。
class Foo{
public:
Foo() = default;
Foo(const Foo&); //我们定义可自己的拷贝构造所以没有移动构造函数
};
Foo x;
Foo y(x); //拷贝构造函数,x是一个左值
Foo z(std::move(x)); //拷贝构造,因为没有移动构造函数
这里初始化z是我们调用std::move函数返回一个x的Foo&&,但是我们没有移动构造函数,所以编译器将Foo&&转化成const Foo&调用拷贝构造来完成相关的构造。移动构造函数和移动赋值的目的是盗取参数的内存,所以当我们移动操作结束后,我们可以销毁移后源对象,可以赋予新值但是不能再使用移后源对象。 从一个对象移动数据并不会销毁此对象,但有时候移动操作完成后我们会销毁此对象,所以我们要保证该对象是可析构的,所以我们移动对象时,如果对象是指针类型,移动后一定要把源对象指针置为null。移动还有一个用处就是如果我们需要把一个不可拷贝的对象给另外一个变量时,比如std::cin,我们就可以用std::move函数来实现。
下面给一个以上所有函数的一个简单的实现例子吧(不是我自己写的,但是很能说明问题)。
#include <iostream>
using namespace std;
class Pointer {
public:
Pointer(const int i, const string &n) {
mptr = new int[i];
length = i;
name = n;
cout << "带参数构造函数\n";
showID();
}
Pointer() : mptr(nullptr), length(0) {
cout << "无参数构造函数\n";
showID();
}
virtual ~Pointer() {
cout << name + "析构函数\n";
if (mptr)
delete[] mptr;
mptr = nullptr;
}
Pointer(const Pointer &s) {
length = s.getlen();
mptr = new int[length];
name = s.name;
cout << "复制构造函数\n";
showID();
}
Pointer &operator=(const Pointer &s) {
if (this == &s)
return *this;
if (mptr)
delete[] mptr;
length = s.getlen();
mptr = new int[length];
name = s.name;
cout << "赋值运算符\n";
showID();
return *this;
}
//移动构造函数,参数s不能是const Pointer&& s,因为要改变s的成员数据的值
Pointer(Pointer &&s) {
length = s.getlen();
mptr = s.getmptr();
name = s.name + "_yidonggouzao";//调用移动构造函数时,加一个标记
s.mptr = nullptr;
cout << "移动构造函数\n";
showID();
}
//移动赋值运算符
Pointer &operator=(Pointer &&s) {
if (this == &s)
return *this;
if (mptr)
delete[] mptr;
length = s.getlen();
mptr = s.mptr;
name = s.name + "_yidongfuzhi";//调用移动赋值运算符时,加一个标记
s.mptr = nullptr;
cout << "移动赋值运算符\n";
showID();
return *this;
}
void showID() {
cout << "长度:" << length << " 指针:" << mptr << " 名字:" << name << endl;
}
int getlen() const {
return length;
}
int *getmptr() const {
return mptr;
}
private:
int *mptr;
int length;
string name = "#NULL";//该参数用来标记不同的对象,c++11支持直接在类的数据成员定义处初始化
};
Pointer test() {
Pointer a(2, "test");
return a;
}
int main(int argc) {
Pointer(4, "notname1");
//调用移动构造函数,创建对象a1
Pointer a1 = test();
cout << "a1.showID():\n";
a1.showID();
Pointer a2;
//调用移动赋值运算符
a2 = Pointer(5, "notname2");
//此处没有调用移动构造函数,也就是说Pointer(7, "notname3") 这个变量没有被立即销毁(即不是临时变量)
// ,也许是因为它有了名字a3,所以不是临时变量了
Pointer a3(Pointer(7, "notname3"));
cout << "a3.showID():\n";
a3.showID();//验证a3确实是Pointer(7, "notname3")
cout << endl;
return 0;
}