前言
C++确实是一门复杂的语言。包括之前查看了一些C++11的文档和做了一些实践和总结,越来越觉得C++是门神奇的语言,也是个陷阱多多的语言。 我现在开发过程中最主要使用的语言就是C++,所以了解C++的一些细节和问题非常重要,后来看到某大神的一篇文章《C++的坑多吗?》,激起了我专门去看一看关于C++的一些常见的设计方法和问题的书。就是刚才提到的文章里有说的《Effecitve C++》和《More Effecitve C++》 共90个条款,所以说是90个坑。
因为只是阅读笔记,只是为了便于回忆,我就只记录了一些我觉得重要和我先前不知道或者没注意到的点:
Let’s begin:
- 尽量以const、enum、inline代替#define,特别是类内部enum的运用
- 尽可能用const,我发现在实际写代码中者这可以让编译器帮你解决很多不经意的问题
- 全局对象的初始化顺序是不确定的,所以建议全局变量互相引用的时候,采用static局部变量的方式。static变量将会在第一次调用时初始化
- 请使用virtual析构函数,在A* p = new B时,如果A的析构函数不是virtual的,delete p会导致内存泄露等行为
- 不要让异常离开析构函数,原因也是容易造成泄露
- operator=或其他类似行为的函数要注意自我赋值的情况,即 stData = stData
- 在不同编译器中,对函数调用的参数执行顺序是不同的,要注意这一点(如: func(a(), b()),有些环境的执行顺序是a->b->func,有的是b->a->func
- shared_ptr和auto_ptr可以让动态链接库产生的对象在产生的模块中销毁,但是带来的问题是该智能指针无法升级
- 大部分情况下可以用pass-by-reference-to-const代替pass-by-value,但是在对象小的时候,比如char、bool、int,这么做完全没有必要
- 尽量把封装部分声明为private,因为暴露给外界的越多,表示你可以改动的地方越少
- 隐式类型转换只会分析一层,所以如果所有参数都需要隐式类型转换,建议使用non-friend、non-member函数
- 一个不抛异常的swap函数可减少很多互斥操作,也能有效减少错误处理的代码。特别要注意,一些STL库,如vector和dequeue在T的拷贝和赋值构造是异常安全的情况下才保证异常安全,这意味着vector和vector<vector >都不是异常安全的
- 转型动作有时候很耗CPU,特别是dynamic_cast
- 继承的非virtual函数在重载之后会发生父类函数的覆盖,这时候可以手动using进来
- 纯虚析构函数必须有一个实现体,即便内容是空,否则会导致父类数据成员的内存泄漏
- private继承和has-a关系的区别是private继承可以减少内存占用,因为大多数编译器在申明内容为空的成员时都会给予一个字节,然后由于内存结构对齐,会扩充到4个(32位系统)或更多字节,最终可能导致一个数据结构的大小不能被CPU缓存。但是仍然不推荐使用private继承
- 大量使用模板可能会导致代码长度剧增,而最终导致代码缓存命中率下降
- *将与参数类型无关的参数抽离template,书上说会导致代码爆炸式增长,事实上,现在的编译器会优化掉,基本可以无视之
- 尝试用traits classes表现类型信息,具体可以看boost的traits库+配合static_assert无敌了
- new与delete重载和placement new与placement delete,还有操作失败时的handle函数,这些个东西着实不是一两句话可以描述清的,还是看书去吧
new操作的正常语序是
- operator new(size_t) // 分配内存
- 调用构造函数
- return 指针地址
但是编译优化的时候有时候会把1和3合并了,这是在多线程编程时需要注意的地方
- 尽量不要重载&&、||和,因为无法达成和编译器一样的行为,比如:在 if ( a && b ) …中,如果a为false,b应该不执行,而如果a和b不是内置类型并且用户重载了&&符号会发生什么事?答案是会执行a.operator&&(b),结果显而易见,是先执行了b,然后执行&&操作符函数。这样就无法达到我们一般的思维。||和,操作符也类似,我们无法模拟出操作编译器的默认行为,所以如果不是我们另有语义上的目的或者我们能确保使用者能正确使用,不要重载这些操作符
- 使用包装器维护对象,使用析构函数释放对象,基本是最简单的防止异常抛出时的内存泄漏的方法,就像shared_ptr和auto_ptr
- 抛出异常的时候,因为要离开函数体,并且局部变量会被析构,所以抛出的对象会被复制构造,并且这个复制的对象异常处理结束后销毁,如果你catch的时候不是catch引用,则会复制两次。这也是现代编译器catch内不是引用类型时会报warning的原因所在
- try-catch语句会带来大约5%~10%的代码膨胀,而异常处理生成的代码性能消耗上至少多出了上一条提到的复制,所以效率较低。编译器一般有编译选项可以关闭异常,并且这时候C++标准库的行为会变化,比如原来的抛出bad_alloc异常会变成返回空指针。建议是按80-20的原则,那20%的代码不要使用异常
- 使用exception specification的时候要注意函数内所调用的对象是否会抛出预料之外的异常,而导致unexcepted被调用(默认行为是abort掉)
- 缓式评估(lazy evaluation)很有用啊很有用,最简单的例子就是写时复制,比如std::string,在赋值操作的时候内部使用引用计数共享同一块内存,等到需要修改对象时,如果引用计数不为1,才复制内容并执行相应修改。所以直接return std::string和赋值是不会照成性能问题滴。需要特别注意的是,有时候在多线程环境下,缓式评估(比如有些写时复制的string操作)并不能带来优化效果。
- 帮助编译器完成返回值优化(RVO),具体视不同编译器而言。通常的做法是,只有一个return函数并且在return函数里写构造函数,或者只return一个变量
- 要注意隐式类型转换,特别是 a = b + c,a、b、c都能隐式转为int时,执行 a = 10 + c 或 a = b + 10的情况,非常危险
- 建议重载操作符时,使用op=来实现op,这样可以减少比如 a = b + c + d + e …操作时编译器优化后产生的临时对象数量
- 运行时类型检查(RTTI)比较耗费性能,无论是dynamic_cast还是typeid
- 这条是我觉得应该避免的,禁止对象产生在堆之中的方法是把new操作符private了,而强制对象产生在堆里的方法是把构造或析构函数private或protected了,然后通过另外的函数来产生对象,但是这不能解决继承关系下的产生位置限定,所以我觉得非常应该避免
- 如上第26所述,缓式评估很NB,但是,在实现的时候要注意写时复制的共享问题,要管理好可共享状态。比如这种行为***string a = “Hello world!”; char& c = a[3]; string b = a;***这时候改变c的值不应该影响到b
- 可以尝试用Proxy Class(代理类)进一步提高缓式评估的效果,比如string的operator[]不返回char&而是一个可以隐式转换成char&的代理类,因为operator[]返回的东西也有可能只需要只读权限
- 一个小tips,对于单个类型决定函数执行时可以用virtual继承,而多个类型决定执行的函数的话可以试试类似这种方式。double-dispatches => 两个single-dispatches
比如两个对象A a和B b分别有虚函数A::func1(Base b) { b->func2(this); }和B::func2(A* a)…,则调用a->func1(b)*就完成了对两个类型相关函数的自动选择。需要仔细思考,或者《More Effective C++》的第31条有比较完整的例子。也比较好理解
以下是另外的地方看到的附加坑
- 宏这个东西很不好玩,比如有些max实现是*#define max(a, b) a > b? a: b*,如果使用max(x + 3, y)会怎么样?表达式会展开成 x + 3 > y? x + 3: y, 即便是*#define max(x, y) (x) > (y)? (x), (y)*,如果使用max(x, y) + 3 也会展开为 (x) > (y)? (x): (y) + 3,还有类似max(++x, y)这种++操作会执行两次的情况。所以不是条件编译、守护头文件和#pragma禁止警告外,少用宏为妙(不过很多测试和日志套件里用宏来判定行数、文件啊什么的还是很可以有的)。
总结
基本就到这里,比较流水帐,C++其实并不难学,但是有时候如果按常规思维或者其他语言的思维,容易落入陷阱里,导致不明原因的泄漏、崩溃、低效等,所以想要成为一名合格的C++程序员,来踏平C++的各种坑吧。不过理解了C++的各项原理的话,其实其他语言只是自动做了一些工作而已,感觉对学习其他语言的原理上还是很有帮助的。