查看原文
其他

编程真可怕,我们日常都在写 Bug

tef CSDN 2018-08-18

作为开发者,我们一直走在写 Bug 的路上,而什么样的代码才是最好的?又该如何掌握调试的正确姿势呢?


编写易于删除且易于调试的代码


可以调试的代码那必然是不如你大脑聪明的代码。现实生活中,我们总会遇到一些不好调试的代码,比如有隐藏行为的代码、错误处理很糟糕的代码、意义模糊的代码、结构化程度太低或太高的代码,或者在修改过程中的代码。如果项目的规模足够大,那你最终会遇到你无法理解的代码。

在老项目上,你根本不记得你写过哪些代码,如果不是提交日志,或许你会认为那些都是别人写的。随着项目规模的增长,想要记住每部分代码的功能变得越来越难,如果代码的行为与预期的不一致就会难上加难。在修改你不理解的代码时,你只能用最难的方式来参透:调试

编写易于调试的代码,第一步就是要认识到:你以后会忘记你曾写过的所有代码。其次,就要遵循以下几条规则:


好的代码也会有缺点


许多传教士都说,编写易于理解的代码本质,就是编写干净的代码。这句话的关键点在于“干净”这个词,它的意思完全依赖于语境。有时,代码干净的原因是不好的代码被写入别的地方了。因此,好的代码不一定是干净的代码。

代码干净还是肮脏,其实更多是在评价你作为开发者对于这段代码的自尊心,或者说是羞耻心,而不是评价它是否易于维护或修改。因此,我们追求的并不是干净的代码,而是那种修改方式一目了然的“无聊”的代码。我发现,这种任何修改都触手可及的代码更容易让他人做出贡献。因此,最好的代码就是能很快弄明白的代码:

  • 不要想着让丑陋的问题变得好看,或者让无聊的问题变得有趣。

  • 错误应当很明显,行为应当是清晰的。我们不需要没有明显错误和晦涩行为的代码。

  • 代码的文档不需要追求完美。

  • 代码的行为十分明显,任何开发者都可以想出无数种修改方法。

有时,代码看起来很恶心,但任何试图修改它的行为都会让它变得更糟糕。在不理解后果的情况下编写干净的代码无异于试图召唤可维护代码。

并不是说干净的代码不好,而是说有时候编写干净的代码更像是把脏东西藏到地毯下面。可调试的代码不一定要干净,而充满了错误检查和处理的代码通常读起来并不愉快。


计算机总会崩溃


计算机总会卡住,程序永远会在上次运行时崩溃。

程序员应当做的第一件事就是在启动时确保一个已知的、良好的、安全的状态,再进行任何其他工作。有时候会由于用户删除、电脑升级等情况导致状态不干净。程序上次运行时会崩溃,再次启动时不应当陷入相互矛盾的状态,而是永远像第一次运行一样干净。

例如,如果要从文件中读写状态,那么可能会发生以下一系列问题:

  • 文件丢失;

  • 文件破损;

  • 文件是旧版本,或比程序还新的版本;

  • 上次对文件的修改未完成;

  • 文件系统返回了错误的数据;

这些并不是新问题,数据库系统从时间开始的那一刻起(1970年1月1日)就在处理这些问题了。使用 SQLite 之类的东西会帮你处理许多类似的问题,但如果程序上次运行时崩溃了,那么代码运行时也许会遇到错误的数据,或者以错误的方式运行。

以定时运行的程序为例,我可以保证下面这些事故一定会发生:

  • 夏令时导致程序在同一时刻运行两次;

  • 由于操作员忘记它已运行过,而导致运行两次;

  • 由于机器磁盘空间满,或神秘的网络问题而错过某次运行;

  • 运行时间超过一小时,导致后续的运行被延误;

  • 在一天内的错误时间运行;

  • 由于不可避免地在边界时间(如午夜、月末、年末)运行而导致算术错误。

编写强壮的软件的第一步,就是要假设上次运行的结果是崩溃,而且需要在不知道如何进行下一步时主动崩溃。抛出异常的最好方法就是在异常中留下类似于“这个状况不应当发生”之类的注释,这样一旦发生,就能知道应当从何处开始调试。

易于调试的代码需要在执行操作之前检查情况是否正确,可以轻松返回到已知良好状态并再次尝试,并且拥有多层防御,使得错误尽早浮现。


代码会跟自己打架


Google 最大的 DoS 攻击来自于自己。我们的系统非常庞大,尽管一直都有人提出给我们的系统做收费的压力测试,但我们认为我们自己才是最适合做这项工作的人。

对于任何系统都是这样。

——AstridAtkinson,Long Game 的工程师

软件总会在上次运行时崩溃,也永远会用尽所有 CPU、占据所有内存,还会用光所有硬盘。所有的工作进程都会遇到空队列,每个进程都会重试超时的网络请求,所有服务器都会在同一时间暂停进行垃圾回收。系统不仅会被破坏,而且还会随时尝试破坏自己。

就连想检查系统是否真的在运行,都可能非常困难。

实现检查服务器是否运行的代码很容易,但如果服务器不能处理请求,就没那么容易了。除非你去检查 uptime,但有可能程序在两次检查之间崩溃。健康检查也可能会触发 Bug:我曾经写过一个健康检查代码,但在三个月后,那段代码却让它保护的代码崩溃了两次。

在软件中,编写错误处理代码会不可避免地导致更多需要处理的错误,其中许多错误都是由错误处理代码本身导致的。类似地,性能优化经常会成为系统的瓶颈——让应用在一个标签内运行得很流畅,会使得 20 个副本同时运行时变得很难用。

还有个例子,流水线中的某个工作进程运行得太快,在流水线中的下一步骤执行之前耗光了所有内存。用汽车打个比方,那就是堵车。堵车的罪魁祸首就是超速,而且堵车可以认为是拥塞部分在车流中向后移动。优化会导致系统在高压力下以某种神秘的方式崩溃。

换句话说,进程越快,就越难被推延,而如果系统不能推延该进程,那么崩溃就在所难免了。

反向压力是系统内的反馈的一种,而容易调试的程序能够让用户参与到反馈循环中,查看系统的所有有意或无意、需要或不需要的行为。可调式的代码很容易检视,从而可以观察并理解其内部发生的一切。


现在不弄清楚,以后就得调试


换句话说,查看程序中的变量的含义并弄清楚它发生了什么应该不难。使用某种线性代数的过程,应该可以将代码的状态以尽可能清晰的方式表示。也就是说,不要做类似于在程序中土改变变量含义的事情,即把一个变量用于两个不同的用途。

这也意味着要避免半谓词问题,即永远不要用一个变量(count)表示一对值(boolean, count)。不要做类似于返回正数表示结果,返回 -1 表示没有匹配的事情。理由很简单,有可能会出现“0,但为真”的需求(需要提一句,Perl 5就正好有这个功能),或者写出的代码很难与系统中的其他部分组合(对于下一个程序来说,-1可能是个有效的输入,而不是错误)。

除了把一个变量用作两个用途之外,为一个用途使用两个变量也同样糟糕,特别是两个都是布尔值的情况。我并不是说用两个值表示一个范围糟糕,而是说用多个布尔值表示程序的状态的情况。后者的本质通常是个状态机。

如果状态的流向不是从顶至下,比如是个循环,那么最好是给状态定义一个变量,并清理下逻辑。如果在一个对象内部有多个布尔值,可以用一个名为state的枚举变量(如果需要保存的话,也可以使用字符串)替换它们。if语句就可以写成if state == name,而不是if bad_name &&!alternate_option。

即使显式写出状态机,也有可能写出糟糕的代码:一些代码可能会包含两个状态机。我在写一个HTTP代理时遇到了极大的困难,直到我明确写出每个状态机,并分别对连接状态和解析状态进行跟踪之后才解决。如果把两个状态机合成一个,那就很难添加新状态,或者判断当前处于什么状态。

这一条更多的是在讨论如何让代码免于被调试,而不是使之容易调试。列出有效的状态,可以更容易地拒绝无效状态,而不是在无意中允许一两个无效状态通过。


无意的行为就是预期的行为


如果你不能深刻理解某个数据结构,用户就会来填充空白,使得你的代码的任何可能的行为,有意的或无意的行为,最终出现在某个地方。比如,许多主流编程语言都有哈希表,在多数情况下,哈希表在遍历时通常会保持插入时的顺序

一些语言会让哈希表尽可能地符合大多数用户的预期,按照键值添加的顺序去遍历,但另一些语言会在每次遍历时使用完全不同的顺序返回。后者的情况下,一些用户反而会抱怨这个行为的随机性不够。

可悲的是,程序中的任何随机性最终都会被用于统计模拟过程,或者更糟糕的情况下会被用于加密,任何顺序最终都会被用于排序。

在数据库中,一些标识符包含的信息要比其他标识符更多。创建表时,开发者可以用不同的类型作为主键。正确的做法是使用 UUID,或类似于 UUID 的东西。其他类型不仅会提供唯一性,还会提供顺序,即不仅会提供 a == b,还会提供 a <= b,有些甚至直接使用自增类型。

自增类型会在每次表中加入新行时自动增加 1。这就导致了模糊的顺序——人们无法判断数据中的哪个属性才能被用作排序的基准。换句话说,是应该按照键值排序,还是应该按照时间戳排序?就像前面说过的哈希表一样,人们会自己决定他们认为正确的做法。这种方式的另一个问题是,用户还能很容易地猜到主键附近的其他记录。

最终,任何自认为比 UUID 聪明的方案都会误伤自己。我们尝试过邮政编码、电话号码、IP 地址,无一不以失败告终。UUID 可能不会让代码更容易调试,但更少的无意行为意味着更少的事故。

人们从主键中得到的信息不仅仅是顺序。如果数据库的主键是通过其他字段构建的,那么人们会抛弃其他数据,而直接利用主键来重构其他数据。这样就有两个问题了:程序的状态被保存在两个以上的地方,这两者很容易出现不一致。如果无法确定哪个已被改变,哪个需要被改变,那么想要同步都不可能。

不管你允许用户做什么,他们最后都会去做。编写可调试的代码意味着提前考虑数据被误用的情况,考虑其他人可能怎样使用这些数据。


调试先是社会过程,再是技术过程


当软件项目分成多个组件和系统时,寻找 Bug 通常会变得非常困难。在理解 Bug 的发生原因后,通常需要与多个团队进行协调,才能改正 Bug。在大型项目中修改项目的主要工作并不是寻找 Bug,而是说服其他人 Bug 的存在,甚至要说服他人该 Bug 是可修复的。

软件中到处都存在 Bug,因为没人肯定谁该为 Bug 负责。换句话说,如果责任不明确,那调试代码就会很困难,任何事情都要先在 Slack(聊天群组工具) 上询问,而只有等到真正知道的人上线后,这些问题才会得到回答。

计划、工具、过程和文档正是解决这个问题的关键。 

计划可以避免意外的压力,规划好的结构可以管理事故。计划可以让客户了解项目进展,在需要时更换人员,并跟踪问题、引入变更以减少未来的风险。工具可以降低工作需要的技能,使得他人也可以完成工作。过程可以避免依赖个人的控制,将控制权交还给团队。

人会变,交流也会变,但过程和工具会在团队中一直传承下去。这并不是说后者的变化的意义大于前者,而是需要通过构建后者来支持前者的变化。流程也可以起到消除团队控制的作用,所以并没有好坏区分,但是总有一些流程会起作用,即便没有写下来,记录文档的行为是让其他人改变它的第一步。

文档并不仅仅是 .txt:文档是关于如何交付职责、如何让新人加快速度,以及如何将变更后的内容传达给受这些变更影响的人的方式。编写文档需要比编写代码更多的感情交流,也需要更多的技巧,它不像代码那样只需简单的编译器标记或类型检查器就能保证正确,并且很容易写很多言之无物的文档。

如果没有文档,你怎能期望人们做出明智的决定,甚至同意使用软件的后果呢?没有文档,工具或流程,就无法分担维护的负担,甚至无法替换目前负责该任务的人员。

简化调试同样适用于编程等代码本身的流程,你需要搞清楚必须站在什么位置上才能修复 Bug。


易于调试的代码很容易解释


调试时常见的情况,就是在向其他人解释问题时就会发现解决问题的关键。因此,即便没有人在,你也必须强迫自己从头开始解释情况、问题,以及重现步骤。通常,这个过程足以让我们找到答案。

当我们寻求帮助时,我们经常会没有问到点上,而且我和所有人一样对此感到郁闷——事实上,这是一个常见的烦恼问题,它有一个名字叫做:“X-Y 问题”:我怎样才能拿到文件名的最后三个字母?哦?不,我想说的是怎样获取文件扩展名。

我们从自己理解的解决方案出发谈论问题,并且从自己意识到的后果出发来讨论解决方案。调试是了解意外后果和找到替代解决方案的艰难方法,调试还涉及程序员最难做到的事情之一:承认他们错了。

毕竟,这不是编译器的 Bug。

原文:https://programmingisterrible.com/post/173883533613/code-to-debug

作者:tef

译者:弯月,责编:屠敏


征稿啦

CSDN 公众号秉持着「与千万技术人共成长」理念,不仅以「极客头条」、「畅言」栏目在第一时间以技术人的独特视角描述技术人关心的行业焦点事件,更有「技术头条」专栏,深度解读行业内的热门技术与场景应用,让所有的开发者紧跟技术潮流,保持警醒的技术嗅觉,对行业趋势、技术有更为全面的认知。

如果你有优质的文章,或是行业热点事件、技术趋势的真知灼见,或是深度的应用实践、场景方案等的新见解,欢迎联系 CSDN 投稿,联系方式:微信(guorui_1118,请备注投稿+姓名+公司职位),邮箱(guorui@csdn.net)。


————— 推荐阅读 —————




文章已于修改

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存