基础议题(basics)
条款1:仔细区别pointers和references
- 使用引用,可以不做null判断
- 当需要考虑“不指向任何对象”的可能性时,或是考虑“在不同时间指向不同对象”的能力时,你就应该采用pointer,前一种情况可以将pointer设为null,后一种情况可以改变pointer所指对象。
- 当确定“总是会代表某个对象”,而且“一旦代表了该对象就不能够再改变”,那么应该选用reference。
- 总是令operator[]返回一个reference。
条款2:最好使用C++转型操作符
- 需要使用类型转换时,先考虑能不能用static_cast
- 不能用static_cast的情况有:
- 移除表达式的常量性(constness)或变易性(volatileness) [用const_cast]
- 继承体系的转型 [用dynamic_cast]
- const_cast无法进行继承体系的向下转型(cast down)
- dynamic_cast无法用在缺乏虚函数的类型身上,也不能改变常量性
- reinterpret_cast不具移植性,是平台相关的
条款3:绝对不要以多态(polymorphically)方式处理数组
1
2
3
4
5
6
7
8
9
10
void printBSTArray(ostream& s, const BST array[], int numElements)
{
for(int i =0; i < numElements;i++){
s<<array[i];
}
}
BST BSTArray[10];
BalanceBST bBSTArray[10];
printBSTArray(cout, BSTArray,10);//OK
printBSTArray(cout, bBSTArray,10);//Not OK
array[i]其实是一个“指针算术表达式”的简写;它代表的其实是* (array+i)。array所指内存和array+i所指内存的距离是i*sizeof(数组中的单个元素)。而因为printBSTArray中,声明了array的元素的类型为BST,所以距离是i*sizeof(BST)。但当传入的是BalanceBST的数组时,就会出错了。
在删除数组时,也有这个问题,C++语言规范中说,通过base class指针删除一个由derived classes objects构成的数组,其结果未定义。
总的一句话:多态和指针算术不能混用,数组对象几乎总是会涉及指针的算法运算,所以数组和多态不要混用。
条款4:非必要不提供 default constructor
- 有default constructor时,可以避免3个问题,一是类数组的初始化不支持带参数的构造函数,二是一些c++模板库,要求被实例化的目标类型必须要有default constructor,三是类的虚继承体系中,如果基类没有default constructor,那么每一层的子类都必须了解基类的构造函数。
- 反过来看,使用default constructor时,可能会增加了类的复杂度,因为不能保证每个字段都有意义(default constructor导致赋予字段一个缺省值,这个缺省值可能是多余的)。并且,使用这些字段的调用者,都需要做一个“测试”,测试字段是否真的被初始化了。
操作符(operators)
条款5:对定制的“类型转换函数”保持警觉
- 用一个普通函数来替代类型转换操作符。因为这种操作符重载是"隐式(implicit)"的:
1
2
3
4
5
class Rational{
public:
operator double() const; //not good
double asDouble() const; //good
}
- 单自变量构造函数,前面要加一个explicit声明。
- 用proxy classes 技术时,可以使用隐式类型转换,因为不能连续执行多个类型转换行为(详情见条款30)。
条款6:区别 increment/decrement操作符的前置和后置形式
- 后置式应以其前置式为基础(即后置式的函数体的累加实现,是调用了前置式)。
- 后置式的返回类型必须加const,从而禁止obj++++这种写法。
- 后置式的函数声明的参数要带一个毫无意义的int,只是用来和前置式做区分。
- 虽然禁止obj+++++,但是++++obj是允许的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class UPInt{
int m_num;
public:
UPInt(int num):m_num(num){};
UPInt& operator++(){
m_num++;
return *this;
};
const UPInt operator++(int){
UPInt oldValue = *this;
++(*this);
return oldValue;
};
int get(){
return m_num;
}
};
条款7:千万不要重载&&、||和, 操作符
直接贴上我写的一个代码片段:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <string>
using namespace std;
class Boolean{
string m_name;
int m_bool;
public:
Boolean(string name, bool b):m_name(name), m_bool(b){};
bool operator &&(bool b){
if((bool)(*this) && b){
return true;
}
return false;
};
operator bool(){
cout<<"get:"<<m_name<<endl;
return m_bool;
}
};
int main(int argc, char** argv) {
Boolean a("a", true);
Boolean b("b", false);
bool result = a && b;
cout<<"a && b = "<<result<<endl;
return 0;
}
运行结果为:
1
2
3
get:b
get:a
a && b = 0
a && b这个表达式,b先求值了,这不反人类么。
所以:
- && || , 被重载后,“函数调用语义” 会取代 “骡死式语义”。前者的求值顺序是未定义的(C++规范里没有定义),后者必然是从左至右。
条款8:了解各种不同意义的new和delete
关于new
- new operator是指:
1
string * ps = new string("Hello");
这样子的代码里的new。这个操作符是由语言内建的,就像sizeof一样,不能被改变意义,总是做相同的事情。它的动作分为2个方面,第一,它分配足够的内存,用来放置某类型的对象;第二,它调用一个constructor,为刚才分配的内存中的那个对象设定初值。new operator总是做这两件事,你无法改变其行为。
- operator new的一般用法,用来分配且仅分配内存(不调用构造函数):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
using namespace std;
class Widget{
char member[2] = {'a','\0'};
public:
Widget(){};
void print(){
cout<<"member="<<member<<endl;
}
};
int main(int argc, char** argv) {
void * rawMemory = operator new(sizeof(Widget));
cout<<sizeof(rawMemory)<<","<<rawMemory<<endl;
Widget * widget = (Widget*) rawMemory;
widget->print();
return 0;
}
输出:
1
2
8,0x306850
member=Pm0
- placement new 用法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
using namespace std;
class Widget{
char member[2] = {'a','\0'};
public:
Widget(){};
};
Widget* constructWidgetInBuf(void * buffer){
return new(buffer) Widget();
}
int main(int argc, char** argv) {
char buf[sizeof(Widget)];
Widget * w = constructWidgetInBuf(buf);
cout<<sizeof(buf)<<","<<buf;
return 0;
}
输出:
1
2,a
- 使用策略:如果你希望将对象产生于heap内存,请使用new operator。它不但分配内存而且为该对象调用一个constructor。如果你只是打算分配内存,请调用operator new,那就没有任何constructor会被调用。如果你打算在heap objects产生时自己决定内存分配方式,请写一个自己的operator new(重载),并使用new operator,它将会自动调用你所写的operator new。如果你打算在已分配的内存中构造对象,请使用placement new。
关于delete
- 类似new,delete也分为 delete operator 和 operator delete。
- 当成对使用operator new、operator delete时,相当于C的malloc和free。
关于数组
- array new指的是:
1
string *ps = new string[10];
这样子的代码里的new operator,由于诞生的是数组,所以内存是由一个兄弟函数:operator new[]负责分配。
array new 不仅要分配多个对象的内存(operator new[]),且要对每一个对象的内存调用一次default constructor。
array delete(即 operator delete[]),类似array new,不过它是先调用每个对象的dtor,再调用 operator delete[]以释放内存。
异常
大概读了一遍这些条款,最后作者的结论是,能不使用异常就不使用异常,这是因为使用异常会有附加的性能开销。但是具体的影响值不明朗,和具体的编译器有关系。
条款9:利用destructors避免泄露资源
问题:C++函数调用过程中,如果出现异常,就会导致这个函数的后续代码不被执行。想一想,如果函数开头new了一个对象,在函数末尾会delete这个对象,而异常出现在中间,那么这个对象就不会被析构,于是内存发生泄露。
解决之道就是,用智能指针来管理堆对象,因为智能指针是局部变量,当异常发生时,局部变量是会正常析构的,而,智能指针所绑定的heap对象,会在智能指针析构时也顺带析构。那么这个问题就算是解决了。(真的吗?)
想一下,如果是在智能指针绑定那个对象(取得资源)过程中发生异常,或者是在资源被析构时发生异常,确定不会出问题吗?
很不幸,确实问题并没有完美解决。
条款10:在constructors内阻止资源泄露(resource leak)
首先要知道一个事实:C++不自动清理那些“构造期间抛出异常”的对象。
所以,构造函数的编写要小心,要确保构造过程中抛出异常时,仍能对已初始化的变量进行回收处理,避免泄露。
解决方法:使用trycatch,确保构造函数所有可能被抛出的异常都能够捕获到,在catch里释放资源,并,再把异常往上继续抛出。
这个事情实际上并不简单。比如,如果构造函数使用了初始化列表,而初始化列表代码中使用了new,这个new就没法写trycatch了。如果初始化列表有2个变量A、B,A成功拿到对对象,和B的对象构造失败(bad_alloc),A的堆对象就没机会释放了,就悲剧了。(针对这个情况,可以把new操作封装在某个成员函数里,然后在这个成员函数写trycatch,初始化列表调用这个成员函数即可)。
更明智的做法是,把这种“需要获得对对象的成员变量”,改成使用智能指针。还是那2个A、B,当B出事时,A因为是一个智能指针成员变量,且已经完整构造,A会正常析构,析构的时候会正常地把堆对象也释放。
条款11:禁止异常流出destructors之外
析构函数在2种情况下会被调用:一,对象在正常状态下被销毁;二,对象呗异常处理机制销毁。
如果异常流出析构函数,当程序是处在上述第二种情况时,会导致程序被terminate。
解决办法是,在析构函数里写trycatch,捕获一切可能的异常,并把catch留空,即,捕获异常后不做任何事情。
另外一个不让异常流出析构函数的理由是,如果析构过程中,某个敌方抛出异常,并传播到上层,那么这个析构函数的后续代码就没有被执行,这个析构函数就没有完成它负责的任务。
条款12:了解“抛出一个异常”与“传递一个参数”或“调用一个虚函数”之间的差异
事实:一个对象被抛出作为异常时,总是会发生复制。(即使声明为static;即使是by reference方式)
条款13:以by reference方式捕捉异常
条款14:明智运用exception specifications
条款15:了解异常处理的成本
为了能够在运行时期处理异常,程序需要做大量记录工作:在每一个执行点,必须能够确认“如果发生异常,哪些对象需要析构”,他们必须在每一个try语句块的进入点和离开点做记号,针对每个try语句块必须记录对应的catch子句及能偶处理的异常类型。
运行时期的比对工作(以确保符合exception specifications)不是免费的;异常被抛出时销毁适当对象并找出正确的catch子句也不是免费的。
第二个成本是,try语句块。代码会膨胀,效率也会下降。(这还是没有异常出现时就有这个开销了)
效率
条款16:谨记80-20法则
2个点:
- 理性地优化代码来提升程序性能。有的代码优化了,并不一定能带来质的性能提升;要找到性能瓶颈之处才行。
- 利用profiler工具来找到性能瓶颈。
条款17:考虑使用lazy evaluation(缓式评估)
其实就是“能拖就拖”,减少不必要的计算。
- 引用计数 && 区分读和写
考虑这样的代码:
string s1 ="hello"; string s2 = s1;
执行s2=s1时,执行了一次复制构造函数。这个调用是否昂贵?这个调用是否可以避免?事实上,要具体情况具体考虑。如果s2只是被“读”,而不会被“修改”,那么s2只需要存一个指向s1的引用即可,不应该创建一个副本。在你真正需要之前,不必着急为某物做一个副本。
- Lazy Fetching(缓式取出)
简单地说,就是读取数据库数据时,能少拿一点就少拿一点,能不拿就不拿。
比如我自己在使用redis(KV数据库),就可以把一个class实例(有多个成员变量),用hash表来存。使用这个实例时(读or写),不直接hgetall或hsetall(全部读写),而是用hget或hset(单个读写)。
- Lazy Expression Evaluation(表达式换评估)
Lazy Evaluation的反义词是Eager Evaluation(急式评估)。 贴书中的例子:
1
2
3
Matrix m1(1000,1000);
Matrix m2(1000,1000);
Matrix m3 = m1 + m2;
m3是m1和m2的合,这是一个百万级的加法操作。如果m3的值实际上是不会被程序用到,那么这个计算就应该被忽略。
一般来说,定义了m3,应该下文就会用到m3。那么m3的计算就在所难免了,但如果是这样的情况:
1
cout<<m3.at(2,2);
显然,按照这一个条款的指示,应该使得,m3.at(2,2)执行时,只计算(2,2)处的值,而不计算其他的值。
条款18:分期摊还预期的计算成本
如果预期到某个操作必然要执行,就可以考虑把这个计算提前,甚至计算结果可以被复用。(caching法)
另一种做法是Prefetching(预先取出)。具体就是说,做一个计算时,可以一次过做多一些(batch)。比如磁盘的块读取,或者动态数组的内存动态扩张。
条款19:了解临时对象的来源
当程序产生一个non-heap object而没有为它命名,便诞生了一个临时对象。(匿名对象)
一般有2种情况产生临时对象:
- 隐式类型转换。比如传参时参数有可能被自动转换。
- 函数返回对象。返回的变量的类型和函数的返回类型不同时发生。
第一种类型,只当对象以by value的方式传递,或当对象被传递给一个reference-to-const参数时,才会发生。如果对象被传递给一个reference-to-non-const参数,并不会发生此类转换。
第二种情况,感觉这本书的分析过时了,c++11的右值引用,应该是解决这个情况的最有力武器。
条款20:协助完成“返回值优化”
撇开c++11,对于返回by value的函数,最佳的return写法是,return后面紧跟一个类构造函数。
这样做可能可以让编译器优化掉这个构造函数产生的临时对象。 (这一招叫做return value optimization)。
条款21:利用重载技术(overload)避免隐式类型转换(implicit type conversions)
很显然,就是通过增加定制的重载函数(函数参数类型和实际的参数类型一致),避免不必要的类型转换开销。
条款22:考虑以操作符复合形式(op=)取代其独身形式(op)
大部分程序员都希望,如果他们能够这样写:
x = x + y
他们也能够写成这样:
x += y
一般而言,复合操作符比其对应的独身版本效率高,因为独身版本通常返回一个新对象,而我们必须因此负担一个临时对象的构造和析构成本。
另外,如果同时提供某个操作符的复合形式和独身形式,便允许你的客户在效率与便利性之间做取舍。(虽然这个选择不易)
比如:
d = a + b + c;
和:
d = 0; d += a; d += b; d += c;
后者要比前者效率要高,因为没有复制对象。
条款23:考虑使用其他程序库
以iosteam和stdio这2个官方库来说明,不同的库有不同的性能。选择一个适合的库,也是提升性能的好方法。
条款24:了解virtual functions、multiple inheritance、virtual base classes、runtime type identification的成本
暂时跳过
技术 Techniques
条款25: 将construtor和non-member functions虚化
所谓virtual constructor是某种函数,视其获得的输入,可产生不同类型的对象。
也就是说,constructor无法被真正虚化。而是借虚化的成员函数来实现构造函数的虚化。
virtual copy constructor
virtual copy constructor,或者叫clone函数,会返回一个指针,指向对象自己的一个新副本。
注意:重载父类的虚函数时,允许改变返回类型。
将non-member functions的行为虚化
和constructor无法被真正虚化一样,non-member functions也不行。但是可以用技巧实现。
首先写一个虚成员函数做实际工作,再写一个什么都不做的内联的非虚非成员函数(inline non-member function),只负责调用这个虚成员函数。
条款26: 限制某个class所能产生的对象数量
允许0个或1个对象
每当即将产生一个对象,我们确知一件事情:会有一个构造函数被调用。
将构造函数声明为private,就可以禁止每个人产出对象的权利。这是0个对象的实现方法。
如果要使得一个雷有且只有1个对象,要用到一些‘技巧’,比如说用static:
1
2
3
4
5
6
7
8
9
10
class A{
friend A& theA();
private:
A();
};
A& theA(){
static A a;
return a;
}
三个要点:1)构造函数private;2)全局函数被声明为friend,使得theA不受A的private的约束;3)theA里的static。
或者把theA写成静态成员函数(static member function):
1
2
3
4
5
6
7
8
9
10
class A{
static A& theA();
private:
A();
};
A& A::theA(){
static A a;
return a;
}
改写成这样的好处是,去掉了全局函数;坏处是,调用theA变麻烦了,要A::theA()。(其实也没多麻烦吧)
还可以把A和theA放进一个namespace:
1
2
3
4
5
6
7
8
9
10
11
12
13
namespace Test{
class A{
friend A& theA();
private:
A();
};
A& theA(){
static A a;
return a;
}
};
然后调用方法就是: Test::theA()。
或者先执行: using Test::theA,然后就可以直接执行 theA()。
博主将十分感谢对本文章的任意金额的打赏^_^