More Effective C++之基础主题Basics
- 条款1:仔细区别pointers和references
- 条款2:最好使用C++转型操作符
- 条款3:绝对不要以多态(polymorphically)方式处理数组
- 条款4:非必要不提供default constructor
基础议题,包括pointers, references, casts, arrays,constructors,这一章描述pointer和reference的差异,并告诉你它们的适当使用时机。本章介绍新的C++转型(casts)语法,并解释为什么新式转型法比旧式的C转型法优越。本章也检验了C的数组概念即C++的多态(polymorphism)概念,并说明为什么将这两者混用是不明智之举。最后,本章讨论default constructors(默认构造函数)的正方和反方意见,并提供一些建议做法,让你回避语言的束缚(因为在你不需要default constructors的情况下,C++也会给你一个)。
只要留心下面各条款的各项终稿,你将向着一个很好的目标迈进:你所编写的软件可以清楚而正确地表现出你的设计意图。
学习书籍1;
条款1:仔细区别pointers和references
Pointers和references看起来很不一样,但他们似乎做类似的事情。不论pointers或是references都是间接参考其他对象。那么,它们之间如何选择呢。
首先我们应该清楚,没有所谓的null reference。一个reference必须总代表某个对象。所以有一个对象,其目的是用来指向(代表)另一个对象,但是也有可能它不指向任何对象,那么我们应该使用pointer,因为我们可以将pointer设为null。换个角度看,如果这个变量总是必须代表一个对象,也就是如果我们的设计并不允许这个变量为null,那么我们应该使用reference。
来看一个null reference的例子:
char* pc = 0; // 将pointer设置为null。
cahr & rc = *pc; // 让reference代表null pointer的解引用
上述行为是有害的,其结果不可预期(C++对此没有定义),编译器可以产生任何可能的输出,从现在起,我们永远将不再考虑“reference成为null”的可能。
由于reference一定得代表某个对象,C++因此要求reference必须有初值:
string &rs; // 错误!reference必须初始化
string s;
string &rs = s; // 没问题,rs指向s
但是pointer没有这个限制
string *ps
没有所谓null reference这个事实意味着使用reference可能会比使用pointers更富效率。这是因为使用reference之前不需要测试其有效性:
void printDouble(const double& rd) {cout << rd; // 不需要测试rd,它一定代表某个double
}
如果使用pointer,通常就得测试它是否为null:
void printDouble(const double *pd) {if (pd) // 检查是否为null pointer。cout << *pd;
}
Pointers和references之间的另一个重要差异就是,pointers可以被重新赋值,指向另一个对象,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。前一种情况可以将pointer设为null,后一种情况可以改变pointer所指对象。而当我们确定“总是会代表某个固定的对象”,而且“一旦代表了该对象就不能够再改变”,那么我们应该选用reference。
还有其他情况也需要使用reference,例如当我们实现某些操作符的时候。最常见的例子就是operator[]。这个操作符很特别地必须返回某种“能够当做assignment赋值对象”的东西:
vector v(10); // 产生一个int vector,大小为10
v[5] = 10;
如果operator[]返回pointer,上述最后一个语句就必须写成这样:
*v[5] = 10;
这使v看起来好像是个以指针形成的vector,事实上它不是。为了这个因素,我们总是另operator[]返回一个reference。条款30有一个例外(待补充)。
因此:当我们知道我们需要指向某个东西,而且绝不会改变所指向其他东西,或是当你实现一个操作符而其语法需求无法由pointers达成,我们就应该选择reference。任何其他时候,请采用pointers。
条款2:最好使用C++转型操作符
想想低阶转型动作。它几乎像goto一样被视为程序设计上的“贱民”。尽管如此,它却能苟延残喘,因为当某种情况愈来愈糟,转型可能是必要的。
不过,旧式的C转型方式并非唯一选择。它几乎允许将任何类型转换为任何其他类型,这是十分拙劣的。如果么次转型都能够更精确地指明意图,则更好。举个例子,将一个pointer-const-object转型为一个pointer-to-non-const-object(也就是说只改变了对象的常量属性),和将一个pointer-to-base-class-object转换为一个pointer-to-derived-class-object(也就是完全改变了一个对象的类型),其间有很大的差异。传统的C转型动作对此并无区分。
旧式转型的地热个问题是它们难以辨识。旧式转型的语法结构是由一对小括号加上一对对象名称(标识符)组成,而小括号和对象名称在C++的任何地方都有可能被使用。因此,我们简直无法回答最基本的转型相关问题“这个程序中有任何转型动作吗?”。因为人们很可能对转型动作视而不见,而诸如grep之类的工具又无法区分语法上极类似的一些非转型写法。
为解决C旧式转型的缺点,C++导入4个新的转型操作符(cast operators):static_cast,const_cast,dynamic_cast和reinterpret_cast。对大部分使用目的而言,面对这些操作符我们唯一需要知道的是,过去习惯的写码形式:
(type) expression
现在应该改为:
static_cast<type>(expression)
举个例子,假设我们想将一个int转换为一个double,以强迫一个整数表达式导出一个浮点数值来。采用C旧式转型,可以这么做:
int firstNumber, secondNumber;
...
double result = ((double)firstNumber)/secondNumber;
如果采用新的C++转型法,应该这么写:
double = static_cast<double>(firstNumber) / secondNumber;
这种形式十分容易被辨识出来,不论是对人类或是对工具程序而言。
static_cast基本上拥有与C旧式转型相同的威力与意义,以及相同的限制。例如,我们不能够利用static_cast将一个struct转型为int,或将一个double转型为pointer;这些都是C旧式转型动作原本就不可以完成的任务。static_cast甚至不能够移除表达式的常量属性(cosntness),因为有一个新式转型操作符const_cast专司其职。
其他新式C++转型操作符使用于更集中(范围更狭窄)的目的。const_cast用来改变表达式中的常量性(constness)或易变性(volatileness)。使用const_cast,便是对人类(以及编译器)强调,通过这个转型操作符,我们唯一打算改变的是某物的常量性和易变性。这项意愿将由编译器贯彻执行。如果我们将const_cast应用于上述以外的用途,那么转型动作会被拒绝。下面是一个例子。
class Widget { ... };
class SpecialWidget : public Widget { ... };
void update(SpecialWidget *psw);
SpecialWidget sw; // sw 是一个no-const对象
const SpecialWidget &csw = sw; // csw 代表sw的reference,并视为const对象
update(&csw); // 编译报错,不能将const SpecialWidget* 传给SpecialWidget*update(const_cast<SpecialWidget*>&csw); // 将&csw的const属性去除后,可编译通过update((SpecialWidget*)&csw); // 完成了const_cast的效果,但是辨识度较低Widget *pw = new SpecialWidget;
update(pw); // 编译报错,参数类型Widget*与SpecialWidget*不匹配update(const_cast<SpecialWidget*> pw); // 编译报错,const_cast只能影响常量性与易变性,无法进行继承体系的向下转型
显然,cosnt_cast最常见的用途就是将某个对象的常量性去除掉。
第二个特殊化的转型操作是dynamic_cast,用来执行集成体系中的“安全的向下转型或跨系转型动作”。也就是说我们可以利用dynamic_cast,将“指向base的pointer或reference”转型为“指向derived(或sibling base)class objects的pointers或references”,并得知转型是否成功。如果转型失败,会以一个null指针或一个excpetion(当转型对象是reference)表现出来:
Widget *pw;
...
update(dynamic_cast<SpecialWidget*>(pw));
void updateViaRef(SpecialWidget& rsw);
updateViaRef(dynamic_cast<SpecialWidget&>(*pw));
dynamic_cast只能用于协助我们巡航于集成体系之中。它无法应用在缺乏虚函数的类型身上,也不能改变类型的常量性(constness):
int firstNumber, secondNumber;
...
double result = dynamaic_cast<double>(firstNumber)/secondNumber; // 错误!未涉及继承机制
const SpecialWidget sw;
...
update(dynamic_cast<SpecialWidget*>(&sw); // 错误!dynamic不能改变常量性
如果我们想为一个不涉及继承机制的类型执行转型动作,可使用static_cast;要改变常量性(constness);则必须使用const_cast。
最后一个转型操作符是reinterpret_cast。这个操作符的转型结果几乎总是与编译平台息息相关。所以reinterpret_cast不具移植性。
reinterpret _cast的最常用用途是转型“函数指针”类型。假设有一个数组,存储的都是函数指针,有特定的类型:
typedef void (*FuncPtr)();
FuncPtr funcPtrArray[10];
假设由于某种原因,我们希望以下函数的指针放进funcPtrArray中:
int doSomething();
如果没有转型,不可能办到这一点,因为doSomething的类型与funcPtrArray所能接受的不同。funcPtrArray内各函数指针所指函数的返回值是void,但doSomething的返回值是int:
funcPtrArray[0] = &doSomething; // 错误!类型不符
使用reinterpret_cast,可以强迫编译器了解我们的意图。
funcPtrArray[0] = reinterpret_cast<FuncPtr>(&doSomething);
函数指针的转型动作,并不具移植性(C++不保证所有的函数指针都能以此方式重新呈现),某些情况下这样的转型可能会导致不正确的结果,所以我们应该尽量避免将函数指针转型,除非已经走投无路;
如果我们的编译器尚未支持这些新式的转型动作,我们可以使用传统转型方式取代static_cast,cosnt_cast和reinterpret_cast。甚至可以利用宏(macros)来仿真这些新语法。
#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, &doSomethine));
这些近似的语法当然不像其本尊那么安全,但如果我们现在就在私用它们,一旦新的编译器开始支持新式转型,程序升级过程便可简化。
至于dynamic_cast,没有什么简单方法可以模拟其行为,不过许多程序库提供了一些函数,用来执行继承体系下的安全转型动作。如果我们手上没有这些函数,而却必须执行这类转型,我们也可以回头使用旧式的C转型语法,但它们不可能告知是否转型成功。当然,我们也可以定义一个宏,看起来像dynamic_cast,就像为其他转型操作符所做的那样:
#difine dynamic_cast(TYPE, EXPR) ((TYPE) (EXPR))
这个近似法并非执行真正的dynamic_cast,所以它无法告知转型是否成功。
尽管这些新式转型操作符看起来又臭又长,但是提供严谨的意义与辨识度,编译器也因此得以诊断转型错误(那是旧式转型法侦测不到的)。因此,强烈大家放弃旧式转型法,加入新式转型操作符的使用者行列中来。
条款3:绝对不要以多态(polymorphically)方式处理数组
继承(inheritance)的重要特性之一就是:你可以通过“指向base class objects”的pointers或references,来操作derived class objects。如此的pointers和references,我们说其行为是多态的(polymorphically)–犹如它们有多重类型似的。C++也允许我们通过base class的pointers和references来操作“derived class objects所形成的数组”。但这一点也不值得沾沾自喜,因它几乎绝不会如我们所预期般地运行。
举个例子,假设我们又要给class BST(意思是binary search tree)及一个继承自BST的class BalancedBST:
class BST { … };
class BalancedBST: public BST { … };
在一个真正具规模的程序中,这样的classes可能会被设计为templates,不过这不是此处重点;针对目前的讨论,我们假设BST和BalancedBST都只内含ints.
现在考虑有个函数,用来打印BSTs数组中的每一个BST的内容:
void printBSTArray(ostream& s, const BST array[], int numElements)
{for (int i = 0; i < numElements; ++i) {s << array[i]; // 假设BST objects有一个operator << 可用。}
}
当我们将一个由BST对象组成的数组传给此函数,没有问题:
BST BSTArray[10];
...
printBSTArray(cout, BSTArray, 10); // 运行良好。
然后如果我们将一个BlancedBST对象所组成的数组交给printBSTArray函数会发生什么事:
BalancedBST bBSTArray[10];
...
printBSTArray(cout, bBSTArray, 10); // 可正常编译
编译器可以正常通过,我们再看看循环:
for (int i = 0; i < numElements; ++i) {s << array[i];
}
array[i]其实是一个“指针算术表达式”的简写:它代表的起始是*(array+i)。我们知道,array是个指针,指向数组起始处。array所指内存和array+i所指内存相距多远?答案是i*sizeof(数组中的对象),因为array[0]和array[i]之间有i个对象。为了让编译器所产生的代码能够正确走整个数组,编译器必须有能力决定数组中的对象大小。很明显,参数array声明为“类型为BST”的数组,所以数组中的每个元素必然都是BST对象,所以array和array+i之间的距离一定是i*sizeof(BST)。
至少编译器是怎么认为的,但是如果我们交给printBSTArray函数一个由BalancedBST对象组成的数组,编译器就会被误导。这种情况下它仍假设数组中的每一个元素的大小是BST的大小,但其实每一个元素的大小是BalancedBST的大小。由于derived class通常比其base classes有更多的data members,所以derived class objects通常比其base class objects来得大。因此,我们可以合理地预期一个BalancedBSt
object比一个BST object大。如果是这样,编译器为printBSTArray函数所产生的指针算术表达式,对于BalancedBST objects所组成的数组而言就是错误的。至于会发生什么结果,不可预期。无论如何,结果不会令人愉快。
如果我们尝试通过一个base class指针,删除一个由derived class objects组成的数组,那么上述问题还会以另一种不同面貌出现。下面是我们可能做出的错误尝试:
// 删除一个数组,但是首先记录一个有关删除动作的消息
void deleteArray(ostream& logStream, BST array[]) {logStream << "Deleting array at address" << static_cast<void*> array << '\n';delete [] array;
}
Banalced BST *balTreeArray = new BalancedBST[50];
...
deleteArray(cout, balTreeArray);
虽然没有显示调用,但其中一样有“指针算术表达式”的存在。数组被删除,数组中的每一个元素的destructor都必须被调用。所以当编译器看到这样的句子:
delete [] array
将会产生出类似这样的代码:
// 将*array中的对象以其构造顺序的相反顺序加以析构
for(int i = numElements - 1; i>=0; --i) {array[i].BST::~BST(); // 调用array[i]的destructor
}
// 将*array中的对象以其构造顺序的相反顺序加以析构如果你这么写,便是一个行为错误的循环。编译器如果产生类似代码,当然同样是个行为错误的循环。C++语言规范中说,通过base class指针删除一个由derived class objects构成的数组,其结果未定义。我们知道所谓“未定义”的意思就是:执行之后会产生苦恼。简单地说,多态(polymorphism)和指针算术不能混用。数组对象几乎总是会涉及指针的算数运算,所以数组和多态不要混用。
条款4:非必要不提供default constructor
所谓default constructor(也就是说不给任何自变量就可调用者)是C++一种“无中生有”的方式。Constructors用来将对象初始化,所以default constructor的意思是在没有任何外来信息的情况下将对象初始化。有时候可以想象,例如,数值之类的对象,可以被合理地初始化为0或一个无意义值。其他诸如指针之类的对象亦可被合理地初始化为null或无意义的值。数据结构如linked lists,hash tables,maps等,可被初始化为空容器。
但是并非所有对象都落入这样的分类。有许多对象,如果没有外来信息,就没办法执行一个完全的初始化动作。例如,一个用来表现通信簿字段的class,如果没有获得外界指定的人名,产生出来的对象将毫无意义;在某些公司,所有仪器设备都必须贴上一个识别号码。为这种用途(用以模拟出仪器设备)而产生的对象,如果其中没有供应适当的ID号码,将毫无意义。
在一个完美的世界中,凡可以“合理地从无到有生成对象”的classes,都应该内含default constructors,而“必须有某些外来信息才能生成对象”的classes,则不必拥有default constructors。但我们的世界毕竟不是完美的世界,所以我们必须纳入其他考虑。更明确地说,如果class缺乏一个default constructor,当我们使用这个class时便会有某些限制。
考虑下面这个针对公司仪器设计的class,在其中,仪器识别码是一定得有的一个constructor自变量:
class EquipmentPiece {
public:EquipmentPiece(int IDNumber);...
};
由于EquipmentPiece缺乏default constructor,其运行可能在3种情况下出现问题。第一个情况是在产生数组的时候。一般而言没有任何方法可以为数组中的对象指定constructor自变量,所以几乎不可能产生一个由EquipmentPiece objects构成的数组:
EquipmentPiece bestPieces[10]; // 错误!无法调用EquipmentPiece ctors
EquipmentPiece *bestPieces = new EquipentPiece[10]; // 错误!对象数组仍然无法初始化
有3个办法可以侧面解决这个束缚。第一个方法是使用non-heap数组,于是便能够在定义数组时提供必要的变量:
int ID1, ID2, ID3, ..., ID10;
...
EquipmentPiece bestPieces[] = {EquipmentPiece(ID1),EquipmentPiece(ID2),EquipmentPiece(ID3),...EquipmentPiece(ID10),
};
不幸的是此法无法延伸至heap数组。
更一般化的做法是使用“指针数组”而非“对象数组”:
typedef EquipmentPiece* PEP;
PEP bestpieces[10];
PEP *bestpieces = new PEP[10];
数组中的各指针可用来指向一个个不同的EquipmentPiece object:
for (int i = 0; i< 10; ++i) bestpieces = new EquipmentPiece(ID Number);
此法有两个缺点,第一,必须记得将此数组的所有对象删除。如果忘记了,就会出现resource leak问题;第二,需要的内存总量比较大。因为我们需要一些空间来放置指针,还需要一些空间来放置EquipmentPiece obects。
“过度使用内存”这个问题可以避免,方法是先为此数组分配raw memory,然后使用“placement new”在这块内存上构造EquipmentPiece objects。
void *rawMemeory = operator new [] (10 * sizeof(EquipmentPiece));
EquipmentPiece *bestPieces = satic_cast<EquipmentPiece*> rawMemory;
// 利用placement new构造EquipmentPiece objects
for (int i = 0; i < 10; ++i)new(bestPieces[i]) EquipmentPiece(ID Number);
注意,我们还是必须供给constructor一个自变量,作为每个EquipmentPiece objects的初值。这项技术(以及“由指针构成数组”的主意)允许我们在“缺乏default constructor”的情况下仍能产生对象数组;但并不意味你可以因此回避供给constructor自变量。
place new的缺点是,大部分程序员不怎么熟悉它,维护起来比较困难。此外,我们还得在数组内的对象结束生命时,以手动方式调用其destructors,最后还得以调用operator[]的方式释放raw memory:
for (i = 9; i >=0; --i) bestPieces[i].~EquipmentPiece();
operator delete[] (rawMemory);
如果我们对rawMemory采用一般的数组删除语法,程序运行将不可预期。因为删除一个不以new operator获得的指针,其结果没有定义
delete [] bestPieces; // 没有定义!因为bestPieces并非来自new operaotr。
关于new operator和placement new,以及它们如何与constructors和destructors互动,下章进行说明。
Classes如果缺乏default constructors,带来的第二个缺点是:它们将不使用于许多templa-based container classes。对那些templates而言,被实例化的“目标类型”必须得有一个default constructors。这是一个普遍的共同需求。因此在那些templates内几乎总是会产生一个以“template类型参数”作为类型而架构起来的数组。例如,一个为Array class而写的template可能看起来像这样:
template<class T>
class Array {
public:Array(int size);...
private:T *data;
};
template<T>
Array<T>::Array(int size) {data = new T[size];...
}
大部分情况下,如果谨慎设计template,可以消除对default constructor的需求。例如,标准的vector template(会产生出行为类似“可扩展数组”的各种classes),就不要求其类型参数拥有一个default constructor。不幸的是许多templates的设计什么都有,独缺谨慎。因此缺乏default constructors的classes将不兼容于许多(不够严谨的)template。当C++程序员学到更多的template设计技术与观念后,这个问题的重要性应该会降低。
到底“要还是不要”提供一个default constructor呢?就像哈姆雷特的难题一样,to be or not to be?在进退维谷的情况下,最后一个考虑点和virtual base classes有关。Virtual base class如果缺乏default constructors,与之合作将是一种惩罚。因为virtual base class constructors的自变量必须由欲产生的对象的派生层次最深(所谓most derived)的class提供。于是,一个缺乏default constructor的virtual base class,要求其所有的derived classes–不论距离多远–都必须知道、了解其意义,并且提供virtual base class的constrotors自变量。derived classes的设计者既不期望也不欣赏这样的要求。
由于“缺乏default constructors”带来诸多束缚,有些人便认为所欲classes都应该提供default constructors–甚至即使其default constructor没有足够信息将对象完整的初始化。依照这样的哲学,EquipmentPiece可能会被修改如下:
class EquipmentPiece {
public:EquipmentPiece(int IDNumber = UNSPECIFIED);...
private:static const int UNSPECIFIED; // 一个无意义的魔术数字
};```这就允许EquipmentPiece objects被这样产生出来:```
EquipmentPiece e;
这几乎总是会造成class内的其他member functions变得复杂,因为这便不再保证一个EquipmentPiece object的所有字段都是富有意义的初值。如果“一个无ID值的EquipmentPiece object”竟然可以生存的,大部分member functions便必须检查ID是否存在,否则会出现大问题。通常,这部分的实现策略并不明朗,许多编译器选择的解决办法是:什么都不做,仅提供便利性,它们抛出一个exception,或是调用某函数将程序结束掉。这样的事情一旦发生,我们是在很难认为软件的整体质量会因为“一个不需要的default constructor的class画蛇添足地加上一个default constructor”而获得提升。
添加无意义的default constructor,也会影响classes的效率。如果member functions必须测试字段是否真被初始化了,其调用者便必须为测试行为付出时间代价,并为测试代码付出空间代价,因为可执行未见和程序库变大了。万一测试结果为否定,对应的处理程序又需要一些空间代价。如果class constructors可以确保constructors无法提供这种保证,那么最好避免让default constructors出现。虽然者可能会对classes的使用方式带来某种限制,但同时也带来一种保证:当我们真的使用了这样的class,我们可以预期它们所产生的对象会被完全初始化,实现上亦富有效率。
此条款为我们在是否提供default constructor上提供了更多的考量。
1: More Effective C++: 35个改善编程与设计的有效方法/(美)梅耶(Meyers’s.)著;侯捷译.北京:电子工业出版社,2011.1 ↩︎