类的5大控制成员

类的5大控制成员包括:

概念

构造函数是类用来控制其对象初始化过程的函数,构造函数的任务是初始化类对象的数据成员.无聊何时只要创建类的对象,就会调用构造函数.
与其他成员函数不同,构造函数没有返回类型;构造函数不能被声明为const.当我们创建一个类的const对象时,直到构造函数完成初始化过程,对象才能真正取得其常量属性.

默认构造函数

当对象被默认初始化值初始化时自动执行默认构造函数.
默认初始化在以下情况下发生:
值初始化在以下情况下发生:

拷贝构造函数

如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数

拷贝初始化通常由拷贝构造函数来完成.但是如果一个类有一个移动构造函数,则拷贝初始化会使用移动构造函数而非拷贝构造函数来完成.总之,拷贝初始化是依靠拷贝构造函数或移动构造函数来完成的.

合成的拷贝构造函数与默认构造函数不同, 即使类中存在其他构造函数, 编译也会自动合成一个拷贝构造函数.
合成的拷贝构造函数将其参数的成员逐个拷贝到正在创建的对象中,编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中.

每个成员的类型决定了它如何拷贝:对于类类型成员,会使用其拷贝构造函数来拷贝;内置类型的成员直接拷贝.

虽然数组不能直接拷贝,但是合成拷贝构造函数会逐元素地拷贝一个数组类型的成员.如果数组元素是类类型,则使用元素的拷贝构造函数来进行拷贝.

直接初始化和拷贝初始化

直接初始化是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数.
使用拷贝初始化则要求编译器将右侧运算对象拷贝到我们正在创建的对象中.如果必要的话还需要进行类型转换.
拷贝初始化要么通过拷贝构造函数,要么通过移动构造函数完成.
拷贝初始化在以下情况下发生:

当我们用=定义变量时
将一个对象作为实参传递给一个引用类型的形参
从一个返回类型为非引用类型的函数返回一个对象
用花括号列表初始化一个数组中的元素或一个聚合类中的成员

拷贝赋值运算符

赋值运算符返回一个指向其左侧运算对象的引用.

析构函数

构造函数初始化对象的非static数据成员;析构函数释放对象使用的资源,并销毁对象的非static数据成员.在一个析构函数中,首先执行函数体,然后销毁成员,成员按初始化顺序的逆序销毁.

移动构造函数

移动赋值运算符

关联

如果类的设计者没有声明,那么编译器会为它声明一个拷贝构造函数,一个拷贝赋值运算符函数和一个析构函数.
如果类的设计者没有声明任何构造函数,那么编译器会为类声明一个默认构造函数.所有这些版本都是public且inline.
默认的拷贝构造函数的功能:

以上五个函数统称为拷贝控制操作.各个拷贝控制操作之间的关系是:

一个类如果包含指针数据成员,那么需要一个析构函数来释放动态分配的内存.

通常如果一个类需要一个析构函数,那么它也需要一个拷贝构造函数和一个拷贝赋值运算符.但需要拷贝或赋值操作时,不一定需要析构函数.
如果一个类需要一个拷贝构造函数,那么它也需要一个拷贝构造函数.

实例

下面实现一个string类,命名为String.首先类的声明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class String {
public:
explicit String(const char *cstr = 0);

String(const String &str);

String &operator=(const String &str);

~String();

char *get_c_str() const {
return m_data;
}

private:
char *m_data;
};

单参数构造函数

接下来我们来看构造的函数的实现.单参数构造函数有一个默认实参,所以,这个构造函数是默认构造函数.explicit关键字的存在是为了防止从char*到String的隐式转换.

1
2
3
4
5
6
7
8
9
inline String::String(const char *cstr) {
if (cstr) {
m_data = new char[std::strlen(cstr) + 1];
strcpy(m_data, cstr);
} else {
m_data = new char[1]; // 这里分配一个长度为1的数组是为了与上面搭配
*m_data = '\0';
}
}

在实现中,首先检查cstr是否是空指针,若是空指针,说明当前字符串是个空字符串.那么仅分配一个字节,存在字符串结束标志’\0’.否则,分配足够的空间,并将字符串指针cstr所指向的字符串拷贝到新分配的空间中. strcpy函数会拷贝整个源字符串到目的字符串中,包括结尾处的空字符’\0’

析构函数

然后我们来看析构函数.因为String类是一个class with pointer,因此需要在析构函数中销毁指向指向的对象并销毁其占用的内存.

1
2
3
inline String::~String() {
delete[] m_data; // 销毁底层字符串并释放字符串占用的空间
}

delete运算符调用m_data指向的对象的析构函数销毁对象,并调用operator delete([])标准库函数释放内存空间.

拷贝构造函数

拷贝构造函数采用的是深拷贝.当目的String对象从源String对象中拷贝的不仅仅是指针,还需要为目的String对象分配内存空间,并拷贝源String对象字符串的内容.

1
2
3
4
String::String(const String &str) {
m_data = new char[strlen(str.m_data) + 1];
strcpy(m_data, str.m_data);
}

拷贝赋值运算符函数

首先,拷贝赋值运算符函数的功能是将source str的内容拷贝给dest str,dest str是一个已经存在的对象,因此,它不是拷贝赋值运算符函数的local object,所以可以返回dest str的一个引用.
接下来,我们需要实现最难实现的拷贝赋值运算符函数.在实现拷贝赋值运算符函数过程中,需要考虑的第一个问题是自我赋值

第一版:检测并避免了自我赋值

1
2
3
4
5
6
7
8
9
10
11
12
String &String::operator=(const String &str) {
// 检测自我赋值
if (this == &str) {
return *this;
}

//清除旧的数据,为新的数据分配空间,令m_data指向新分配的数据
delete m_data;
m_data = new char[strlen(str.m_data) + 1];
strcpy(m_data, str.m_data);
return *this;
}

上面的代码尽管避免了自我赋值,却不具备异常安全性.具体而言,
*m_data = new char[strlen(str.m_data) + 1]; *
可能会因为内存不足等原因而出错.一旦出错,就会导致,m_data指向一块已经被释放的内存空间.

因此我们仍然需要考虑如何实现异常安全性.

第二版:防止在内存分配成功之前释放旧的内存空间

1
2
3
4
5
6
7
String &String::operator=(const String &str) {
char *m_old = m_data;
m_data = new char[strlen(str.m_data) + 1];
strcpy(m_data, str.m_data);
delete m_old;
return *this;
}

在这一版本的代码中,我们实现了先保证新的内存空间能够分配成功,分配成功之后,再将旧的字符串拷贝到新内存中,然后再销毁旧的字符串并释放内存空间.

第三本版:copy and swap计数实现异常安全性
copy和swap在于"修改对象的副本,然后在一个不抛异常的函数中将修改后的数据和原件置换".
首先需要定义个public member swap,负责实现两个String类型的置换,这个函数不允许抛异常.

1
2
3
4
5
// member swap:不允许抛异常
void String::swap(String &other) {
using std::swap;
swap(m_data, other.m_data); // 直接交换两个指针
}

此后在std命名空间中全特化swap的String.

1
2
3
4
5
6
namespace std {
template<>
void swap<String>(String &a, String &b) {
a.swap(b); // 调用String的public swap成员函数
}
}

或者是定义一个non-member swap.

1
2
3
4
5
// non-member swap,调用了public member swap
// 且non-member swap会先于std::swap被匹配
void swap(String &a, String &b) {
a.swap(b);
}

在std命名空间中的全特化swap和non-member swap中都调用了public member swap来实现真正的置换过程.
最后,我们可以实现拷贝赋值运算符函数的实现如下.

1
2
3
4
5
6
7
String &String::operator=(const String &str) {
String tmp(str);

using std::swap; // 令std::swap在此函数内可用
swap(*this, tmp);
return *this;
}

测试

最后为了测试,准备一个输出运算符重载的non-member函数.

1
2
3
4
5
6
7
8
std::ostream &operator<<(std::ostream &out, const String &rhs) {
// 为了测试,当字符串为空时,输出empty String
if (*rhs.get_c_str() == '\0') {
out << "empty String";
}
out << rhs.get_c_str();
return out;
}

测试程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
#include "String.h"

using namespace std;

int main() {
String s1;
char p[] = "hello";
String s2(p);
cout << "s1:" << s1 << endl;
cout << "s2:" << s2 << endl;

char p1[] = "good";
String s3(p1);
cout << "s3:" << s3 << endl;

s1 = s3;
cout << "s1:" << s1 << endl;

String s4(s2);
cout << "s4:" << s4 << endl;

return 0;
}

运行结果:

1
2
3
4
5
s1:empty String
s2:hello
s3:good
s1:good
s4:hello