c++作为高级语言,面向对象编程是其重要的语言特性。设计好的架构,其基础也是类的设计。我们之前已经将类本身的知识梳理了一遍。这一章着重介绍类控制,包括拷贝控制、重载、面向对象设计以及模板和泛型编程。这些非常非常重要,是实现工程必须要掌握的基础知识。要打起十分的精神来学习。
按照c++ primer的顺序,我们从拷贝控制说起。
这章看的时间有点久,有些东西很陌生。可能自己接触的实际工程太少,有些经验的东西还体会不到。不过也不影响学习。至少有了前车之鉴,以后走过的坑会少点。
c++是一门很细致的语言。对象的生命周期可能会经过初始化、拷贝、赋值、释放一系列的过程。c++统统可以对其进行控制。在类的基础知识那一章着重分析了类的初始化,也就是类的构造函数。这一章主要讨论的是如何控制对象拷贝、赋值、移动和销毁。
类通过特殊的成员函数来控制这些操作。
注意:
拷贝构造函数和赋值是不一样的,前面提到过初始化和赋值的区别。拷贝构造函数是在用另一个对象创建本对象时调用,而赋值是在用右值替换左值过程调用。
这些统称为拷贝控制操作。需要指出的是,如果类没有定义这些操作,编译器会自动生成默认的操作。但是一些特殊的场景下,使用默认的操作会出现问题。因此,问题的关键在于认识在何时需要定义这些操作。
这三者操作是最基本的操作。移动操作是新的标准提供的性质,一会深入分析。
1.拷贝构造函数
拷贝构造函数使用场景有三个:
拷贝构造函数的第一个参数是自身类类型的引用,且任何其他参数都有默认值。
class Foo {
public:
Foo();
Foo(const Foo &); // 拷贝构造函数
};
Foo a;
Foo B = a; // 拷贝初始化
Foo B(a); // 直接初始化
拷贝构造函数不应该是explicit,因为拷贝构造函数在几种情况下会被隐式的使用。
拷贝构造函数参数必须是引用,因为如果不是引用,按值传递就必须调用拷贝构造函数,如此无限循环。
2.拷贝赋值运算符
初始化和赋值是两个不同的操作,我们反复在强调这一点。这里是重载赋值运算符,以控制同类型之间的对象赋值。
场景:
Sales_data trans, accum;
trans = accum; // 使用Sales_data的拷贝赋值运算符
重载运算符本质上是函数。
class Foo {
public:
Foo& operator= (const Foo &); // 赋值运算符重载
};
Foo& Foo::operator= (const Foo &rhs) {
data = rhs.data;
return *this;
}
3.析构函数
析构函数是在对象销毁之前调用的函数。和构造函数执行相反的操作,这个相反在全方位。比如构造函数先执行初始化列表(按照数据成员定义顺序),然后执行函数体。析构过程先执行函数体,然后销毁数据成员(按照数据成员定义的逆序)。
析构函数不接受参数,不返回值。
class Foo {
public:
~Foo(); // 析构函数
};
场景:
注意:
当指向一个对象的引用或者指针离开作用域时,析构函数不会执行
4.三/五法则
何时定义拷贝控制操作,有几条原则可寻。
这是因为,需要析构函数,常常伴随动态内存管理。而使用编译器合成的操作,往往是浅拷贝。可能会析构多次,导致未定义的错误。因此,几乎肯定需要拷贝和赋值操作
5.使用合成版本
使用=default
显示地要求编译器生成合成的版本。
6.阻止拷贝
在某些场景下,需要禁止拷贝操作。比如iostream类,阻止拷贝,以避免多个对象读写同一个IO缓冲,导致数据不一致。
实现拷贝阻止有两种方式:
struct NoCopy {
NoCopy() = default;
NoCopy(const NoCopy &) = delete; // 阻止拷贝
NoCopy &operator=(const NoCopy &) = delete; // 阻止赋值
~NoCopy() = default;
};
注意:
析构函数不能是删除的成员,可以是删除,但是一旦定义为删除的,就不能定义这种类型的变量或成员,但是可以动态分配对象,却又无法释放对象。
通过将拷贝构造函数和拷贝赋值函数声明成private 来阻止拷贝
class PrivateCopy {
PrivateCopy(const PrivateCopy &); // class 默认为private ,阻止拷贝
PrivateCopy &operator=(const PrivateCopy &); // 阻止赋值
public:
PrivateCopy() = default;
~PrivateCopy() = default;
};
注意:
虽然声明成private,但是友元类和友元函数还是可以拷贝,因此,为了阻止友元类和友元函数拷贝,我们将这些拷贝成员声明为private,但是不定义它们。这样友元类和友元函数如果拷贝,会发生链接错误。
拷贝的过程,是先分配新的空间,然后向新的空间里赋值。大部分情况下,对象拷贝之后会立即销毁。如果使用移动而非拷贝对象,性能将会大幅度的提升。新标准就提供了移动的新特性。
1.右值引用
为了支持移动操作,新标准引入了右值引用,非常triky。
我们之前提到的引用都是左值引用,即绑定到左值的引用。右值引用顾名思义,绑定到右值的引用。使用的方法是&&
而非&
。
右值引用一个非常重要的特性是—–只能绑定到一个将要销毁的对象(临时对象),因此,我们可以自由的将右值引用的资源”移动”到另一个对象中。好好体会一下。
int i = 42;
int &r = i; // 左值引用
int &&rr = i; // 错误,不能将右值引用绑定到一个左值
int &r2 = i * 42; // 错误,不能将一个左值引用绑定到一个右值
const int &r3 = i * 42; // 正确,const引用绑定右值
int &&rr2 = i * 42; // 正确,右值引用绑定右值
左值持久,右值短暂(字面值、临时对象)
但是我们说了,右值引用这个性质,可以让我们自由的接管所引用的对象的资源。
int &&rr1 = 42; // 正确,绑定字面值
int &&rr2 = rr1; // 错误,rr1表达式是一个左值
虽然我们不可以将一个右值直接绑定到左值上,但是我们可以显示的将一个左值转换为右值引用。
#include
int &&rr3 = std::move(rr1); // ok
调用了move意味着承诺:除了对rr1赋值或者销毁它以外,我们将不再使用它(不再使用它的值)。
2.移动构造函数、移动赋值运算符
移动操作,表示从给定对象“窃取”资源,而不是拷贝资源。
StrVec::StrVec(StrVec &&s) noexcept // 移动操作不应该抛出任何异常
// 成员初始化接管 s 中的资源
: element(s.element), first_free(s.first_free), cap(s.cap)
{
// 必须让 s 进入这样的状态-----对其运行析构函数是安全的。
// 因为,其后s 源对象会被销毁,也就是执行析构函数,如果s.first_free 还指向原来的资源
// 那么,移动的内存就会被销毁,这不是我们所想要的。
s.element = s.first_free = s.cap = nullptr; // 源对象必须不再指向被移动的资源
}
移动赋值运算符必须要正确处理自赋值(赋值运算符都需要考虑这一点)
StrVec &StrVec::operator=(StrVec &&rhs) noexcept {
// 检测自赋值
if (this != &rhs) {
free(); // 释放已有的元素
elements = rhs.elements; // 从rhs接管资源
first_free = rhs.frist_free;
cap = rhs.cap;
// 将rhs置于可析构状态
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
}
注意:
移源后对象必须可析构,另外,移动构造函数默认不能由编译器合成,但是如果类中的每个非static数据成员都是可移动的,编译器就可以为它合成移动构造函数或移动赋值运算符。其中,内置类型可以移动,string定义了自己的移动操作。
如果一个类有一个可用的拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来”移动”的。
class HasPtr {
public:
// 移动构造函数
HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) {
p.ps = 0;
}
// 赋值运算符既是移动赋值运算符,也是拷贝赋值运算符
HasPtr &operator=(HasPtr rhs) {
swap(*this, rhs);
return *this;
}
};
hp = hp2; // hp2 是一个左值,使用拷贝构造函数来拷贝
hp = std::move(hp2); // 移动构造函数移动hp2
最后建议:不要随意使用移动操作,因为移后源对象具有不确定的状态,对其调用std::move是危险的。当我们调用move函数时,必须绝对确认移后源对象没有其他的用户。