《More Effective C++》读书笔记。

条款1:仔细区别pointers和references

  • pointer(指针): 可以为空(null),可以重新赋值,使用前需要测试其有效性。
  • refences(引用): 不可为空,必须初始化,无法重新赋值。

注意:定义一个空指针的引用会引起未定义行为的问题

当考虑“不指向任何对象”的可能性时,或“不同时间指向不同对象”的能力时,考虑使用指针;确定“总会代表某个对象”而且“一旦确定就不在改变”时,就使用引用。

一般运算符重载常使用引用。

条款2:最好使用C++转型操作符

C++引入了4个新的转型操作符:static_cast,const_cast,dynamic_cast,reinterpret_cast。使用方式是static_cast<type>(expression) 。例如,要将一个 int 型转换为 double 型的值:

1
2
int firstNumber,secondNumber;
double result = static_cast<double>(firstNumber)/secondNumber;
  • static_cast: 基本上具有和旧式类型转换相同的能力和相同的限制。例如不能把struct 转成int 或将double转成指针。

  • const_cast: 可用于切仅可用于改变表达式中的常量性(constness)或易变性(volatileness),如果用于其他用途,转型动作会被拒绝。const_cast最常见的用途是用来去掉某个对象的常量性。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget {...};
class SpecialWidget: public Widget {...};
void update(SpecialWidget *psw);
SpecialWidget sw; //sw是一个non-const对象
const SpecialWidget& csw=sw; //csw是一个sw的const引用
update(&csw); //错误!update函数要求一个const的参数
update(const_cast<SpecialWidget*>(&csw)); //正确!&csw的常量性被取出掉了。
update((SpecialWidget*)&csw); //同上,只是采用了旧式的转型语法。
Widget *pw = new SpecialWidget;
update(pw); //错误!pw的类型是Widget*,但是update函数要求SpecialWidget*。
update(const_cast<SpecialWidget*>(pw)); //错误!const_cast只能用来改变常量性或变易性,无法进行继承的向下转型(cast down)动作。

  • dynamic_cast: 用来指向继承体系中“安全的向下转型或跨系转型动作”。也就是将“指向基类对象(base class objects)的指针或引用”转型为“指向派生类对象的指针或对象”,并返回转型是否成功。如果转型失败,并以一个空指针(当转型对象是指针时),或一个异常(exception)(当转型对象是引用时)表现出来。dynamic_cast只能用于继承体系中,不能应用于缺乏虚函数的类型身上(条款24),也不能改变类型的常量性。例如:

1
2
3
Widget *pw;
...
update(dynamic_cast<SpecialWidget*>(pw));//正确!

  • reinterpret_cast: 这个操作符的转换结果总是和编译平台相关,所以reinterpret_cast不具有移植性。它最常用的用途是转换“函数指针”类型。由于其不具有移植性,所以某些情况这样的转型会导致不正确的结果,所以应该尽量避免将函数指针转型。

如果编译器没有不支持这些语法,可以使用宏来模仿这些语法:

1
2
3
#define static_cast(TYPE,EXPR) ((TYPE)(EXPR))
#define dynamic_cast(TYPE,EXPR) ((TYPE)(EXPR))
#define reinterpret_cast(TYPE,EXPR) ((TYPE)(EXPR))

条款3:绝对不要以动态(polymorphically)方式处理数组

继承(inheritance)的最重要的性质之一就是:指向基类对象的指针或引用,可以操作派生类对象。我们说这种指针和引用的行为是多态(polymorphically)的。虽然C++也允许通过基类对象的指针和引用来操作派生类的对象数组,但是程序却几乎无法如预期一样的运行。考虑如下的示例程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class BST {...};
class BalancedBST {...};
void printBSTArray(ostream& s, const BST array[], int numElements)
{
for(int i = 0; i < numElements; ++i){
s << array[i]; //假设BST对象有一个<<操作符可用
}
}
BST BSTArray[10];
...
printBSTArray(cout, BSTArray, 10);//运行良好
BanlancedBST bBSTArray[10];
...
printBSTArray(cout, bBSTArray, 10);//能正确运行吗?

这段程序在编译时是没有问题的,但是运行程序时却很可能不会得到我们预想的结果。问题出在printBSTArray 函数里面的循环中,array[i] 其实是一个指针算术表达式,代表的是*(array+i) ,array是一个指向数组起始的指针。在内存中array+i 所指向的地址和array 所指向的地址相差i*sizeof(数组中对象) ,因为它们之间有i个对象。这里编译器假定array中的对象的大小和BST对象的大小是一致的,所以第一个printBSTArray函数运行良好;但是第二个却会出现问题,一般来说,派生类的具有更多的数据成员,因此派生类对象的大小一般都要比基类对象大,而这个程序中由于函数的参数设置,编译器还是认为对象大小为BST对象的大小。这样通过指针算术表达式算出来的地址就是错误的,从而产生意想不到的结果。

简单来说,就是多态和指针运算不能混用,数组对象几乎总会涉及到指针运算,因此数组和多态不要混用。

条款4:避免冗余的 default constructor

本条款的英文原文为:Avoid gratuitous default constructor

default constructor是指在没有任何外来信息的情况将对象初始化。有的类不需要外部信息即可完成初始化,例如数字可以默认的初始化为0或者无意义的值,指针可以初始化为null,但是有的类却必须要由外部的信息参与才可以完成初始化,对这种情况,如果没有default constructor将会在三种情况出问题:

  • 产生数组的时候;当产生类的对象数组的时候,将会由于无法调用该类的构造函数而出错。
  • 无法适用于基于模板的容器类(template-based container classes)。对模板而言,被实例化的“目标类型”必须有一个default constructors,因为模板内几乎总是会产生一个以“模板类型参数” 作为类型二架构起来的数组。
  • 虚基类如果缺少default constructor,与之合作将会很痛苦。因为虚基类的构造函数的参数必须有派生层次最深的类提供,这就导致一个缺乏default constructor的虚基类的所有派生类都必须知道这个基类构造函数参数的意义,并提供这个参数。

由于缺乏default constructor 有这些缺点,所有就有人认为都应该为类提供一个default constructor。但是这样做几乎总是使得类的成员函数变得复杂。同时添加无意义的default constructor 会影响类的效率。

条款5:对定制的“类型转换函数”保存警觉

c++运行不同类型之间进行隐式类型转换。当自己实现某个类的时候可以选择是否提供这类类型转换函数来供编译器调用,有两种函数运行执行这样的转换:

  • 单自变量构造函数: 指只有一个参数的构造函数或有多个参数,但只有一个参数没有默认值的构造函数。

  • 隐式类型转换操作符: 一般形式为:operator 类型名() ,例如如果要让rational对象能隐式的转换为double,那么rational类的定义可能如下:

1
2
3
4
5
class rational {
public:
...
operator double() const;
}

但是,在实际写程序中,最好不要提供任何类型转换函数,因为可能在没打算也没预期的情况下,这类函数会被调用,其结果可能是不正确、不直观的程序行为,难于调试。

explicit 关键字

虽然可以不声明隐式类型操作符,但是单自变量的构造函数却不太好避免,于是C++有了一个新特性explicit来解决隐式类型转换带来的问题,它的用法很简单,只需要包单自变量的构造函数声明为explicit,编译器就不会因为隐式类型转换而调用他们,但是同时,显示的类型转换并不受影响。

1
2
3
4
5
6
template<class T>
class Array {
...
explicit Array(int size);
...
};

条款6:区别自增、自减操作符的前置和后置形式

C++中自增(++)或自减(—)操作符具有重载能力,但是一般的函数函数重载都是利用参数来区分彼此的,但是无论是自增还是自减,都没有参数,这就导致自增、自减操作符的前置和后置无法区分,为了填补这个问题,于是就让后置式有一个int型变量,并且在它被调用的时候编译器给这个int一个0值:

1
2
3
4
5
6
7
8
class UPInt {
public:
UPInt& operator++(); //前置式
const UPInt operator++(); //后置式
};
UPInt i;
i++; // 调用i.operator++(0);
++i; // 调用i.operator++();

前置式返回一个引用,后置式返回一个const对象。在C语言时代,自增的前置式是“increment and fetch”(累加然后取出)后置式是“fetch and increment”(取出然后累加)

1
2
3
4
5
6
7
8
9
10
11
12
13
// 前置式:累加然后取出
UPInt & UPInt::operator++()
{
*this += 1;
return *this;
}
// 后置式:取出然后累加
const UPInt UPInt::operator++(int)
{
UPInt oldValue = *this;
++(*this);
return oldValue;
}

后置式之所以要返回一个const类型的对象,是因为类似i++++的操作是非常不受欢迎的:

  1. 它和内建类型的行为不一致。
  2. 其行为结果不是预期的结果,第二个operator++所改变的对象是第一个operator++所返回的对象,而不是原对象,也就是说只被累加了一次而已,这是违反直觉的,也容易引起混淆。

由以上的前置式和后置式实现代码可知,由于后置式产生了一个临时变量,这个过程就需要构造以及析构这个对象,所以前置式的效率更高

条款7:千万不要重载&&||, 操作符

C++中对于真假值表达式采用从左向右“骤死式”判定方式,也就是从左向右开始判断该表达式的值,如果一旦该表达式的真假值确定,剩下的部分就算没有检验也停止评估。

1
2
3
char *p;
...
if (p != 0) && (strlen(p) > 10)...

无需担心在调用strlen()p是否为null,因为如果pnullstrlen()就不会被调用。很多程序的正确运行依赖于这种“骤死式”的判断方式,而当重载&&|| 之后,再进行类似操作之后就会由“函数调用”取代“骤死式”的语义,它们之间有两个重大区别:

  • 当函数调用的语义被执行是,所有的评估都必须完成。
  • c++标准中没有规定函数调用过程中那个部分先进行评估,而“骤死式”语义则是从左向右的顺序。

如果重载了&&,上面的例子里面如果p为null,程序的运行会出现问题,因为对null指针调用strlen(),结果不可预期。

逗号,操作符的情况类似,例如在for循环中:

1
2
3
4
5
6
for(int i = 0,j = strlen(x)-1; i < j; ++i,--j)
{
int c = s[i];
s[i] = s[j];
s[j] = c;
}

如果表达式中有逗号,那么逗号左边会被先评估,然后逗号的右边再被评估,最后整个逗号表达式的结果以逗号右边的只为代表,上面的例子中,++i 会先计算,然后计算 --j,而整个逗号表达式的结果是--j的返回值。如果重载逗号表达式,将无法保证逗号左边的式子会被先计算,因此应尽量避免重载逗号表达式。

条款8:了解各种意义newdelete

new operatoroperaor newoperator new[]placement new

  • new operator 的声明通常为: string *ps = new string("Memory Management"); 的是new operator,这是由语言内建的,不能被改变意义;同时它调用一个构造函数,为刚才分配的内存中的那个对象设定初值。

  • operator new 则是一个函数,可以被重载或重写,通常声明为:void * operator new(size_t size); ,其返回值是一个空指针,指向一块没有初值的内存。operator new 只负责分配内存

  • operator new[]operator new 的数组版,是在用 new operator 构建对象数组的时候自动调用的。

  • placement new:的使用需要包含头文件 #include<new>,它是用于在已经分配好的原始内存上构建对象时使用,例如:

1
2
3
4
5
6
7
8
9
class widget{
public:
widget(int widgetSize);
...
};
widget * constructWidgetInBuffer(void * buffer, int widgetSize)
{
return new(buffer) widget(widgetSize);
}

事实上这就是一个接受两个参数的 operator new

1
2
3
4
void * operator new(size_t,void *location)
{
return location;
}

总结下来就是:要在堆内存上构建对象,可以直接使用new operator,如果只需要分配内存就只需要使用operator new,如果需要在堆内存构建对象时自己决定内存分配方式,那么就需要重载一个operator new;要在已经分配好的内存上构建对象,就需要使用placement new。

为了防止内存泄漏,需要一个与 new 想对的释放操作 delete ,它也有 operator deletedelete operator ,它们的行为和operator newnew operator 相似。

条款9:利用析构函数避免资源泄漏