五、实现--条款26-27
概述
本章讲述代码的实现细节。包括:
- 变量的定义时机。太快变量的定义往往会造成效率上的拖延。
- 过度转型。过度的转可能导致代码变得又慢又难维护,以及微妙的难以理解的错误。
- 返回对象“内部数据之号码牌”会破坏封装以及产生“虚吊的号码牌”。
- 异常引起的资源泄漏,数据败坏。
- 过度inlining会导致代码膨胀。
- 过度耦合导致冗长的build time.
条款26:尽可能延后变量定义式出现的时间
一、原因
任何你定义的变量,只要有构造函数和析构函数,那么变量定义时你得承担构造函数的成本,变量离开作用域时你得承担析构成本。
二、好的做法
不仅仅应该延后到该使用之前定义,而要考虑延后到可以给它赋初值为止。这样既可以避免构造和析构非必要的对象(假如未使用之前抛出异常就会这样),又可以避免无意义的default构造行为。
书上的一个加密的例子可以帮助我们理解:
string encryptPassword(const string &password)
{
// string enctypted; 放在这里是不妥的
if(password.length() < minLenth)
{
throw ... // 抛出异常
}
/*string enctyped;
enctyped = password;*/ // 这样多承担了default构造函数的成本
string enctypted(passeword); // 直接就调用了copy构造
encrypt(enctypted); // 加密
return enctyped;
}
这段代码里面,我把书上的几种情况综合了一下。为了便于对比,我把不好的做法也注释上去了。
- 倘若一开始就声明一个enctyped变量(上述已经注释),那如下面一行抛出了异常,就要承担构造和析构非必要对象成本了。
- 如果我们不是在一开始,即抛出异常之前定义它,我们也不应该先定义再复制。这样就承担了额外的default构造成本。
所以最好的方式是,定义时就能够直接赋初始值。
三、循环定义时候的选择
观察以下两段for循环:
方案A:
Widget w;
for(int i = 0; i < 10; ++i)
{
w = 和i有关的某个值;
... // 其它操作,也可能和w有关
}
方案B:
for(int i = 0; i < 10; ++i)
{
Widget w(和i有关的某个值;);
... // 其它操作,也可能和w有关
}
方案A的成本:
1个构造函数 + 1个析构函数 + 10次的赋值操作
方案B的成本:
10个构造函数 + 10个析构函数
作者给出的建议是:
(1) 除非你知道赋值的成本 < 构造+析构的成本;
(2) 你所处理的代码对效率高度敏感。
否则就应该使用方案B。另外A中的w作用域更大,有时候对程序的可理解性和易维护性造成冲突。而作用域更大说明可见性更大,可能在代码中会带入一些脏数据,这也是A的缺点之一。
作者总结
尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。
条款27:尽量少做转型动作
C++提供四种新型的转型:
- const_cast. 通常用来做常量性移除。同时也是唯一有此能力的C++-style转型操作符。
- dynamic_cast. 主要用来做“安全向下转型”。就是用来决定对象是否属于继承体系中的某个类型。是唯一无法用旧式类型来执行的动作,也是唯一可能耗费重大运行成本的转型动作。
- reinterpret_cast. 执行低级转型,实际结果取决于编译器,也就意味着不可移植。例如将pointer to int 转成int.
- static_cast. 强迫隐式转换。如将non-const转成const,int转成double,base转成derive,但是它无法将const转成non-const,这只有const_cast才做的到。
一、不要认为转型其实什么也没做
许多人有一种错误的观念:
认为转型其实什么也没做,只是告诉编译器把某种类型视为另一种类型。
但是任何一个类型转换往往真的令编译器编译出运行期间执行的代码。
比如我们将int转成double几乎肯定产生一些代码,因为int的底层表述不同于double的底层表述。更有甚者,还有更不一样的例子:
class Base0
{
public:
Base0(int A)
:a(A){}
int a;
};
class Base1
{
public:
};
class Derive : public Base0, public Base1
{
public:
Derive(int A, int B)
:Base0(A), b(B){}
int b;
};
Derive继承于Base0和Base1.接下来执行以下操作:
int main()
{
Derive D(1, 2);
Derive *pD = &D;
Base1 *pB1 = &D;
Base2 *pB2 = &D;
cout << "D的地址: " << &D << endl;
cout << "pD的地址:" << pD << endl;
cout << "pB1的地址:" << pB1 << endl;
cout << "pB2的地址:" << pB2 << endl;
return 0;
}
执行结果如下:
我们可以很清楚的看到,即使Base1指针和Derive指针、Base2指针指向同一个派生类,它们的地址却不相同!!!这难道还不能说明类型转换的话编译器其实是有做一些动作的吗?
地址不同编译器怎么处理?
由于笔者水平有限,目前对对象模型和编译原理的理解有限,无法解释为何地址不同。只能说一下编译器怎么处理这些不同。
在上述情况下,有时候会有个偏移量(offset)作用于Derive *的指针身上,用以取得正确的Base指针。不同编译器的解决方法不同。
所以单一对象可能拥有一个以上的地址!!!
二、转型会出现一些似是而非的代码
很多时候,我们继承一个类,在某个虚函数中,我们很可能就直接调用基类的函数来实现了。倘若这个时候使用转型方法来调用,会有什么不妥呢?
class Window
{
public:
virtual void OnResize()
{
... // 具体代码实现
}
};
// 继承自Window
class SpecialWindow : public Window
{
public:
virtual void OnResize()
{
static_cast<Window>(*this).OnResize();
... // 执行和子类相关的行为
}
};
上述代码看似能够正确执行,其实暗藏了一个不难发现的“坑”。重点就在于:
static_cast<Window>(*this).OnResize();
这一句真的能够调用基类的OnResize函数吗?
事实上,我们使用转型的时候,生成的是一个临时的副本。也就是说,这个转型动作会得到当前this对象的Base成分的副本!!! 所以我们调用的是此副本的OnResize函数,而不是当前对象基类的OnResize函数。
假如基类的OnResize函数是一个const函数,那么此调用似乎不会有什么错误,但问题就在于它不是一个const函数,也就表明里面很可能会出现修改成员变量的行为。那么我们只是改动了副本,并没有改变真正的基类!
三、尽可能避免使用dynamic_cast转换函数
探究dynamic_cast之前,先明确一点:许多dynamic_cast的实现版本执行速度特别慢。
例如有一个很普遍的实现版本是基于“class名称之字符串的比较”。如果在一个四层深的单继承体系内调用一次dynamic_cast就会调用四次的strcmp,用以比较class的名称。深度继承或者多重继承的成本还会更高。
替代用法
当我们需要使用dynamic_cast的时候,想想看是否可以有别的方法。
例如。我们Window基类中有一个容器和一个函数
vector<Window> v;
void blink(); // non-virtual
我们的SpecialWindow子类中也有一个函数
void blink(); // non-virtual
然后我们还遍历这个容器且要调用其子类中blink函数(注意:blink不是一个虚函数!),那么如果我们采用的是向下转换的方法:
// iter是指向vector<Window>的迭代器
for(iter = v.begin(); iter != v.end(); ++iter)
{
if(SpecialWindow *p = dynamic_cast<SpecialWindow *>(iter->get()))
{
p->blink();
}
}
如果我们这么写,无疑效率是非常缓慢的,我们要调用n次的dynamic_cast转换。所以我们要将基类中的容器改为:
vector<SpecialWindow> v;
这样就不用转换,直接就可调用。
但是这样就不通用了:如果我们还有别的类要用呢?不仅仅是只使用SpecialWindow类呢? 那就是虚函数发挥作用的时候了。我们直接声明为虚函数,就可以正确的进行调用了。
作者总结
如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_cast,如果有个设计需要转型动作,试着发展无需转型的替代设计。
如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需要将转型放进他们自己的代码内。
宁可使用C++-style转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的职掌。