1. 基础议题
条款 01:仔细却别 pointer 和 reference
pointer 和 reference 看起来不一样(pointer 使用 * 和 -> 操作符,reference 则使用 . 操作符),但它们都可以使你得间接参考其他对象。
如果你有一个变量,其目的是用来指向另一个对象,但也有可能它不指向任何对象,那么你应该使用 pointer(pointer 可以设为 null)。如果这个变量必须代表一个对象,也就是并不允许将这个变量设置为 null,那你应该使用 reference(reference 不可以被设为 null)。
pointer 和 reference 的区别
由于 reference 一定得代表某个对象,C++ 因此要求 reference 必须有初值;但是 pointer 并没有这样的限制:
string &rs; // 错误,refernce 必须被初始化
string *ps; // 正确,未初始化的指针,有效,但风险高
没有所谓的 null reference 也意味着使用 reference 回避 pointer 更具效率,因为使用 reference 之前不需要测试其有效性;但是使用 pointer 前,通常需要测试它是否为 null:
void printDouble (const double& rd, const double* pd) {
cout << rd; // 无需测试
if (pd) { // 检测是否为 null pointer
cout << *pd;
}
}
pointer 可以被重新赋值,指向另一个对象;但是 reference 总是指向它最初获得的那个对象:
string s1("Nancy");
string s2("Clancy");
string& rs = s1; // rs 指向 s1
string* ps = &s1; // ps 指向 s1
rs = s2; // rs 仍然指向 s1,但是 s1 的值变成了 Clancy
ps = &s2; // ps 现在直线 s2,s1 没有变化
如何选择 pointer 和 reference
当你需要考虑“不指向任何对象”的可能性时(将 pointer 设置为 null),或者考虑“在不同时间指向不同对象”的能力时(改变 pointer 所指对象),应该采用 pointer;当你确定“总是会代表某个对象”,而且“一旦代表了该对象就不能够再改变”时,应该选用 reference。
还有其他情况也需要使用 reference,例如当你实现某些操作符时,最常见的是 operator[]。这种操作符必须返回某种“能够被当做 assignment 赋值对象”的东西:
vector<ing> v(10);
v[5] = 10; // assignment 的赋值对象是 operator[] 的返回值
如果 operator[] 返回 pointer,上述语句应该写成:
*v[5] = 10;
条款 02:最好使用 C++ 转型操作符
传统的 C 转型动作允许你将任何类型转换为任何其他类型,而且它们难以辨识。旧式转型的语法结构是由一对小括号加上一个对象名称(标识符)组成,而这种组合在 C++ 的任何地方都有可能被使用。
为解决 C 旧式转型的确定,C++ 导入 4 个新的转型操作符:static_cast、const_cast、dynamic_cast 和 reinterpret_cast。
static_cast
static_cast 基本拥有与 C 旧式转型相同的威力与意义,以及相同的限制。例如不能将一个 struct 转型为 int,这些也是 C 旧式转型原本就不可能完成的人物。static_cast 也不能移除表达式的常量性(const_cast 的任务)。
const_cast
const_cast 用来改变表达式种的常量性或易变性。如果将 const_cast 应用于上述以外的用途,那么转型动作会被拒绝。
const_cast 最常见的用途旧式将某个对象的常量性去除掉。
dynamic_cast
dynamic_cast 是用来执行继承系统中“安全的向下转型或跨系转型通过”。也就是说你可以利用 dynamic_cast,将“指向 base class object 的 pointer 或 reference” 转型为“指向 derived class object 的 pointer 或 reference”,并得知转型是否成功。如果转型失败,会以一个 null 指针(当转型对象是指针)或一个 exception(当转型对象是 reference)。
reinterpret_cast
reinterpret_cast 转换结果于编译平台相关,所以 reinterpret_cast 不具有移植性。
reinterpret_cast 的最常用用途是转换“函数指针”类型。假设有一个数组,存储的都是函数指针,由特定的类型
typedef void (*FuncPtr)(); // FuncPtr 是个指针,指向某个函数,后者没有自变量,返回值是 void
FuncPtr funcPtrArray[10]; // funcPtrArray 是个数组,内有 10 个 FuncPtr
假设由于某种原因,你希望将以下函数的一个指针放进 funcPtrArray 中:
int doSomething();
如果没有转型,那么久办不到这一点,因为 doSomething 的类型与 funcPtrArray 所能接受的类型不同:
funcPtrArray[0] = &doSomething; // 错误,类型不符
funcPtrArray[0] = reinterpret_cast<FuncPtr>(&doSomething); // 正确,这样便可以通过编译
函数指针的转型动作,并不具有移植性,某些情况下这样的转型可能会导致不正确的结果(见条款 31),所以你应该尽可能避免将函数指针转型。
替代方法
如果你的编译器尚未支持这些新式转型动作,你可以使用传统转型方式来取代 static_cast、const_cast 和 reinterpret_cast(dynamic_cast 没有什么简单方法可以模拟其行为)。甚至可以利用宏来仿真这些新语法:
#define static_cast(TYPE, EXPR) ((TYPE)(EXPR))
#define const_cast(TYPE, EXPR) ((TYPE)(EXPR))
#define reinterpret_cast(TYPE, EXPR) ((TYPE)(EXPR))
上述新语法的使用方式如下:
double result = static_cast(double, firstNumber) / secondNumber;
update(const_cast(SpecialWidget*, &sw));
funcPtrArray[0] = reinterpret_cast(FuncPtr, &doSomething);
条款 03:绝不要以多态方式处理数组
继承的最重要性质之一就是:你可以通过“指向 base class object 的 pointer 或 reference” 来操作 derived class object。如此的 pointer 和 reference,我们说其行为是多态的 —— 犹如它们有多重类型。
C++ 允许通过 base class 的 pointer 或 reference 来操作 derived class object 所形成的数组,但是这是很糟糕的尝试。
参考下面的例子:
class BST { ... };
class BalancedBST : public BST { ... };
现在考虑有个函数,用来打印 BST 数组中的每一个 BST 内容:
void printBSTArray (ostream& s, const BST array[], int numElements) {
for (int i = 0; i < numElements; i++)
s << array[i];
}
BST BSTArray[10];
...
printBSTArray(cout, BSTArray, 10); // 运行良好
将一个 BST 对象组成的数组传给此函数,没有什么问题。但是当你将一个 BalancedBST 对象所组成的数组交给 printBSTArray 函数时,你的编译器也会接受它。
array[i] 它所代表的实际上时 *(array + i),其中 array 是个指针,指向数组起始处。array 和 array + i 的距离就是 i * sizeof(数组中的对像)。因此编译器必须知道数组中对象的大小,参数 array 时被声明为 BST 的数组,所以编译器会认为数组中的每一个元素都是 BST 对象,此时 array 和 array + i 的距离就是 i * sizeof(BST)。但实际上数组中每一个元素的大小是 BalancedBST 的,由于 derived class object 通常都会比 base class object 大,所以此时编译器为 printBSTArray 函数所产生的指针算术表达式对于 BalancedBST 对象所组成的数组而言就是错误的。至于会发生什么错误,结果不可预期。
简单来说,多态和指针算数不能混用。数组对象几乎总是会设计指针的算术运算,所以数组和多态不要混用。如果你避免让一个具体类(例如 BalancedBST)继承自另一个具体类(例如 BST),你就不太能够犯“以多态方式来处理数组”的错误(见条款 33)。
条款 04:非必要不提供 default constructor
default constructor 的意思就是在没有任何外来信息的情况将对象初始化。但是又许多对象,没有外来信息,就无法执行一个完全的初始化动作,或者执行初始化操作得到一个毫无意义的对象。
缺少 default constructor 的三种限制
如果 class 缺少 default constructor,当你使用这个 class 时便会有一些限制。考虑下面的例子:
class EquipmentPiece {
public:
EquipmentPiece(int IDNumber);
...
};
由于 EquipmentPiece 缺少 default constructor,其运行可能在 3 种情况下出现问题。
第一种限制是在产生数组的时候,一般而言没有任何方法可以为数组中的对象指定 constructor 自变量,所以几乎不可能产生一个由 EquipmentPiece 对象构成的数组:
EquipmentPiece bestPieces[10]; // 错误,无法调用 EquipmentPiece 的默认构造函数
不过有三个方法可以侧面解决这个束缚。第一个方法是使用 non-heap 数组:
EquipmentPiece bestPieces[] = {
EquipmentPiece(ID1),
EquipmentPiece(ID2),
EquipmentPiece(ID3),
...
}
这种方法的缺点是:无法延伸至 heap 数组。
第二个方法是使用指针数组而非对象数组:
typedef EquipmentPiece* PEP; // PEP 是指向 EquipmentPiece 的指针
PEP bestPiece[10];
PEP *bestPieces = new PEP[10];
// 数组中的每一个指针都可以用来指向一个不同的 EquipmentPiece 对象
for (int i = 0; i < 10; i++) {
bestPiece[i] = new EquipmentPiece(ID);
}
这个方法的缺点是:第一,必须记得将此数组所指的所有对象删除,否则会出现资源泄露的问题;第二,你需要的内存总量比较大,因为需要用一些额外空间来存放指针。
第三个方法(这种方法可以比避免过度使用内存这个问题)是先为数组分配 raw memory,然后在使用 placement new(见条款 08):
// 分配足够的 raw memory
void *rawMemory = operator new[](10 * sizeof(EquipmentPiece));
// 让 bestPiece 指向这块内存,是这块内存被视为一个 EquipmentPiece 数组
EquipmentPiece *bestPiece = static_cast<EquipmentPiece*>(rawMemory)
// 使用 palcement new 构造
for (int i = 0; i < 10; i++) {
new (&bestPiece[i]) EquipmentPiece(ID);
}
placement new 方法的缺点是:第一,大部分程序员不怎么熟悉它,维护起来比较困难;第二,你需要在数组内的对象结束生命时,以手动方式调用其析构函数,最后还要调用 operator delete[] 的方式释放 raw memory:
// 逆序析构 bestPiece 中的对象
for (int i = 9; i >= 0; i--) {
bestPiece[i].~EquipmentPiece();
}
// 释放 raw memory
operator delete[](rawMemory);
class 缺少 default constructor,带来的第二个限制是:它们将不适用于 template-based container classs。对于那些 template,被实例化的目标类型必须有一个 default constructor。因为那些 template 内几乎总会产生一个以 template 类型参数作为类型的数组。
第三个限制是有关 virtual base class 的:缺少 default constructor 的 virtual base class,会要求其所有的 derived class,都必须知道并且提供 virtual base class 的构造函数所需参数。
不要添加无意义的 default constructor
添加无意义的 default constructor,也会影响 class 的效率。如果成员函数需要测试字段是否真的被初始化了(不是由默认构造函数生成的),其调用者必须为测试行为付出时间单价,并未测试代码付出空间代价。如果测试结果为否定,对应处理程序还需要一些空间代价。