5、C++11 补丁 – 缺省值
经过上面的学习我们发现,自动生成的默认构造函数对内置类型不处理,对自定义类型要处理的特性使得构造函数变得很复杂,因为一般的类都有需要初始化的内置类型成员变量,这就使得编译器默认生成的构造函数看起来没什么作用;
C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给缺省值;缺省值的意思就是如果构造函数没有对该变量进行初始化,那么该变量就会使用缺省值:
C++11 中的缺省值功能十分强大,它甚至可以缺省一块动态内存:
注意:这里对成员变量给定缺省值并不是对其初始化,因为类中的成员变量只是声明,只有当实例化对象之后它才具有物理空间,才能存放数据;而缺省一块动态内存也不难理解,相当于我设计了一份房屋的图纸,我知道某个房间具体要多大,所以我可以在图纸上可以进行标注,当实际建造房屋的时候根据标注给定大小即可;
三、析构函数
1、基础知识
析构函数:和构造函数相反,析构函数完成对象中资源的清理工作,并且在对象销毁时由编译器自动调用;(注:如同构造函数不是创建一个对象一样,析构函数也不是销毁一个对象,对象的销毁工作由编译器完成)
需要注意的是,当变量的生命周期结束时变量被销毁,所以位于函数中的局部对象在函数调用完成时销毁,位于全局的对象在main函数调用完成时销毁;另外,后定义的对象会被先销毁;
析构函数的特性如下:
析构函数名是在类名前加上字符 ~ (表示与构造函数功能相反);;
无参数无返回值;
一个类只能有一个析构函数,若未显式定义,系统会自动生成默认的析构函数;(注意:析构函数不能重载)
对象生命周期结束时,C++编译系统系统自动调用析构函数;
析构函数对内置类型不处理,对自定义类型调用它自身的析构函数;
2、特性分析 – 选择处理
我们还是以 Date、Stack、MyQueue 这三个类来演示:
Date:
class Date { public: Date(int year = 1970, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } private: int _year; int _month; int _day; };
Date 类没有进行资源的申请 (malloc 内存、fopen 文件等操作),所以我们可以不用显式定义析构函数,直接使用编译器自动生成的构造函数即可;(虽然自动生成的构造函数对内置类型不处理,但本来Date类就不需要我们做任何处理)
Stack:
class Stack { public: Stack(int capacity = 4) { _a = (int*)malloc(sizeof(int) * capacity); if (_a == nullptr) { perror("malloc fail\n"); exit(-1); } _top = 0; _capacity = capacity; cout << "Stack 构造" << endl; } ~Stack() { free(_a); _a = NULL; _top = _capacity = 0; } void Push(int x) { _a[_top++] = x; } private: int* _a; int _top; int _capacity; };
而 Stack 类中的成员变量_a指向了一块动态开辟的空间,如果我们使用自动生成的析构函数,那么析构函数对内置类型 int* _a 不进行处理,就会造成内存泄露;所以我们需要显式定义析构函数;
MyQueue:
class MyQueue { public: void Push(int x) { _pushST.Push(x); } Stack _pushST; Stack _popST; };
MyQueue 的两个成员变量 pushST 与 popST 都是自定义类型,所以编译器会调用它们的析构函数,即 ~Stack,所以MyQueue动态开辟的空间也会得到释放,不需要我们手动定义析构函数,使用系统默认生成的即可。
总结
如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如 Date 类;有资源申请时要写,否则会造成资源泄漏,比如Stack类;但这只是一般情况,不是绝对的,最终还是要看需求 (比如 MyQueue 中我们的成员变量申请了资源,但是也不需要我们手动定义析构函数) 。
四、拷贝构造
1、基础知识
在现实生活中,可能存在一个与你一样的自己,我们称其为双胞胎:
那在创建对象时,可否创建一个与已存在对象一模一样的新对象呢?答案是可以的。C++设计了拷贝构造来实现这个功能。
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用 (一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
拷贝构造也是特殊的成员函数,其特征如下:
拷贝构造函数是构造函数的一个重载形式,当我们使用拷贝构造实例化对象时,编译器不再调用构造函数;拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用;
若未显式定义,编译器会生成默认的拷贝构造函数;
默认的拷贝构造函数对内置类型以字节为单位直接进行拷贝 – 浅拷贝,对自定义类型调用其自身的拷贝构造函数;
Date 类的拷贝构造:
class Date { public: Date(int year = 1970, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } Date(const Date& d) //拷贝构造 { _year = d._year; _month = d._month; _day = d._day; } private: int _year; int _month; int _day; };
2、特性分析 – 引用作参数
拷贝构造的第二点特性如下:拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用;
原因如下:当我们使用d1来拷贝构造创建d2对象时,编译器会自动调用拷贝构造函数,但是我们知道,传值传递时形参是实参的一份临时拷贝;也就是说,拷贝构造函数在执行其函数体中的指令之前,其形参d需要先拷贝一份d1,而d拷贝d1又需要调用拷贝构造函数,如此下去就会引发无穷递归;
但是如果拷贝构造函数的参数是引用的话,形参作为实参的别名,不需要拷贝实参,从而使得函数功能顺利实现;
另外,拷贝构造函数的参数通常使用 const 修饰,这是为了避免在函数内部拷贝出错,类似下面这样:
3、特性分析 – 深浅拷贝
默认拷贝构造的拷贝规则如下:默认的拷贝构造函数对内置类型以字节为单位直接进行拷贝 – 浅拷贝,对自定义类型调用其自身的拷贝构造函数;
对于深浅拷贝,我们以栈为例:
class Stack { public: Stack(int capacity = 4) { _a = (int*)malloc(sizeof(int) * capacity); if (_a == nullptr) { perror("malloc fail\n"); exit(-1); } _top = 0; _capacity = capacity; cout << "Stack 构造" << endl; } ~Stack() { free(_a); _a = NULL; _top = _capacity = 0; } void Push(int x) { _a[_top++] = x; } private: int* _a; int _top; int _capacity; };
可以看到,我们并没有显式定义 Stack 的拷贝构造函数,那么编译器会自动生成一个拷贝构造,并且会将 Stack 的成员变量 _a、_top、_capacity 按字节拷贝到d2对象中;
我们继续往下调试,发现程序异常:
对C语言动态内存管理较为敏感的同学可能已经发现了问题:编译器按字节将d1中的内容拷贝到d2中,但成员变量_a指向的是一块动态内存,即_a中存放的是动态空间的起始地址,那么将d1的_a拷贝给d2的_a后,二者指向同一块空间,而main调用完毕时会销毁d1和d2对象,此时编译器会自动调用 Stack 的析构函数,这就造成 _a 指向的同一块空间被析构了两次,从而引发异常;同时,这也造成了我们在 d2中插入3时也改变了d1中的数据;
那么正确的拷贝方式应该是:为d2的_a单独开辟一块空间,并将d1中_a指向空间的内容拷贝到该空间中,其余内置成员变量再按字节拷贝:
class Stack { public: Stack(int capacity = 4) { _a = (int*)malloc(sizeof(int) * capacity); if (_a == nullptr) { perror("malloc fail\n"); exit(-1); } _top = 0; _capacity = capacity; cout << "Stack 构造" << endl; } ~Stack() { free(_a); _a = NULL; _top = _capacity = 0; } Stack(const Stack& st) //拷贝构造 { _a = (int*)malloc(sizeof(int) * st._capacity); if (_a == nullptr) { perror("malloc fail\n"); exit(-1); } memcpy(_a, st._a, sizeof(int) * st._capacity); _top = st._top; _capacity = st._capacity; } void Push(int x) { _a[_top++] = x; } private: int* _a; int _top; int _capacity; }; int main() { Stack st1; st1.Push(1); st1.Push(2); Stack st2(st1); st2.Push(3); return 0; }
在了解了 Stack 的拷贝构造之后,我们再来看 Date 类和 MyQueue 类的拷贝构造;
对于 Date 类来说,其成员变量全是内置类型,且没有资源申请,所以我们可以直接使用编译器默认生成的拷贝构造,直接按字节拷贝:
对于 MyQueue 类来说,它的成员变量全部是自定义类型,所以默认成员函数回去调用其自身的拷贝构造,即 Stack 的拷贝构造,而 Stack 的拷贝构造虽然需要深拷贝,但我们已经显式定义,所以也不需要我们提供拷贝构造:
总结
如果类中没有资源申请,则不需要手动实现拷贝构造,直接使用编译器自动生成的即可;如果类中有资源申请,就需要自己定义拷贝构造函数,否则就可能出现浅拷贝以及同一块空间被析构多次的情况;
其实,拷贝构造和函数析构函数在资源管理方面有很大的相似性,可以理解为需要写析构函数就需要写拷贝构造,不需要写析构函数就不需要写拷贝构造;
拷贝构造的经典使用场景:
- 使用已存在对象创建新对象;
- 函数参数类型为类类型对象;
- 函数返回值类型为类类型对象;