GotW#63 狂乱的代码
难度:4/10
有时生活中你会遇到一些看似平常却不可思议的调试情形。继续尝试解决这个问题,看看你能否解释可能导致问题的原因。
问题:
1.一个程序员写下以下代码:
//--- file biology.h
//
// ... 适当的包含文件和其它材料 ...
class Animal
{
public:
//基于该类对象的方法:
//
virtual int Eat ( int ) { /*...*/ }
virtual int Excrete( int ) { /*...*/ }
virtual int Sleep ( int ) { /*...*/ }
virtual int Wake ( int ) { /*...*/ }
// 对于已经有配偶的动物,
// 有时它们不喜欢它们的配偶,我们提供了:
int EatEx ( Animal* a ) { /*...*/ };
int ExcreteEx( Animal* a ) { /*...*/ };
int SleepEx ( Animal* a ) { /*...*/ };
int WakeEx ( Animal* a ) { /*...*/ };
// ...
};
// 具体类。
//
class Cat : public Animal { /*...*/ };
class Dog : public Animal { /*...*/ };
class Weevil : public Animal { /*...*/ };
// ... 更多可爱的生物 ...
// 虽然冗余却很方便的外覆(helper)函数。
//
int Eat ( Animal* a ) { return a->Eat( 1 ); }
int Excrete( Animal* a ) { return a->Excrete( 1 ); }
int Sleep ( Animal* a ) { return a->Sleep( 1 ); }
int Wake ( Animal* a ) { return a->Wake( 1 ); }
不幸的是,代码无法通过编译。编译器拒绝了一个或更多的...Ex()函数的定义,并给出出错信息表明函数已经被定义了。
为了绕开编译器错误,这个程序员将...Ex()函数注释掉,使程序通过了编译,并开始测试sleeping函数。很不幸,Animal::Sleep()成员函数看来没有一直正确地运行;当他试图直接调用Animal::Sleep()时,一切正常。但当他试图通过Sleep()这个作为包裹的自由函数来调用Animal::Sleep()时——事实上这个包裹函数除了调用成员函数版本的Sleep()外什么也没做——有时却什么都不做...并非每次都如此,只是某些时候才发生这种情况。最后,当程序员在调试器中或是进入到连接器生成的符号表中,以试图找出症结所在时,他发现根本找不到Animal::Sleep()的代码。
编译器出毛病了吗?是不是这个程序员应该给编译器的提供商发一封令人光火的电邮并且向纽约时报提交一封愤怒的信呢?难道是千年虫?或者只是因为从因特网上感染了淘气的病毒呢?
究竟是怎么了??
解答:
简短地说,有几种情况可能导致以上症状,但有一种显著的可能情况能够解释所有观察到的行为。对,你猜对了,正是狂乱运作的宏和有意无意的重载在作怪。
<动机>
某些流行的C++编程环境为你提供了更改函数名的宏。通常它们出于“良好的”或“清白的”原因在工作,即是为了向前和向后的API版本的兼容;例如:如果一个Sleep()函数在某个版本的操作系统中被替换成SleepEx(),那么供应商所提供的声明函数的头文件中就可能包含自动将Sleep转换成SleepEx的这么一个“有用的”宏。
这不是个好主意。宏是封装的敌人,因为无法控制它们的实际作用范围,即使是宏的作者也无能为力。
<宏所忽略了的>
因为某些原因,宏被视为“令人讨厌的、恶臭的、乱拱被子的同床者”(obnoxious, smelly, sheet-hogging bedfellows),而绝大部分原因都是跟它作为一种“久负盛名”的文本替换工具的本质相关的。宏在预处理期即开始起作用,而此时任何C++的语法和语义规则都还没有被应用。以下就是宏的一些缺乏吸引力的特性:
1.宏会更名——更多时候会伤害无辜者而不是保护它们。
保守一点说,这种重命名会在一定程度上扰乱除错工作的进行。这种宏的重命名工作意味着你的函数事实上并不如你所愿的被调用。
举个例子,考虑我们的非成员函数Sleep():
int Sleep ( Animal* a) { return a->Sleep(1); }
你无法在目标代码或链接表文件的任何地方找到Sleep(),因为里面根本没有Sleep()函数。起初,当你想知道你的Sleep()去向时,你也许会觉得,“啊,或许编译器自动帮我将Sleep()内联了(..inlining Sleep() for me.)。”因为那样可以解释为什么简短的函数看上去不存在——虽然很显然编译器不会在没有直接指明的情况下内联任何东西。你立即得出了结论,于是火冒三丈地给你的编译器供应商去了一封气愤的电邮以抱怨过分的(agressive)优化,然而,你错怪了这家公司(或者,至少,错怪这部门)。
你们中的一些人也许已经遇到过不幸的、的确很糟糕的结果了。如果你们像我一样,容易被编译器的古怪所激怒,并且不满足于简单的解释,那么你强烈的好奇心也许能打败你:)接下来,你也许会打开调试器,故意单步跟踪进入函数...只是被带入正确的幻影函数所在的源代码行(在源代码中,它看上去还是原先的名字),继续单步跟踪进那个幻影函数,你会发现它的确在工作、在被调用,然而所有其它调用它的用户却都不知道它的存在。通常,在“找出究竟该怎么办”与“只是低声抱怨愚蠢的宏的诡计”之间只差一小步。
等等,情况变好了:
1(b). C++已经具备解决名字问题的特性了。这导致了可能的“不健康的交互作用”。你也许认为改变函数名字不是什么大问题。好,很好,通常的确如此。但是如果你改变了函数的名字结果它与另一个已经存在的函数重名了...如果两个函数重名了,C++该怎么办呢?它会重载它们。如果你没有意识到悄悄发生的重载的话,那可就不太好了。啊,这就正如Sleep()案例一样。原因就在于库的供应商提供了一个“有用的”Sleep宏,用以自动将函数重命名为SleepEx,从而导致事实上在供应商的库中有重名的函数。如果不同的函数有不同的签名呢?那么我们在写我们自己的Sleep()函数时,我们就可以发觉库中的Sleep()的重载,从而小心地避免不确定性和其它一些导致问题的错误。我们甚至还得依赖于重载,因为我们也许会刻意要提供与库中Sleep()类似的行为。换句话说,一旦我们的函数被悄悄地重载后,不仅仅是被重载的函数行为异于我们所期待的,而且如果我们原本刻意设计了重载,那么它也将什么都不做,至少它不按照我们想的那样去运作。
在我们问题的上下文中,这么个重命名宏只能部分地解释为什么在不同环境下最终调用的是完全不同的函数。哪一个函数被调用依赖于重载决议在不同的调用点所决定的特定类型。有时是我们的函数,有时是库函数。仅仅是依赖,也许以一种不明显的方式。
如果这个故事以令人不快的对于非成员函数的影响结束的话,这远远不够。很不幸,一些危险的东西就像榴霰弹(shrapnel)一样向其它方向飞去:
2.宏忽略了类型
上面描述的Sleep宏的本来意图是改变一个全局非成员函数的名字。然而,这个宏改变了所有出现Sleep的地方;如果我们恰好有一个全局变量Sleep,那么它的名字也会被悄悄的改掉。这绝对是件糟糕的事情。
3.宏忽略域控制范围
更糟糕的是,一个改变全局非成员函数的宏会很乐意地改变所有匹配的函数(或其它东西)的名字,也许这些函数是类的成员或已经被很好地封装在你自己的名字空间中了。在这个例子中,我们写下了包含Sleep()和SleepEx()的类;上面许多问题都与Sleep更名宏有关,至少部分有关。这个宏使我们自己的函数互相隐式地重载了。正是这种看不见的重载,解释了上面第一点中提到的为什么有时候非期望的成员函数被调用。这绝对是个坏东西。这就像一个脱下手套的、无证医生(不够聪明的库的头文件作者)用脏手(“未经消毒的”宏)剖开你的身体(类或名字空间),然后在你的体腔里重新排列东西(类成员和其它代码)一样...而这一切竟然是他在梦游中完成的(完全没有意识到他们在做什么)。
<概要>
简而言之,宏对任何东西都满不在乎:)
策略:避免宏。
你对宏默认的反应应该像这样,“宏!呃,真讨厌!”。除非在一些特殊情况下被迫要使用它们,并且要保证它们不搞破坏。宏不是类型安全(type-safe)的,也不是域安全(scope-safe)的...它们根本不安全。如果你必须写宏,应该避免它们出现在头文件中,并且要试图赋予它们足够长并且个性化的名字,这样才不至于无意中伤害到别的东西。
策略:尽量使用名字空间。
确实,像上面看到的一样,宏并不尊重域,当然也包括名字空间域。然而,将问题中的自由函数放进名字空间也许至少会减少一些问题。有一点可以明确,这样可以避免被供应商提供的库函数全局重载。简单的说,要施行优良的封装结构。好的封装不仅仅有利于更好的设计,还可以帮助你对抗未预见到的威胁。宏是封装的敌人,因为无法控制它们的实际作用范围,即使是宏的作者也无能为力。类和名字空间是C++中管理并使程序各不应相关部分之间耦合最小化的极其有用的工具。明智地使用它们和其它C++工具来加强封装将不仅仅是有利于做出出众的设计,更将提供一种针对其它程序员所作的欠周全的代码的保护措施。