第4课 - 顶层父类的创建
在开始创建顶层父类之前,先补充两点知识,主要是为了解释为什么需要顶层父类,以及顶层父类在DTLib中的作用。
1. 软件架构实践经验
在面向对象软件架构实践中,总结出了以下三条经验:
- 尽量使用单重继承的方式进行系统设计
- 尽量保持系统中只存在单一的继承树
- 尽量使用组合关系代替继承关系
但不幸的是:
- C++语言的灵活性使得代码中可以存在多个继承树
- C++编译器的差异使得同样的代码可能表现不同的行为,举个例子,new操作如果失败会发生什么?
2. new操作失败会发生什么
【常见的动态内存分配代码】
1 /*C代码*/ 2 int *p = (int *)malloc(10 * sizeof(int)); 3 4 if (p != NULL) 5 { 6 //...... 7 } 8 9 /*C++代码*/ 10 int *p = new int(10); 11 12 if (p != NULL) 13 { 14 //...... 15 }
【必须知道的事实】
- malloc函数申请失败时返回NULL
- new申请失败时,根据编译器的不同,其结果可能为:
1.返回NULL(早期C++编译器)
2.抛出std::bad_alloc异常(现代C++编译器大多采取这种结方式)
【如何跨编译器统一new的行为,提高代码移植性】
可以考虑的解决方案有:
- 类层次范围:重载new/delete,不抛出任何异常。
- 单次动态内存分配:使用nothrow参数,指明不抛出异常。
3. 顶层父类Object设计
【创建Object类的意义】
- 遵循经典设计准则,DTLib中所有数据结构类都继承自Object,保证单一继承树
- 在Object类中重载new/delete,统一动态内存申请的行为,提高代码移植性
【接口定义】
1 #ifndef OBJECT_H 2 #define OBJECT_H 3 4 namespace DTLib 5 { 6 7 class Object 8 { 9 public: 10 void *operator new(unsigned int size) throw(); 11 void operator delete(void *p); 12 void *operator new[](unsigned int size) throw(); 13 void operator delete[](void *p); 14 virtual ~Object() = 0; 15 }; 16 17 } 18 19 #endif // OBJECT_H
【接口实现】
考虑到malloc()申请内存失败是固定返回NULL值,因此new和new[]的重载实现为调用malloc(),delete和delete[]的重载实现为调用free()。
1 #include "Object.h" 2 #include <cstdlib> 3 4 namespace DTLib 5 { 6 7 void *Object::operator new(unsigned int size) throw() 8 { 9 return malloc(size); 10 } 11 12 void Object::operator delete(void *p) 13 { 14 free(p); 15 } 16 17 void *Object::operator new[](unsigned int size) throw() 18 { 19 return malloc(size); 20 } 21 22 void Object::operator delete[](void *p) 23 { 24 free(p); 25 } 26 27 Object::~Object() 28 { 29 30 } 31 32 }
需要注意的是,重载的new、delete仅在同时满足以下条件时适用:
1.使用了DTLib命名空间 using namespace DTLib;
2.动态申请Object的子类对象 Test *obj = new Test(); //class Test : public Object
【测试示例】
先在new/delete、new[]/delete[]的重载函数中添加一行测试代码:
1 void *Object::operator new(unsigned int size) throw() 2 { 3 cout << "Object::operator new: " << size << endl; 4 return malloc(size); 5 } 6 7 void Object::operator delete(void *p) 8 { 9 cout << "Object::operator delete: " << p << endl; 10 free(p); 11 } 12 13 void *Object::operator new[](unsigned int size) throw() 14 { 15 cout << "Object::operator new[]: " << size << endl; 16 return malloc(size); 17 } 18 19 void Object::operator delete[](void *p) 20 { 21 cout << "Object::operator delete[]: " << p << endl; 22 free(p); 23 }
然后编写main.cpp测试代码:
1 #include "Object.h" 2 #include <iostream> 3 4 using namespace DTLib; 5 using namespace std; 6 7 class Test1 : public Object 8 { 9 public: 10 int i; 11 int j; 12 }; 13 14 class Test2 : public Test1 15 { 16 public: 17 int k; 18 }; 19 20 int main() 21 { 22 Test1 *obj1 = new Test1; 23 Test2 *obj2 = new Test2; 24 25 cout << "obj1 = " << obj1 << endl; 26 cout << "obj2 = " << obj2 << endl; 27 28 delete obj1; 29 delete obj2; 30 31 cout << endl; 32 33 Test1 *obj3 = new Test1[10]; 34 Test2 *obj4 = new Test2[10]; 35 36 cout << "obj3 = " << obj3 << endl; 37 cout << "obj4 = " << obj4 << endl; 38 39 delete[] obj3; 40 delete[] obj4; 41 42 return 0; 43 }
运行结果:
PS:测试结果显示obj3和obj4的size分别为124和164,都比理论值多了4字节,这里引用https://blog.csdn.net/hazir/article/details/21413833一文中的部分内容进行解释。
如何申请和释放一个数组?
我们经常要用到动态分配一个数组,也许是这样的:
string *psa = new string[10]; //array of 10 empty strings int *pia = new int[10]; //array of 10 uninitialized ints
上面在申请一个数组时都用到了
new []
这个表达式来完成,按照我们上面讲到的 new 和 delete 知识,第一个数组是 string 类型,分配了保存对象的内存空间之后,将调用 string 类型的默认构造函数依次初始化数组中每个元素;第二个是申请具有内置类型的数组,分配了存储 10 个 int 对象的内存空间,但并没有初始化。如果我们想释放空间了,可以用下面两条语句:
delete [] psa; delete [] pia;
都用到
delete []
表达式,注意这地方的 [] 一般情况下不能漏掉!我们也可以想象这两个语句分别干了什么:第一个对 10 个 string 对象分别调用析构函数,然后再释放掉为对象分配的所有内存空间;第二个因为是内置类型不存在析构函数,直接释放为 10 个 int 型分配的所有内存空间。这里对于第一种情况就有一个问题了:我们如何知道 psa 指向对象的数组的大小?怎么知道调用几次析构函数?
这个问题直接导致我们需要在 new [] 一个对象数组时,需要保存数组的维度,C++ 的做法是在分配数组空间时多分配了 4 个字节的大小,专门保存数组的大小,在 delete [] 时就可以取出这个保存的数,就知道了需要调用析构函数多少次了。
还是用图来说明比较清楚,我们定义了一个类 A,但不具体描述类的内容,这个类中有显示的构造函数、析构函数等。那么 当我们调用
class A *pAa = new A[3];
时需要做的事情如下:
从这个图中我们可以看到申请时在数组对象的上面还多分配了 4 个字节用来保存数组的大小,但是最终返回的是对象数组的指针,而不是所有分配空间的起始地址。
这样的话,释放就很简单了:
delete [] pAa;
这里要注意的两点是:
- 调用析构函数的次数是从数组对象指针前面的 4 个字节中取出;
- 传入
operator delete[]
函数的参数不是数组对象的指针 pAa,而是 pAa 的值减 4。
4. 异常类改进
根据所设计的顶层父类Object,对上次创建的异常类进行如下改进:
【改进点一】
Exception类继承自Object类 class Exception : public Object ,使得在堆空间创建异常类对象失败时,返回NULL指针。
【改进点二】
新增InvalidOperationException非法操作异常类,当类的成员函数被调用时,如果状态不正确(如一些成员函数在类对象刚初始化时不允许使用)则抛出异常
1 /* 2 * 非法操作异常:类的成员函数被调用时,如果状态不正确(如一些成员函数在类对象刚初始化时不允许使用)则抛出异常 3 */ 4 class InvalidOperationException : public Exception 5 { 6 public: 7 InvalidOperationException() : Exception(0) { } 8 InvalidOperationException(const char *message) : Exception(message) { } 9 InvalidOperationException(const char *file, int line) : Exception(file, line) { } 10 InvalidOperationException(const char *message, const char *file, int line) : Exception(message, file, line) { } 11 12 InvalidOperationException(const InvalidOperationException& e) : Exception(e) { } 13 InvalidOperationException &operator = (const InvalidOperationException &e) 14 { 15 Exception::operator =(e); 16 return *this; 17 } 18 };
5. DTLib的开发方式和注意事项
- 迭代开发:每次完成一个小的目标,持续开发,最终打造可复用类库
- 单继承,单一继承树:所有类都继承自Object,统一规范堆对象创建时的行为
- 只抛异常,不处理异常:使用THROW_EXCEPTION抛异常,提高可移植性
- 弱耦合性:尽量不使用std标准库中的类和函数,提高可移植性
注:本文整理于狄泰《数据结构开发实战教程》课程内容