缘起
前几天,码农朋友甲(下文简称“甲”)拿着我 5 年前发表在某博文的代码问我:“这段代码是有 BUG 吧?”下面就是他给我指出来的一段 C++ 代码,大家可以先尝试能不能找到甲看到的 BUG :
void solve::Initial(void)
{
TimeStamp = 0; // 时间戳
DFN = new int[N+1]; // 搜索次序
Low = new int[N+1]; // 能够回溯的最早次序号
setIntArrayVal(DFN, 0, N+1);
setIntArrayVal(Low, 0, N+1);
SCC_id = 0;
SCC = new int[N+1]; // 辅助栈
Status = new int[N+1]; // 辅助栈状态
setIntArrayVal(Status, 0, N+1);
sp = new Shrink_point[N+1]; // 缩点(极大强连通分量)
return;
}
void solve::setIntArrayVal(int* array, int val, int len)
{
memset(array, val, sizeof(int)*len);
return;
}
诚然,突然要我查一段几年前写下的代码是否有 BUG ,我内心是比较抗拒的——尤其是我自己写的代码(我对自己还是有相当自信的)——毕竟人的弱点就是不善于揭发自己的短处。不过这都只是次要的心理因素。
归根结底,所谓打铁趁热, BUG 也是越早发现越好,**新代码的 BUG 总是要比历史代码的 BUG 更容易处理**。而面对这个陈年 BUG ,我已经完全忘记了我在 5 年前写这段代码的思绪,所以要我马上就应付甲的质疑是不可能的。与其再花费一番周折琢磨我自己的代码,我干脆直接就举手投问:“所有测试用例运行可以通过,是哪里有 BUG 呢?”
因注释而蔓延
甲告诉我,是 memset 函数使用错误:在 C++ 中,函数 memset 的作用是对一段连续的内存块赋值,即赋值的单位是字节,换而言之 memset 只能用于字节数组,但 int 数组不是字节数组。
void solve::setIntArrayVal(int* array, int val, int len)
{
memset(array, val, sizeof(int)*len);
return;
}
老实说,我很高兴甲会如此仔细的看我 5 年前的代码。而且毫无疑问,他的观点是正确的。但是也不见得我就是错的。因为早在那时我就已经知道 memset 函数的局限所在,但我坚持要用这个函数做数组的初始化,是因为我看中了它的效率——
相对于逐个赋值的方法初始化数组元素、memset 的效率要高得多,因为从寻址次数来看,前者的时间复杂度是 O(n)、后者是 O(1),更何况当时所解决问题的 n 是上千万级别的。虽然我把 memset 用在非字节数组,只要我保证初始化的值只为 0 就不会有任何问题。事实上也是如此。
于是我自信满满地告诉甲,单纯断章取义地看我这个方法,确实是一个 BUG 。但如果整体地去看我的代码就恰恰相反,**我只是利用了 BUG ,并得到了更高效的处理**。
但是甲之后的一席话确实值得我深思:
“或许对目前的这份代码而言,这个 BUG 是被你巧妙地利用了,但是我觉得**真正的 BUG 或许不是你的代码,而是你没有文字注释去说明你的想法**。不要忘记你已经共享了你的代码,当更多人看到这段程序时,如果他们不了解 menset 的原理就照样搬用,那么你就无异于在别人的代码中散播了 BUG ,因为你不能把他们代码中的 val 限制为 0。”
最危险的组合
不得不承认,甲是对的。即使我有足够的自信在 5 年后仍然记得利用这个 BUG 的前因后果,但在这 5 年间早已误了不少别人的子弟……
不过话说回来,先不论这个 BUG 的蔓延性,甲能够如此深入琢磨我的历史遗留物、并发现这个 BUG 实属难得——在软件中有一种 BUG 是最难被发现的:组合式的 BUG 。组合式的 BUG 有两种类型:相辅相成型、相互弥补型——甲在我代码中发现的 BUG 就属于后者。
相辅相成型:举例而言,一个 BUG 是楼梯很滑,另一个 BUG 扶手坏了,但除非这两个 BUG 同时存在,否则只有其中一个 BUG 是不足以让人摔下楼梯的。
相互弥补型:它与相辅相成型刚好相反,只有两个 BUG 同时存在(或不存在)程序才会正常运行。若只修正了其中一个 BUG ,另一个 BUG 就会曝露出来,而且会让人有误以为自己改错了的假象,因为修改之前程序是可以正常运行的。
之所以说它难以发现,因为组合 BUG 几乎无迹可寻,尤其是相互弥补型。除非是编译原理的狂热爱好者、抑或出现了非常极端的运行环境。**存在组合 BUG 的程序,其通常状态无异于正常程序,而且可能正常运行了很长时间都没有曝露出来**。
回到我的代码,它已经正常运行 5 年了。如果甲没有向我质问他心中的疑惑,而是擅自修改了他所发现的 BUG ,那么我的程序就无法正常运行了——而甲就很可能会因此陷入怀疑自己的正确性的境地。
消灭 or 圈养
事实上,不是所有 BUG 都需要解决掉的。很多时候我们明明知道正在为代码引入一个 BUG ,但是我们却依然保留它。因为回避它的代价太大了,我们宁愿限制它的前提条件不让它轻易发生、或者将其“圈养”起来(如 try-catch)不让它暴走——**如何容忍 BUG 也是一门学问**。
不过也总有一些技术葩喜欢另辟蹊径,誓言要代表月亮消灭所有 BUG 维护代码界安全 —— 先不说甲就是这种人,反正我是不会去消灭一个几年前就已经知道的 BUG 的。如果要消灭它,我当时就做了,何必等到现在。
这前面提到的“新 BUG 更易于旧 BUG 被解决”是一个原因,但我真正担忧的是我或许会引入更多不可控的 BUG ——代码的历史太久远了,我已经近乎忘记了它的逻辑,我一旦盲目修改,完全有可能采用了更危险的方法去解决那个稳定了 5 年的 BUG 。
很多时候,我们写完一段代码,只要程序能够编译运行、完成需求功能就算完成了,鲜有考究 BUG 的可能性,大部分的 BUG 都是通过日后使用时再去发现和解决的。其实解决 BUG 的黄金时间在于代码刚被编写的时候,这时候我们往往只需看到异常提示,就可以马上定位异常原因,因为潜意识中我们已经隐约觉得哪个位置会报什么异常了。
所以当我们在面对一些陈年 BUG 的时候,其实早就已经错过了解决它的最好时机。这时候不妨将其圈养起来,可能相比于消灭它,会令代码更安全。