软件 bug 的规律

本文是翻译,版权归原作者所有



一般地,在程序代码中犯错比不犯错更容易

不管看起来是否违反直觉,我觉得存在一种半正式的论据。在同样心态下,也有一些有趣的推论。


首先,为什么改错比犯错更难,尤其在软件工程中,更是如此?

让我们尽量从熵、混沌【注1】和有序的视角来分析。

下图是有序:

有序

下图是混沌:

混沌

我们大概都了解混沌和有序,通常不必费力思考原因的一个现象就是,我们更容易给系统创造混沌而非有序。一条狗很快就能把你的卧室搞乱,而整理好房间却需要视角和某种精神上的努力。再举个更为尖刻的例子:一家建筑公司倾其全力造好一栋建筑需要数年时间,而把该栋建筑破坏掉,只需要一个人,只要他足够多的炸药,瞬间就能完成。

创造比破坏难,原因在于系统内部所具有的混沌状态,要远远大于有序状态。当然,这取决于有序的定义和界定,但是,如果和无序状态比起来,任何合理的定义都会让你得到更少的、可能有序的状态。考虑一下卧室里的袜子:为了创造混沌,你只需肆意甩出去,不必多想。狗狗就能做到。但是,为了将来需要时能快速找到,整理这一双双袜子、并按照某种方式摆放好,很明显就需要更多努力,还有某种认知和思考。

因此,问题就变成了可能状态(混沌)的数量,和相应使其有意义的一个子集(有序)。


那么,程序代码中的错误是怎么回事?bug 究竟是怎样产生的?

当你的代码未能按照预期执行时,就说明存在 bug 了。(让我们把可计算性的话题放到另一篇博文,因为它比较复杂。某个任务在合理时间内,可能有解决方案,也可能没有,不过,我的意图是,存在可做的先验。我们简化一下问题:程序未能按照意图运行。)

比如,你需要一个函数,它返回两个 int 值的平均数(整数)。首先,你可能这样写代码:

int avg(int a, int b)
{
    return (a + b) / 2;
}

正确吗?

错。它没有考虑可能存在的整数溢出,而两个整数的平均数应该总是介于 ab 之间。换句话说,avg() 函数被期望总是返回一个(四舍五入)的结果,且不会溢出。

让我们修复一下。一个新手程序员可能会按照如下方式「修复」:

int avg(int a, int b)
{
    return a / 2 + b / 2;
}

当然,又错了。

在某个互联网论坛上,当被问到根据其最初形式修复 avg() 时,总是会给出建议,要求把参数的数据类型转换为浮点,再把结果四舍五入为 int 型:再说一次,糟糕想法会产生奇怪的结果,即使有最好的结果,也一定没有效率。两个 int 值的平均数计算竟然用到了浮点,你是认真的吗?

我们再考虑一种更好的方式。这一次,显然修复成了:

int avg(int a, int b)
{
    return a + (b - a) / 2;
}

正确吗?

不正确!尝试 avg(INT_MIN, INT_MAX) 的情况,你就明白错在哪里了。事实上,如果 a 或者 b 是负数,(b-a) 甚至在达到接近边界值 INT_MIN 和 INT_MAX 之前就相对容易溢出了。

最后的解决方案呢?如果我没有遗漏什么的话,我相信下面的代码就是最好的了:

int avg(int a, int b)
{
    if ((a < 0) != (b < 0))
        return (a + b) / 2;
    else
        return a + (b - a) / 2;
}

首先,上面代码中有趣的地方较为明显了,软件 bug 常常是没有考虑到、漠视或忘记了某些东西,也有可能是压根儿不知道。

在每行源代码里,存在着一定数量的对象,这些对象又包含了特定属性。在 avg() 例子中,对象有:a, b, 2, +, /, result。在 bug 让你损失数十亿美元的云服务或太空船之前,需要定位并修复它,你应该查看表达式/语句所涉及到的所有对象,并思考你掌握它们的所有知识。比如,ab 是 int 型,因此它们必定总是在某个限制内(现实往往比较残酷);+ 操作符在大多数语言中因为没有警告溢出而臭名昭著;除法容易搞砸 int 型,也禁止被零除。幸亏我们多多少少习惯了。

在现实生活中,它太简单了,以致于我们忘记、漠视或对上面提到的知识点一无所知。对于既定的一项任务,只存在很少的、形式上正确的方案,通常,有一种方案最简短,但是还有很大可能隐藏着 bug。如果你在写代码时,脑子里需要记住 10 个因素,只要你无视了其中一个,就会导致 bug 的产生。我们的大脑还不够完美:它倾向于忘掉那些不应该忘掉的东西。


我们对找到 avg() 的最佳解决方案的探求,应该已经提醒你注意卧室里、那条淘气的狗狗了。犯错很容易,而找到正确的方案,相对难一些。

为了采取相对正式的方法,假定你被安排了一项先验的计算任务,那么你需要决定为编码投入多少努力。有两个极端:零努力、无穷多的努力。

对于零努力的情形,如果你无论如何都要做点儿事情的话,那就是在机器上扔一个随机的位元流【注2】,观察运行情况。这种做法属于十足的混沌:用随机序列来解决某个特定问题的可能性微乎其微,就好像把一堆球扔到台球桌、还期望它们能够组成三角形。各种可能性太多,导致正确的可能性几乎不会发生。

对于无穷多努力的情形,为了找到最便捷、最牛逼的解决方案而投入了足够多的努力,当然你会找到的。这是有序,很难。

不是很难,而总是更难。

当你从零努力开始朝着积极方向前进时,随着你考虑越来越多的因素,也就有了越来越少的可能性,其中,很多可能性将是错误的、有 bug 的,只有很少一部分是正确的。朝着极限前进,会增加你编写正确解决方案的机会。

总结一下:

找到某项任务的正确解决方案,需要记住并应用一定数量的条件。漠视或不了解其中一种,将导致 bug 的产生。和掌握并应用所有相对知识相比,引入 bug 常常更容易,因为某些相关知识没被应用的情形有很多,而考虑了所有因素的情形少之又少。

观点被证明了吗?我想说,证明过了。


更多的思考。首先,修复软件 bug 不等同于找到了正确的解决方案:需要额外的时间,首先甄别出代码中让人不爽的地方,然后思考并找到更好的解决方案。

但是,找到正确的解决方案,要比修改代码 bug(程序错误的规律)投入更多的努力,这个事实加剧了状况的恶化。因此 bug 对项目造成的损失就等于:按照错误方案编写代码所消耗的时间,加上后续寻找和甄别错误的时间,再加上团队/公司所遭受的道德或财务上的损失。这样:

推论 1:修改 bug 比起一开始就不引入 bug,需要更多的努力(常常不成比例)。

bug 存在一个至关重要的、且常被忽略的副作用,那就是一段时间以后,新员工总体优势的下降,并因此引起了 bug 产生率的进一步增加,等等。产品最初的原型阶段,常常由一组技术底子好的优秀程序员完成,一旦过了这个阶段,就进入了相对乏味和修复 bug 的工作阶段。此时,团队开始进入成本削减的螺旋上升和产品质量下降的阶段。新员工竞争力相对低些(因为没有人愿意做无聊的工作和修复 bug),因此带来了更多的 bug 等等。过了一段时间,如果管理方面不做一些非常规测量(也从没做过),软件团队就达到了某种均衡,50% 的时间花在了修复代码上(正如这项研究所展示的,尽管研究没有提到最初的原型阶段,不过,有趣的地方在于,那时候产生的 bug 也不多),更多的早期优秀员工/创始人离职、或不再写代码了。所有这些因素一定导致了一种结果,催生了第一个 bug 和糟糕的最初设计。

推论 2:低竞争优势的团队成员给团队工作造成的危害,远远大于团队竞争优势缺乏和薪水降低所带来的危害。

最终,就像某个物理系统进入混沌状态、且处于「无人值守」的境地一样,软件项目步入了杂乱无章,典型的软件血汗工厂,充斥着得意忘形的团队、不可维护且 bug 林立的代码库、财务损失、了然无趣。到达混沌比远离混沌要容易得多。这就到了:

推论 3:在产品生命周期的各个阶段,只有有意识的协作努力,才能使 bug 最少,也才能为享受编程和削减开发成本提供保障。因此,任何编程「方法论」,如果在最后没有论证出减少的 bug 产生率,那么,不管它说得多么天花乱坠,都是扯淡。

推论 3.1:如果你独自编程,那也挺好。只要确保别把 bug 和 「让人厌烦」的工作悄悄带入你的项目,就没有雇佣更多人的必要。)

注释

译文:软件 bug 的规律 》| 腊八粥