条款1: pointer和reference
0 指针(pointer)
C++中的指针可以如下形式进行定义:
int number = 0;
int *ptr = &number; // 可修改number值,也可以改变指针指向。
const int *ptr0 = &number; // 不能修改number值,可改变指向
int const *ptr1 = &number; // 可修改值,不能改变指向
const int const *ptr2 = &number; // 不能修改值,也不能改变指向
1 引用(reference)
C++语言标准引入了“引用”类型。其必须在声明时给予明确定义。如下代码所示。通过修改对ref的修改,我们可以修改变量number的值——ref为number的一个替身。
int number = 0;
int &ref = number;
相应于pointer,我们也可以有如下几种定义形式。由于reference一旦定义,将无法改变其指向,因此,第二种形式非法。
const int &ref = number; // 合法。不能修改值
int const &ref0 = number; // 非法。
2 比较与综合
由于pointer可以有第二种状态,即null。因此,如果我们欲让pointer什么都不指代,即可赋值给pointer为null。但是reference必须有所指代,即必须完成指向对象的初始化工作。因此,我们得到如下规则:
1,若对象必须指代一个实体,则使用reference。
若指代的实体不能发生变化,则使用reference。
2,若可能需要变量什么都不指,出现空置状态;或者指向对象可以变化,则使用pointer。
条款2:使用C++的转型操作
C语言中只有“自动类型转换”和“强制类型转换”,两种转换都不够暗转——无法知道类型转换是否成功,或者类型转换是否恰当。
C++引入如下四个操作符进行类型转换
1, static_cast:相当于C中的自动转换,如short类型转换成int型。
2, const_cast:转换变量的可修改性。将const类型转换成non-const类型。
3, dynamic_cast:用于基类与派生类之间的类型转换。此种转换通常用于多态,将基类指针转换成派生类指针。转换失败会返回null指针,或抛出exception。该转换只有在virtual-function存在的情况下才有可能成功。
4, reinterpret_cast:常用于转换函数指针。
以下将举出类型转换的实例。
1 static_cast
截取浮点数中的整数部分:
double number = 1.234567;
int integer = static_cast<int>(number);
2 dynamic_cast
class BaseClass {};
class DerivedClass : public BaseClass {};
......
BaseClass *baseArray[10];
derived = dynamic_cast<DerivedClass*>(baseArray[0]);
3 const_cast
移除常量性(const)或者变异性(volatile):
void refresh(int &number);
const int number = 2;
refresh(const_cast<int&>(number)); // 正确
refresh(number); // 错误
4 reinterpret_cast
该类型转换不具备移植性。其常用于“函数指针”的类型转换。如下例所示。
typedef void (*FuncPtr)();
FuncPtr funcPtrArray[10];
int doSomething();
由于doSomething函数的类型FuncPtr的类型不匹配,因此只有使用reinterpret_cast才能够将该函数放入funcPtrArray数组中。如下所示。
funcPtrArray[0] = &doSomething; // 非法,类型不匹配
funcPtrArray[0] = reinterpret_cast<FuncPtr>(&doSomething); // 合法,强制类型转换成功
5 不推荐的方法
若编译器较老,没有提供类型转换操作符,可以利用强制类型转换来定义出自己的宏,但这种方式不够安全,因此不推荐使用。
#define xxxxxx_cast(TYPE, EXPRESSION) ((TYPE)(EXPRESSION))
// 比如
#define const_cast(TYPE, EXPRESSION) ((TYPE)(EXPRESSION))
6 类构造函数默认类型转换
下面代码展示了,由于Fruit类中存在一个接收int类型的构造参数,因此,int类型可以转换成Fruit类型。这种类型转换增加了类的复杂性。C++提供explicit关键字来禁止默认类型转换。
// 例1:
class Fruit {
Fruit(int number);
}
Fruit aFruit = 2; //合法,因为可以调用一个参数的构造函数。
// 例2
class Fruit0 {
explicit Fruit(int number);
}
Fruit0 aFruit0 = 2; // 非法,必须明确调用相应的构造函数
Fruit0 aFruit1(0); // 合法,明确调用构造函数
条款3:勿用多态技术处理数组
本条款内容相对过时,其中涉及到的技术问题在现代编译器中得到妥善解决。由于涉及到底层的具体细节,因此这些问题依然值得关注。
1 数组元素长度变化
下面代码定义了基类二叉树,和子类平衡二叉树。假设两种类型的树中,元素均为int。我们打算通过一个函数中序输出两棵树中的节点。
class BST {
public:
BST() {};
virtual ~BST() {}
// some function
// some variable
}
class BalanceBST : public BST {
public:
BalanceBST() {}
virtual ~BalanceBST() {}
// some function
// some varialbe
}
...
void printArray(ostream& out, const BST tree[], int num) {
for (int index = 0; index < num; ++index) {
out << tree[index];
}
}
BST tree[100];
BalanceBST baBST[100];
printArray(out, tree, 100);
printArray(out, bsBST, 100);
显然,派生类BalanceBST的大小通常和基类BST不一致,当使用派生类调用函数printArray时,使用索引访问tree[index]势必会造成错误,因为:
索引访问数组tree[index],实际是指针算术表达式的简写:*(tree+index)。每次所跨越的空间大小,是基类对象的大小。即tree[index-1]与tree[index]之间相差一个BST大小的空间。
但是,gcc 5.x编译器很好地解决了此种问题——编译器可以正确识别动态类型,并应用于数组中。
2 删除数组元素
同上面情况类似,当我们使用基类数组删除一个派生类数组的时候,只会调用基类的析构函数,从而导致派生类析构函数没有调用,产生内存泄漏的问题。如下所示。
void destruct(BST array[]) {
delete [] array;
}
BalanceBST *baBST = new BalanceBST[100];
destruct(bsBST);
目前,C++规范中表示,删除一个又基类指针指向的派生类数组,结果未定义。
不要让具有实际意义的具体类继承自另一个类,而是采用组合方式,将会更容易维护。
条款4:非必要不默认构造函数(default constructor)
在类中,不是所有成员变量都具有两种状态——有意义的值和无意义的值。一些对象总是需要一个有意义的值。因此,必须通过传入参数来控制这些成员变量。
默认构造函数没有参数传入的构造函数。当我们不声明构造函数时,编译器会自动给出默认构造函数;一旦我们计划声明一个传入参数的构造函数,那么默认构造函数必须手动声明。
不定义构造函数会出现如下问题。
1 无法定义数组
若不给出默认构造函数,下面的代码将无法执行。
class DefaultClass {
public:
explicit DefaultClass(int number) {}
}
DefaultClass *sample = new DefaultClass[10];
DefaultClass sample[10];
解决途径有两种
(1) 可以通过先声明指针数组,然后再定义对象来解决。
DefaultClass *sample[10];
for (int index = 0; index < 10; ++index) {
sample[index] = new DefaultClass(index);
}
(2)采用"placement new"技术,即先开辟一块内存空间,再对该内存空间进行初始化。
// 开辟内存空间
void *rawMemory = operator new[](sizeof(DefaultClass) * 10);
DefaultClass *sample = static_cast<DefaultClass*>(rawMemory);
// 初始化内存
for (int index = 0; index < 10; ++index) {
new (&sample[index]) DefaultClass(index);
}
多数人不太熟悉此方法。同时,当我们需要释放开辟的内存时,需要用rawMemory来释放,不能使用sample来释放。
for (int index = 9; index >= 0; --index) {
sample[index].~DefaultClass();
}
operator delete [](rawMemory);
// 下面为错误的方法
delete [] sample;
2 不能使用模板容器类
STL提供的模板容器类需要调用传入的类模板的构造函数。如下面代码,开辟空间和创建对象一次性完成,因此若没有默认构造参数,容器将无法初始化对象。
std::vector<DefaultClass> defaultVector; // 非法,没有默认构造函数,不能初始化空间。
3 需要深层次传入参数
当类继承体系过于复杂,继承层次比较深的时候,若没有默认构造函数,最底层基类所需的构造参数,可能需要最顶层的派生类来传送,从而导致接口过于庞大。
总结
原本计划每篇文章控制在千余字,稍不留神便到了4500字之多。考虑到英文每个字母都算作一个字,因此篇幅应该还算可以忍受。
本篇4个条款只是作为引子,为后面针对四方面的内容——指针、空间分配、多态、类型转换,作一个简要铺垫。后面内容将紧紧围绕这4个方面内容,作进一步详细讲述。而这4方面,也是C++种较难维护的问题。