Chapter 5 定义与实现
Item 26 尽可能推迟变量定义
-
为了避免无谓的变量声明造成垃圾值的构建和构析开销,建议不要预先定义变量,而是有必要时在定义变量(注意最好用括号而不是赋值运算符),这样有利于程度的可读性和性能。
-
面对循环时有两种处理方式,在循环外声明变量和在循环内声明变量,设循环为n次,则前者定义成本为 1次构造 + 1次构析 + n次赋值, 后者定义成本为 n次构造 + n次构析,如果类的赋值成本不高,可以选择在循环外声明,否则在循环内定义变量可能是更高效的做法。
Item 27 尽可能不用强制转换
-
使用新式转换相比旧式转换的好处:
- 易识别,易被类似grep这样的文本检测工具搜索到
- 细化了强制转换的种类,使得更容易检测出错误(比如误用const_cast以外的转换来去掉const)
-
有必要使用强制转换时,建议定义在代码内,如此就不必要在调用函数时还要调用强制转换(确保接口一致性)
-
尽量别在性能敏感的程序上使用dynamic_cast(因为dynamic_cast在运行时进行类型识别会降低程序运行效率),找到尽可能不用cast的实现方法
Item 28 避免返回指向对象内部的手柄
-
避免返回指向类内部封装数据的指针,引用或者迭代器, 原因如下:
-
对const 成员函数确保其const性的保证,比如下文的代码破坏了类的封装性:
struct RectData { Point ulhc; Point lrhc; }; class Rectangle { public: ... Point& upperLeft() const { return pData->ulhc; } Point& lowerRight() const { return pData->lrhc; } ... private: std::tr1::shared_ptr<RectData> pData; }; int main(){ Point coord1(0, 0); Point coord2(100, 100); // rec is a const rectangle from const Rectangle rec(coord1, coord2); // (0, 0) to (100, 100) rec.upperLeft().setX(50);// now rec goes from (50, 0) to (100, 100)! }
正确做法应该是:
struct RectData { Point ulhc; Point lrhc; }; class Rectangle { public: ... Point& upperLeft() const { return pData->ulhc; } Point& lowerRight() const { return pData->lrhc; } ... private: std::tr1::shared_ptr<RectData> pData; }; int main(){ Point coord1(0, 0); Point coord2(100, 100); // rec is a const rectangle from const Rectangle rec(coord1, coord2); // (0, 0) to (100, 100) rec.upperLeft().setX(50);//Error, can't change const }
-
避免由于类未初始化内部成员时而调用类成员函数导致的"空手柄"(dangling handles)问题
-
Item 29 追求exception-safe的代码
(由于直译为“异常安全性”在中文显得很奇怪,故直接用英文术语)
在出现异常时,一个exception-safe的函数应该具有如下特征:
- 在出现异常时不会造成资源泄露
- 在出现异常时内部变量及数据结构不会被影响
对于1,item13和item14已经讨论过了,主要是通过智能指针取代原始指针,以防构建新对象时造成异常,无法手动delete导致的资源泄漏问题
对于2,exception-safe的函数应提供下述三项保证中的任意一项保证:
-
函数保证当异常发生时程序内的一切处在正常状态,不会有任何对象或者数据结构被败坏,构造该保证的难点在于程序的确切状态是难以预测的(基本保证)
-
函数保证当异常发生时程序的状态是不变的,这类函数的调用具有“原子性”:若成功则是完全成功,如果调用失败,程序内的一切状态应该就像没调用一样。(强保证)
-
函数保证不会掷出异常(比如C语言对内置类型的操作永远不会出现异常,但如此代码现实上很难实现)
实现强保证的方法有:
copy and swap: 在相应处理函数内为想要修改的对象做一个副本,对副本做出对应修改,如果修改成功就可用于置换原对象,如果修改失败也不会影响原对象的状态
书中用了pimpl(item25,item31) 来讲述, 但我不觉得pimpl(和copy and swap有什么关系,因为如果要改数据的话其实在相关函数内直接构造个新对象也是可以的, 但显然把较大的内部数据成员封装为指针管理会更专业一些:
struct PMImpl
{
std::tr1::shared_ptr<Image> bgImage;
int imageChanges;
};
class PrettyMenu
{
... private : Mutex mutex;
std::tr1::shared_ptr<PMImpl> pImpl;
};
void PrettyMenu::changeBackground(std::istream &imgSrc)
{
using std::swap; // see Item 25
Lock ml(&mutex); // acquire the mutex
std::tr1::shared_ptr<PMImpl> // copy obj. data
pNew(new PMImpl(*pImpl));
pNew->bgImage.reset(new Image(imgSrc)); // modify the copy
++pNew->imageChanges;
swap(pImpl, pNew); //swap the new data into place
// release the mutex
...
}
- 对一个函数A,若其内部有调用多个函数a,b,c…,即使内部函数对exception-safe都是“强保证”,也不意味着A就是强保证的。(比如a结束后b抛异常导致程序状态改变)
- 撰写实际代码时,不能只追求强保证,需要在效率,实现复杂度之间权衡,但是至少提供“基本保证”(不败坏数据结构)以及防止资源泄漏。为你的函数用户和将来的维护者着想。
摘录:
四十年前(1955?),满载goto的代码被视为一种美好实践,而今我们却致力写出结构化控制流(structured control flows)。
二十年前(1975?),全局数据(globally accessible data)被视为一种美好实践,而今我们却致力于数据的封装。
十年前(1985?),撰写“未将异常考虑在内”的函数被视为一种美好实践, 而今我们致力于写出“异常安全码”。
时间不断前进。我们与时俱进!
Item 30 透彻了解Inline函数的里里外外
-
inline 函数既可以被隐式申请(比如class内被定义的成员函数及其friend函数会被inline),也可以被显式申请(直接在函数前声明Inline关键字)
-
inline的原理是在编译期间将函数调用替换为函数本体从而提高执行效率,但代价是增大了目标程序的大小,使用不当易造成“代码膨胀”问题
-
编译器并不一定会inline被声明了inline的函数,比如:
-
带有循环和递归的函数(难以优化)
-
virtual函数(在运行期才确定实际运行的函数,但编译器在编译期间不知道实际调用哪个函数)
-
函数指针(即使指向的函数是Inline函数,但是通过函数指针来调用该函数编译器并不一定会Inlining)
-
构造和构析函数(同函数指针,需要指向类,另外就是如果Inline的话就将面对类内部的数据成员的构造和构析函数本体都被替换所带来的成本,开销可想而知,尤其是多态体系)
-
-
如果使用inline, 尽量限制在小型,被频繁调用的函数上。可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
-
不要只因为函数模板出现在头文件就将它们声明为Inline,原因是这会影响到该函数模板实例化后的每一个特化函数版本,造成不必要的膨胀。
P.S:在头文件设计类时,如果一定要将函数实现,就可将该函数声明为Inline来避免重复定义问题(inline函数相对于有别于一般函数头文件声明,实现文件定义截然不同的一点,做项目发现的,也不知道是为什么,注意,lnline函数一定要放在头文件内,项目内放到实现文件里反而会导致重复定义问题)
疑问: 什么是二进制升级?(binary upgradability)
Item 31 尽量降低文件间的编译依赖⭐
为什么我做第一个C++项目的时候没看这本书呢? 😦
- 设计原则:
- 设计头文件的原则:每个头文件都应该能自给自足,也就是其能在不需要引用其他头文件的情况下单独引用(不知道这个自给自足有没有包括“内部包含”,应该没有,不然类要pimpl的数据集没法搞)
- 如果用对象指针或引用便能完成的任务,尽量不用对象本身(pimpl就体现了这一思想)
- 尽量用class声明替换class定义(写有其他class依赖的class头文件的基本常识,前提是类的数据成员不包括其他自定义class,否则要么把类设计成Handle Class或者Interface Class, 要么就直接用这章不建议的实践–> 把相关类头文件给include进来)
- 为声明和定义提供不同的头文件(这部分没懂,好像是把设计类依赖的其他类之[前置手工声明]都封装在一个头文件里,其他头文件定义需要时就直接引用过去,书中的例子是iosfwd)
P.S: 之前一直没懂这里声明和定义的区别,现在了解了只要编译器有给变量分配内存空间,那么变量就是被定义的,和你有没有写功能实现无关,哪怕你class只是有个空{},没写任何函数或者数据成员,其实也是定义(空类分配1字节内存,编译器自动分配构造和构析函数)
-
将定义与实现分离的方法
-
pimpl(Handle Class)
将类使用的数据结构单独划出一个impl类,并用指针(往往是智能指针)管理这个类
-
Interface Class
定义一个只有纯虚函数和静态方法的类作为接口,通过该接口实现的具象类来管理数据成员,类似于Java中的接口(这个例子是从接口单继承,item40将讲多重继承)
上述设计的共同优点是,如果要修改数据成员(书中是Person类的name,birthday,address三个成员类),则只需要重新编译被修改的部分(如书中的PersonImpl),用于管理数据的本体类不会受影响,对于大量使用本体类的程序能极大减少编译成本。
但上述设计显然也有局限性, 对于Handle Class, 通过指针访问数据多了一层间接性,指针的大小随着指向对象的大小增加而改变,且指针必须被初始化,故最后Handle Class要承担指针带来的额外内存开销以及可能的
bad_alloc
错误。Interface Class本质上是通过vptr指向对象,故也要承受vptr带来的额外内存开销,开销大小取决于指向的对象是否还有其他virtual函数来源。
故设计上述类的功能时尽量使用Inline函数做最大优化。
对书里的代码自主实现了一下,感兴趣的可以下载:https://gitee.com/tracker647/effective-c–code/tree/master/item31
-