分享

Bug是如何产生的?

 半佛肉夹馍 2023-10-20 发布于河南

所有bug都来自于“顾此失彼”

只不过,不同的编程/软件工程水准有不同的“顾此失彼”。

根据个人的一点经验,可以把导致bug的原因分为如下七个侧面:

一,语法

这是初学者需要迈过的第一道坎。

归根结底,程序,是一种给计算机阅读的格式文本。

和我们日常说话不同,程序的格式极其重要。

比如说,这句话就充满了编译器无法接受的语法错误。你看得出来吗?

幸好,编程语言只有很少几种“语法”,大概就是若干种声明语法(声明变量、函数、类,各有不同)、若干种赋值语法、若干种判断/分支语法以及若干种循环语法

对初学者来说,这些极其细节的语法是非常非常讨厌的。一行代码出四五个甚至一大堆编译错误是家常便饭。

更可怕的是,三行代码还可能各自有不同的语法错误、而且组合起来后恰巧又被识别成了另外一种语法、反而没了语法错误!

没错。看起来没有语法错误,并不能保证得到正确的结果。这是最大的问题。

举例来说,美国的火星探测飞船就曾经因为这种低级错误炸掉过,损失了五亿美圆。

二,数据类型

当你勉强能过去语法错误关、写出有意义的代码时,更大的老虎跳到了你面前。

没错。这就是数据类型关。

简单说,不同的数据有不同的性质、需要占用不同数量的内存空间、具有不同的意义。

比如,你的名字是字符串常量,你的年龄是int8_t,你的性别是boolean(但LGBT群体表示bool系统不足以表达更多更丰富的性别)……最后,关于你的所有信息被整合起来,又构成了一个新的用户自定义数据类型,比如people_info……

这个类型,在银行系统、警察系统、社保系统,还各不相同、各有侧重……

更可怕的,比如需求要求你计算一个班级的平均年龄;你把这么一堆int8_t加起来,打算除以班级总人数求平均值——bug就来了。

为什么?

因为int8_t最大只能表示到128,再大就溢出了,成了负数。

换成uint8_t?

可以。最大能表示到255。再大,又溢出了,绕回来成了0、1、2……

类似的,size_t不可能小于0,你的倒序循环稍不小心(比如探测<0)就会变成死循环——但int index呢,如果index从0开始,那么你不探测<0就会提前结束循环……

类似的,开/闭区间、半开半闭区间……稍有差池,“边界值”相关的bug就出来了。

然后,计算机里面还存在地址数据(指针),指针值和指针指向的值,你想清楚要哪个了吗?

然后,还嫌不够乱,很多语言还有“数据类型自动转换”服务……

——你累加int8_t?累加会溢出?看,我的编译器多智能!我会自动给你分配一个足够大的、足够存下中间结果的空间,确保你得到正确结果!

万一……我就是利用绕回,做环形链表呢?

——这个函数要求入参类型是float,而你传了int?别着急,我自动给你转换……嗯嗯,那个函数要求入参是int,你却传了个student类的对象?没问题,这也能自动类型转换……

可是,你知道这件事背后的意图吗?

总之,这玩意儿对初学者非常友好,不用多想,敲出来就有正确结果;但,当你需要更精细的控制一些东西时,当程序复杂到一定程度时,这些奇奇怪怪的“自动化特性”会让你一脸懵逼:我是谁?我怎么到了这里?我原本是想干什么?

三,算法

语法和数据结构都玩转以后,终于可以玩玩算法了……

可是算法啊……

不多说了。以前吐槽的够多了。

四,副作用

等你真正开始编程解决问题时,你会发现……

你的每一行代码,它并不仅仅解决你想解决的问题;而是伴随它的执行,它还会改变很多、很多……

被改变的东西包括CPU标志位、寄存器内容、栈状态、变量值,以及磁盘、网络甚至键盘CapsLock/NumLock状态——以及你自己的界面状态。

每一行代码执行之后,程序就会进入一个新的状态。这个状态会影响接下来的更多状态……

这东西实在太烦了,以至于那些函数语言研究者突然发现,函数式编程(λ演算,非图灵机体系)有个意想不到的优点,就是它可以没有变量!

没有变量,就不需要考虑什么状态了。乌拉!

可惜,这只是把问题推远了一步而已:图形界面本身就需要用不同的界面状态来提示用户,让他们知道当前状态是什么、下一步有什么选择!

甚至……对于C/C++,你知道i=i+++++i是什么意思吗?

它二三十年来一直被当做计算机二级的重要考点;但实际上,这是一个不被标准承认的错误写法;谁写出这种程序,编译器哪怕格式化他的硬盘、往他的BIOS里面烧写垃圾信息,都是符合C/C++标准的合法行为!

原因很简单,i++实质上相当于i=i+1,也就是它并不是一个简单的数学表达式,而是“具有赋值副作用的、特殊的程序语句”。

更有甚者,很多精巧的算法还会有意利用这些副作用……

你看,就连老老实实写一个程序都能把你难到抠脚;结果,程序语句都还不仅仅老老实实干自己的活,还要产生一大堆的、防不胜防扑朔迷离的副作用——而这些你本该考虑到、却没有能力掌控的副作用,可以轻而易举捣毁你的程序。

举例来说,c++的vector,你并不能简单的循环探测某个值是否等于X,是,你就调用remove方法把它删除——remove方法可能引起vector内存布局改变、导致迭代器失效;失效的迭代器是没法用作循环变量、执行自增/自减操作的。

你连语法都磕磕巴巴、数据类型朦朦胧胧、算法会抄不会写——那么,你怎么可能不被副作用玩成白痴啊!

五,左右互搏

绝大多数的实用程序,是不可能让你写个单纯的算法就蒙混过关的。

恰恰相反。实用程序经常是这个画风:

1、你要监控用户输入,及时做出相应。

2、你要监控网络传来的信息,及时更新界面状态。

3、为了舒服用户的眼睛,所有的状态改变不应该啪啪啪的直接切换,而是要有一个动画过渡。

4、你要把一些信息通过硬盘、网络或者数据库之类东西保存下来,或者通过声卡/显卡播放音乐/动画;保存/传输需要一定的时间,但这个过程是硬盘/网卡等硬件自动执行的,你只需要在它们告诉你可以接受新数据时继续传输即可。所以,请一边及时给网卡/硬盘喂数据,一边不要卡住用户的屏幕!

5、为了提高效率、降低能耗,不要写成死循环轮询。

实际上,大多数程序并不仅仅是只做上面列出的五件事;而是几十、几百件事……

而你的任务,就是安排一颗CPU,有条不紊的同时把这相互关联的几十、几百件事做好!

注意了,完成事件1可能引起A、B、C等五十个对象状态改变;这些改变可能影响事件2、3、4以及1自己。

甚至,当我们在事件1里面执行第9步时,事件2、7、10应该暂时退让,等事件1完成了第9步,它们才能继续执行——否则就会引起状态紊乱(术语叫脏读、脏写)。

但,由于你这倒霉孩子脑子不够清楚,事件1想要完成第9步,就必须先等事件6完成第3步;而事件6的第3步又依赖事件2的第七步……行了,现在大家转圈等吧,谁也动不了了。这个情况,术语叫死锁。

——这,就是多线程程序难写的原因。

但,问题是,如果这点困难就让你觉得多线程代码难写的话……

在多线程出现之前、或者无法利用多线程的场合、或者没有现成的框架时——比如用某种编程语言但不要用游戏引擎写一个俄罗斯方块——你需要做到是,在一颗没有多线程支持的CPU上,完成消息循环、支持不间断的音乐播放、允许画面上持续播放动画……

更可怕的是,与之同时,你可能还要处理两个玩家的同时输入、计算界面上的遮挡关系、优化算法剔除不必要的消耗……

然后,与之同时,你要做好dispatcher,换句话说就是自己实现一个协程类似物、把如上的一大坨安排的清清楚楚,这样才能在一颗CPU上、让所有任务都能及时响应,让用户的输入-响应灵敏而精准——嗯……这个怎么描述呢?简单说,键盘存在一个输入缓冲区;你取的不及时,用户之前的输入就“堆积”在里面,使得反应迟钝;但由于画面绘制/游戏逻辑可能较为耗时,按键堆积又是不可避免的;于是你要主动剔除过多的输入信息;但这个剔除太积极了,又会造成“丢输入”,让用户白操作半天,你的程序一点反应没有。更可怕的是,键盘的报告速度是可以调节的,同时游戏内部和界面的响应速度也各自天差地别,你的程序必须自动适应所有情况。一个典型的反例就是某个版本的grub,在这个版本它突然支持了splash,但按键处理没做好……

而你,必须克服所有的困难,让所有的功能流畅、精确,完全符合设计意图!

没错。妥妥的左右互搏。

不,并不仅仅是左右互搏。这是一只蜈蚣长着200只脚,每只脚上套着扳手、螺丝刀、套筒、游标卡尺等等不同的工具——而你,要指挥这200只脚相互配合!

这玩意儿叫双摆,也叫混沌摆。没错,仅仅是“复合”两个单摆、让它们互相影响,就出现了混沌……

同样的,哪怕只有两个相互影响的功能,你不能高屋建瓴的一下子看透它们的所有变化,那么出bug就是必然的——甚至,凭你的能力,是永远不可能写出正确的程序的。

而大多数软件,你需要同时解决的问题可不止是三五个、十几个、几十个……

幸运的是,大多数问题是没有相互影响的,别硬往一块扯,一个个搞定就行了;不幸的是,相互影响也是司空见惯——而你,如果一开始没能看透问题、随意动手的话,那么问题可能很快衍生出几十、几百个……

可想而知,一旦你稍有疏忽,后果会是什么……

六,专业知识

俗话说“隔行如隔山”;很不幸,编程往往需要你去理解另外一个行业——还不是泛泛的、螺丝钉水平的理解;而是要有大局观,要从每个犄角旮旯的一个小兵出发,把每条线在每个层级、每个节点的所有互动全都理解透彻。

比如说,写一个生产线管理软件,那么生产线上200个人,你就得像蜈蚣了解自己的200只脚一样,搞明白这200个人的工作、然后确定他们如何和电脑对接、对接后信息又如何流动、如何汇总、如何呈现、呈现给谁、如何加工处理……

当然,这并不算难。但也不容易。

尤其是,如果你不足够敏锐的话,人家内行觉得不言而喻、给你一笔带过的东西,将来就可能让你千万投资打水漂……

七,抽象

如你所见,真的在main函数里面搞一个大循环、硬生生为蜈蚣的二百只脚写驱动的话……这活是没人能干的了的。

怎么办呢?

没错,抽象。

比如说,TCP/IP协议栈恐怖吧?

喏:

皮毛。

没错,这么厚一本书,也只是皮毛。

举例来说,里面还有一大筐的流控算法呢,这本书哪有资格涉及。

那么,这么复杂的软件,没法写了?

并不是。我们可以分层、分块,做好抽象。

可以参考我的这个回答:

还有这个:

总之,好的抽象可以大幅降低软件设计的复杂度,使得我们无需同时的、全盘的考虑“蜈蚣的200只脚”,而是当成一堆小程序、一只一只脚的写一个简单算法、借助抽象出来的框架凑起来,程序就完成了。

但,很不幸,只有“好的抽象”才能“大幅降低软件设计复杂度”;一个垃圾抽象很可能把一个简单任务搞的……砸多少钱、多少人、多少时间,都别想写好它

我的这个回答就是一个实际案例:

总之,当你有能力、并且真的做出了一个不错的抽象时,你的程序就会变的简单、直白、易懂、不出错;反之……

这就是所谓的“会者不难,难者不会”。

更黑暗的总结

残酷的是,以上困境,并不因你的水平低下而消失

并不是说,我的能力到不了抽象层,做不了架构,所以架构bug就不可能在我的程序中出现……

很遗憾。并不是。

哪有这种好事。

你水平越差,你面对的问题就越难

因为,你面对的不仅仅是这七大困境,还要面对自己给自己制造的各种迷障……

比如说:

在这个案例中,我和陈一开始就帮助组员绕开了所有可能的问题、把解决方案压缩成5个功能点、几百行代码;而这个方案,新人以及那个滥竽充数的雷沐猴是看不懂的。

正因为看不懂,所以这个方案,在他们看来自然是“各种啰嗦”、“充满了困难”,怎么都搞不好——如果他们水平稍微高一些,哪怕看不懂,但照着做了,就会发现项目“神奇”的完全成功了,但却搞不明白为什么……

而雷沐猴的方案呢……你看,它给自己、给接手的田水污,挖了多少坑啊。

亦因此,在“如何正确提问”中,其中一个条款就是,问你遇到的、最开始的具体问题;不要问你的异想天开的解决方案的其中一个步骤——因为你的解决方案往往是非常蠢的;正是这种愚蠢的解决方案,才会把你导向某个非常复杂、非常困难的问题;而且解决了这个问题之后,还会引出一大堆的其他问题……

没错。当你水平不够、连语法都搞不对、数据类型懵懵懂懂、算法一塌糊涂时,全世界都在和你作对——你所处的公司,那些人模狗样儿的所谓领导,也会从一开始就制造出各种遍地是坑的所谓“架构”,坑死你没商量;而你,压根看不出其中的问题……

于是,你的程序,自然就越写越屎山了。

你水平越高、越是对整个领域了如指掌,你面对的问题反而越是简单

就好像之前的案例一样,在我和陈的面前,那个需要长篇大论才能说清楚的问题,那个给田水污、雷沐猴一辈子都搞不定的问题,被压缩成了五条独立的规则——然后,一劳永逸。

随便将来再提多少奇葩需求、多少界面美化方案;这个项目的核心代码都不再需要改变。

换句话说,蜈蚣有200条腿,对高手来说,压根不需要同时关注它们——把腿按功能分成三类,然后写三个逻辑,完事。

越是善于解决复杂问题的,反而越是善于简化方案;然后轻装上阵,基于这个最简化但却又最全面的方案,挑战进阶问题,是不是就轻松多了?

反之,越是看不出问题复杂在哪的,反而越是会把简单问题复杂化——或者更可怕的,复杂问题简单化,然后再就着错误的简单化无限的发挥,离答案越来越远。

更“可怕”的是:善于简化时,不光你面对的问题会变得简单;你能从计算机里面得到的帮助也会越来越多。

一旦你的水平足够、理解了所有的bug来源、汲取了先行者的各种经验教训;那么你可以从抽象、从架构开始,就把bug分化、限制、包围,稍有端倪就一把揪出来——然后,语法、数据结构、算法……一切的一切,都不再是你的障碍,反而都可以为你所用,都可以成为你的帮手,积极主动的给你打小报告,让bug无所遁形:

这就是著名的“不要写没有明显错误的程序,写明显没有错误的程序”。

在你能够把蜈蚣的200只脚安排的明明白白之前,不要假装自己理解了这句话。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多