作为开发者,我们一直走在写 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
译者:弯月,责编:屠敏