简介
在C++11里,类有五个已经为你写好的特殊函数。它们是析构函数,拷贝构造函数,移动构造函数,拷贝赋值操作和移动赋值操作函数。这些就是Big-Five。在许多情况下,你可以接受编译器为五大操作提供的默认函数。但是,有时你不能这样做。
析构函数(Destructor)
当一个对象离开作用域或者遭到删除时,析构函数被调用。通常,析构函数的唯一职责就是释放在使用对象期间获取的任何资源。这包括通过调用delete 来释放任何与之相应的通过new 操作申请的内存,关闭任何打开的文件等等。默认情况下简单地对每个数据成员调用析构函数。
拷贝构造函数和移动构造函数(Copy Constructor and Move Constructor)
有两个特殊构造函数需要构造一个新对象,该对象初始化为同一类型的另一个对象的同一状态。一个很好的办法来判断到底是拷贝构造函数还是移动构造函数就是通过判断对象是左值还是右值。如果现有的对象是左值(lvalue),那么是拷贝构造函数,如果现有的对象是右值(rvalue)(例如,即将被摧毁的临时对象),那么是移动构造函数。
对于任何对象,例如IntCell 类,一个拷贝构造函数和移动构造函数可以被这样调用:
- 一个初始化声明,例如
IntCell B = C; // 如果C是左值,那么是拷贝构造函数;如果C是右值,那么是移动构造函数
IntCell B { C }; // 如果C是左值,那么是拷贝构造函数;如果C是右值,那么是移动构造函数
需要注意的是
B = C; // 赋值操作,稍后讲解
- 一个使用按值传递(而不是通过& 或者const & )的对象,如前所述,很少这样做。
- 一个通过值返回的对象(而不是通过& 或者const & )。同样,如果被返回的对象是一个左值,则调用拷贝构造函数,如果正在返回的对象是一个右值,则调用一个移动构造函数。
默认情况下,拷贝构造函数的实现,通过依次对其中每个数据成员调用其自身的拷贝构造函数来实现。对于初级的数据成员(例如,整形(int), 浮点型(double)或者指针(pointer)),简单的赋值就可以完成。在IntCell 类中,我们将看到简单的实现。对于本身就是类对象的数据成员,数据成员适当的调用自身的构造函数来实现。
拷贝赋值操作和移动赋值操作(operator =)
当 “=”应用在两个先前已被构造好的对象时,将调用赋值运算符(=)。lhs = rhs 旨在将rhs 的状态赋值到lhs 中。如果rhs 是一个左值,这将通过拷贝赋值操作完成,如果”rhs” 是一个右值,这将通过移动赋值操作完成。默认情况下,通过依次向每个数据成员应用拷贝赋值操作符来实现拷贝赋值操作符
默认操作
如果我们检查IntCell类,我们看到默认值是完全可以接受的,所以我们不必做任何事情。 这通常是这样的。 如果一个类完全由初始数据成员组成,默认值通常是有意义的。 因此,其数据成员为int
,vector<int>
,string
和vector<string>
的类都可以接受默认值。
主要的问题发生在包含指针作为数据成员的类中。假设类中包含一个指针的数据成员,这个指针指向一个动态分配的对象。此时,默认的析构函数不会对这个指针做任何事情(我们必须统购调用delete来删除指针)。此外,拷贝构造函数和拷贝赋值运算符都复制指针的值,而不是指向的对象。因此,我们将有两个类实例包含指向同一个对象的指针。 这是所谓的浅拷贝。通常,我们期待的是一个深拷贝(克隆整个对象)。因此,当一个类中包含指针作为数据成员,深层的语义是重要的,我们必须重构析构函数,拷贝构造函数和拷贝赋值操作来取消默认由编译器自带的默认操作。通常来说,要么接受默认的五个操作,要么重新定义五个操作。
对于IntCell ,这些默认的操作为:
~IntCell(); // 析构函数
IntCell(const IntCell& rhs); // 拷贝构造函数
IntCell(IntCell&& rhs); // 移动构造函数
IntCell& operator=(const IntCell& rhs); // 拷贝赋值操作
IntCell& operator=(IntCell&& rhs); // 移动赋值操作
operator=返回类型为引用的原因是为了允许链式赋值,例如a=b=c。虽然看起来返回值类型应该为const引用,以防止(a=b)=c这种操作。但是在C++中,这种表达式实际上是允许的。 因此,通常使用引用返回类型(而不是const引用返回类型),但是从语法上说是不严谨的。
当我们定义这些函数时,如果还想使用编译器自带的默认函数,我们可以使用= default
,例如:
~IntCell() { cout << "Invoking destructor" << endl; } // 析构函数
IntCell(const IntCell& rhs) = default; // 拷贝构造函数
IntCell(IntCell&& rhs) = default; // 移动构造函数
IntCell& operator=(const IntCell& rhs) = default; // 拷贝赋值操作
IntCell& operator=(IntCell&& rhs) = default; // 移动赋值操作
或者,我们可以通过= delete
来禁止默认操纵,例如:
~IntCell() { cout << "Invoking destructor" << endl; } // 析构函数
IntCell(const IntCell& rhs) = delete; // 拷贝构造函数
IntCell(IntCell&& rhs) = delete; // 移动构造函数
IntCell& operator=(const IntCell& rhs) = delete; // 拷贝赋值操作
IntCell& operator=(IntCell&& rhs) = delete; // 移动赋值操作
如果默认操作是有意义的,那么我们可以使用默认的,如果是没有意义的,我们需要重写这些操作。
重写Big-Five
在大多是时候,这些默认操作都不足以满足我们的需求,这时候我们需要重写这些操作。默认函数不工作的最常见情况发生在数据成员是指针类型,并且指针由某个对象成员函数(例如构造函数)分配时。例如我们通过动态分配一个int 类型的时候,如下,我们定义一个IntCell类:
class IntCell
{
public:
explicit IntCell(int initialValue = 0)
{
storedValue = new int{initialValue};
}
int read() const
{
return *storedValue;
}
void write(int x)
{
*storedValue = x;
}
private:
int* storedValue;
};
当我们定义如上的IntCell类时,使用了默认的操作,其中会有很多问题发生。例如我们如下使用:
int f()
{
IntCell a{2};
IntCell b = a;
IntCell c;
c = a;
a.write(4);
cout << a.read() << endl; // 4
cout << b.read() << endl; // 4
cout << c.read() << endl; // 4
return 0;
}
此段代码输出为3个4,而我们的本意是只将a改为4,这个问题就是使用了默认的拷贝构造函数(Copy Constructor)和拷贝赋值操作(Copy assignment operator=)来拷贝这个storedValue
指针。因此,a.storedValue
,b.storedValue
和c.storedValue
都是指向相同的int 值。这种拷贝就是我们所说的浅拷贝,指针而不是指针被复制。
第二,很容易忽略的是内存泄漏。当我们使用完这个指针变量,离开 f() 函数的作用域,我们不再使用指针了,由于默认析构函数不会自动调用delete
来删除storedValue
指针,所以存在内存泄露的隐患。
综合这些问题,我们重新定义这5个函数。代码如下所示。正如我们所见,一旦析构函数被实现,浅复制将导致一个编译错误:两个对象的storedValue
指针指向相同的int 对象,一旦第一个IntCell对象被析构,storedValue
指针将回收,此时,如果第二个IntCell对象析构,storedValue
指针被删除两次,这是将导致严重的错误。
这就是为什么C++11弃用了先前的默认拷贝操作行为,即是析构函数被重写了。
class IntCell
{
public:
explicit IntCell(int initialValue = 0)
{
storedValue = new int{initialValue};
}
~IntCell()
{
delete storedValue;
}
IntCell(const IntCell& rhs)
{
storedValue = new int{*rhs.storedValue};
}
IntCell(IntCell&& rhs) : storedValue(rhs.storedValue)
{
rhs.storedValue = nullptr;
}
IntCell& operator=(const IntCell& rhs)
{
if(this != &rhs) {
*storedValue = *rhs.storedValue;
}
return *this;
}
IntCell& operator=(IntCell&& rhs)
{
std::swap(storedValue, rhs.storedValue);
return *this;
}
int read() const
{
return *storedValue;
}
void write(int x)
{
*storedValue = x;
}
private:
int* storedValue;
};
在C++11中,在拷贝赋值操作通常使用copy-and-swap惯用法实现。
在第19行和第22行的移动构造函数将数据表示形式从rhs移动到*this; 那么它将rhs的原始数据(包括指针)设置为有效但易于销毁的状态。注意,如果存在非原始数据,则该数据必须在初始化列表中移动。 例如,如果有vector<string>
项,那么构造函数将是:
IntCell(IntCell&& rhs)
: storedValue(rhs.storedValue),
items(std::move(rhs.items)) // 通过std::move将rhs的vector<string>项移动到items中
{
rhs.storedValue = nullptr;
}
在最后,移动赋值操作通过成员与成员之间的swap操作进行交换。注意,有时它被实现为与复制赋值运算符相同的单一交换对象,但是只有当交换自身实现为逐个成员交换时才起作用。如果交换被实现为三个移动,那么我们将有相互的非终止递归。
此时再次调用f(),将会输出
4
2
2