在软件工程领域,有些 "老派" 的方法和理念,是经过时间检验的真理,值得我们重新审视和学习。
大多数大型软件开发项目都会使用编码规范,旨在规定编写软件的基本规则:代码应如何构建,以及应该使用和避免哪些语言特性,尤其是在代码的正确性会对设备产生决定性影响的领域,如潜水艇、飞机、将宇航员送上同步轨道的航天器,以及距离居民区仅几公里之外的核电站等设施运行的控制代码等。
在众多编码规范中,NASA 的编码规则以其严苛性和有效性反复被提起。近期,油管博主 ThePrime Time 发布的解读 NASA 安全编码规则的视频,甚至短时间内引发了超百万观看。
特别是在 AI 编程和“氛围编程”流行的当下,重新审视严谨、可验证的编程规范,是对软件工程本质的回归。
有声音说,“老派的 NASA 编码方式是最好的方式。”也有人评价,“在 C 语言中使用这些标准的编码人员是真正的战士。”
NASA 程序员在编写航天设备运行代码时都遵守一套严格的规则,这套编码规则由 NASA 喷气推进实验室(JPL)首席科学家 Gerard J. Holzmann 所提出,名为《The Power of Ten – Rules for Developing Safety Critical Code1》(十倍力量:安全关键代码开发规则)。
其在开头指出,“大多数现有的规范包含远远超过 100 条规则,而且有些规则的合理性存疑。有些规则,特别是那些试图规定程序中空白使用方式的规则(提到了 Python),可能是出于个人偏好而制定的。其他一些规则则是为了防止同一组织早期编码工作中出现的非常特定且不太可能发生的错误类型。毫不奇怪,现有编码规范对开发人员实际编写代码的行为影响甚微。许多规范最致命的方面是它们很少允许进行全面的基于工具的合规性检查。基于工具的检查很重要,因为对于大型应用程序编写的数十万行代码,手动审查通常是不可行的。”
ThePrime Time 对此表达了强烈地赞同,称“确实有很多个人偏好被写入了代码规范中。我认同目前提到的所有内容,代码就应该可靠。自动化和工具的使用应该杜绝个人偏好。”
NASA 的编码规则主要针对 C 语言,力求优化更全面检查用 C 语言编写的关键应用程序可靠性的能力。原因是,“在包括 JPL 在内的许多组织中,关键代码都是用 C 语言编写的。由于其悠久的历史,这种语言有广泛的工具支持,包括强大的源代码分析器、逻辑模型提取器、度量工具、调试器、测试支持工具,以及成熟稳定的编译器选择。因此,C 语言也是大多数已开发的编码规范的目标。”
ThePrime Time 表示,“我知道现在有很多软件开发人员,一听到用 C 语言编写安全关键代码,可能就会想‘怎么又是这个’ 。你们可没有像 ‘旅行者号’ (NASA 研制的太空探测器)那样的项目,你们还不是顶尖开发者。”
此外,Holzmann 认为,“为了有效,规则集必须很小,并且必须足够清晰,以便于理解和记忆。规则必须足够具体,以便可以机械地进行检查。当然,这么小的规则集不可能涵盖所有情况,但它可以为我们提供一个立足点,对软件的可靠性和可验证性产生可衡量的影响。”因此,他将 NASA 的编码规则限制在十条。
这十条规则正在 NASA 喷气推进实验室用于关键任务软件的编写实验,取得了令人鼓舞的成果。据 Holzmann 介绍,一开始,NASA 的开发人员对遵守如此严格的限制存在合理的抵触情绪,但克服之后,他们常常发现,遵守这些规则确实有助于提高代码的清晰度、可分析性和安全性。这些规则减轻了开发人员和测试人员通过其他方式确定代码关键属性(例如终止性、有界性、内存和栈的安全使用等)的负担。“这些规则就像汽车上的安全带,起初可能有点让人不舒服,但过一段时间后,使用它们会成为习惯,不使用反而难以想象。”
ThePrime Time 最后对 NASA 编码规则给出的整体评价是,“我喜欢这份文档,即便我并非完全认同其中所有的规则。我只是很惊讶,政府机构编写的内容竟如此条理清晰。这是一份极其连贯的文档,似乎出自一位追求务实的人之手。”
不少与 NASA 工程师共事过的开发者们,都对这则 NASA 十大编码规则的解读视频深有感触:“他们的编码指南并不‘疯狂’,反而实际上相当理智。我们没有以这种方式编程才是疯狂的”,并分享了许多个人的相关经历。
“在学习 C 语言的时候,我的教授曾为卫星编写 C 程序 / 代码。他把自己的方法教给了我们,这种方法要求我们在电脑上编程之前,先把所有内容都写在纸上。这种方式迫使我们准确理解自己正在编写的内容、内存分配等知识,还能编写出更高效的代码,并掌握相关知识。我很庆幸自己是通过这种方式学习的,因为在面试时,我能轻松地在白板上编写代码。”一名工程师说。
另一位与前 NASA 工程师共同开发过游戏的程序员透露,“他的代码是我见过的最整洁、最易读的。当时我还是一名初级程序员,仅仅通过和他一起编写代码,我就学到了很多东西。我们使用的是 C++ 语言,但他的编程风格更像是带有类的 C 语言。他的代码本身就很易于理解(具有自解释性),不过他仍然对自己的代码进行了注释(既有代码中的注释,也有实际的文档说明)。”
还有一位自述“和 NASA 一位级别很高的程序员关系非常密切”的开发者表示,“我听过很多故事,这些故事都能说明制定所有这些标准的合理性。客观来讲,从 Java 1.5 升级到 1.7 的成本,比从零开始重建任务控制中心(MCC)还要高。而重建任务控制中心是用 C 语言完成的,其中另一位首席工程师曾是 C++ 专家,他认定最初的 C 语言更可靠。”
同时有前 NASA 工程师出来现身说法道,“曾参与构建云基础设施,他们的指导原则可不是闹着玩的,代码审查简直是人间炼狱。‘严苛’这个词用来形容再贴切不过了。不过,相比我之前在电信、金融科技领域的工作经历,以及后来在其他科技公司的工作,我在 NASA 工作期间对可靠性方面的了解要多得多。”
对于这十条规则,Holzmann 已经声明,“为了支持强大的审查,这些规则有些严格,甚至可以说严苛。但这种权衡是有道理的。在关键时候,尤其是开发安全关键代码时,多费些功夫,遵守更严格的限制是值得的。这样我们就能更有力地证明关键软件能按预期运行。”并且,每条规则之后都附了其被纳入的简短理由。
ThePrime Time 对这些编码规则及理由一一进行了评价和分析,以下是经不改变原意的翻译和编辑后整理出来的解读内容。
将所有代码限制在非常简单的控制流结构中,不要使用 goto 语句、setjmp 或 longjmp 结构以及直接或间接递归。
理由:更简单的控制流意味着更强的验证能力,并且通常能提高代码的清晰度。禁止递归可能是这里最让人意外的一点。不过,如果没有递归,我们就能保证有一个无环的函数调用图,这能被代码分析器利用,还能直接帮助证明所有本应有限的执行实际上都是有限的。(注意,这条规则并不要求所有函数都有一个单一的返回点——尽管这通常也会简化控制流。不过,有很多情况下,提前返回错误是更简单的解决方案。)
ThePrime Time:
我不知道间接递归是什么意思。间接递归是指两个函数相互调用吗?在实际情况中,这种情况确实会发生,而且发生过很多次。比如有一个函数需要调用另一个函数去做某些事情并进行一些检查,然后再通过某种不受你控制的方式返回结果。特别是在那些没有异步 / 等待(async/await)机制的语言里,我猜你只能阻塞线程了,对吧?规则是说如果没有异步机制,就只能阻塞线程,然后在一个
while
循环里处理,是这样吗?我得想想我自己使用间接递归的场景。实际上,我在处理套接字重连时就用到了间接递归。具体来说,当我打开一个套接字(就像这样,打开这个套接字),重连机制会调用一个私有函数来重置状态,然后调用
reconnect
函数,
reconnect
函数会调用
connect
函数,当连接断开时,
connect
函数又会再次调用自己。从技术上讲,这就是一种间接递归。我在想,也许我可以把它改成用
while
循环加上等待机制,这样会不会更简单呢?现在真的让我开始思考这个问题了,这可能只是一种替代方案。
哎呀,我已经违反规则一了。不过间接递归确实是一种非常强大的无限问题解决机制,但我可以尝试不用它,对吧?我完全不介意尝试新的做法。理由很简单,“更简单的控制流意味着更强的验证能力,并且通常能提高代码的清晰度。”我认同这一点,确实看到代码里有类似这样的逻辑时会觉得有点绕。比如在一个 close 函数中调用 reconnect,然后在 reconnect 中检查是否已经启动,如果没有完成,就返回。这些语句的顺序会导致问题,所以我可以理解为什么会出现这种情况。我甚至可以说服自己接受这一点。
说实话,这是一个非常务实的观点,解释了为什么不应该使用递归。而且说句公道话,我大学三四年级的时候(不对,其实是大二),老师让我们用非递归的方式实现 AVL 树。如果你不熟悉 AVL 树,这是一种使用旋转的自平衡二叉搜索树,有四种不同的旋转:右旋、左旋、先右后左旋和先左后右旋。做起来其实很有趣,假设我们有一个二叉树,像这样:a(根节点),b 是左子节点,c 是右子节点。我们想重新组织成 b 作为根节点,a 作为左子节点,c 作为右子节点。如果你有一棵二叉树,看起来像 “a b c”,你就把它重组为 “a c b”,然后进行旋转。很简单直接,对吧?老师说我们要实现这个程序,但不能用递归。这对我来说是一次很棒的学习经历。
所有循环必须有固定的上限。检查工具必须能够轻易地静态证明循环的预设迭代上限不会被突破。如果无法静态证明循环的上限,就视为违反规则。
理由:没有递归且存在循环上限可以防止代码失控。当然,这条规则不适用于那些本就不打算终止的迭代(例如在进程调度器中)。在这些特殊情况下,适用相反的规则:必须能静态证明迭代不会终止。支持这条规则的一种方法是,给所有迭代次数可变的循环添加明确的上限(比如遍历链表的代码)。当超过上限时,会触发断言失败,包含失败迭代的函数将返回错误。(关于断言的使用,请参见规则 5)
ThePrime Time:
这是不是意味着不能使用像数组的
forEach
这样的方法呢?因为从技术上讲,其上限是根据数组动态变化的,没有固定值。还是说不能使用
while (true)
这种循环呢?这是一条有趣的规则。
初始化后不要使用动态内存分配。
理由:这条规则在安全关键软件中很常见,并且出现在大多数编码规范里。原因很简单:像 malloc 这样的内存分配器和垃圾回收器,其行为往往不可预测,可能会对性能产生重大影响。一类显著的编码错误也源于对内存分配和释放例程的不当处理,比如忘记释放内存、释放后继续使用内存、试图分配超过实际可用的内存,以及越界访问已分配的内存等等。强制所有应用程序在固定的、预先分配好的内存区域内运行,可以避免很多这类问题,也更容易验证内存的使用情况。需要注意的是,在不使用堆内存分配的情况下,动态申请内存的唯一方法是使用栈内存。在没有递归的情况下(规则 1),可以静态推导出栈内存使用的上限,从而可以证明应用程序将始终在其预先分配的内存范围内运行。
ThePrime Time:
这么一来,JavaScript 和 Go 语言可就有点麻烦了。不过说实在的,其实在 JavaScript 和 Go 里也能做到这一点。显然,在大多数情况下是可以的。但我敢肯定,只要调用类似
G Funk
(这里可能是随意提及的某个函数)这样的函数,它背地里肯定会分配一些你不知道的内存。当然,不是所有解释型语言都这样,这么说不太准确,不是所有解释型语言都有这个问题。
我猜这条规则意味着不能使用闭包,对吧?因为闭包会涉及到内存分配。准确地说,你得使用内存池(Arena)进行内存分配。也就是说,不能随意使用列表或字符串吗?也不是,其实可以使用列表和字符串,只是意味着所有内存分配都必须在程序开始时完成。我猜这里说的是堆内存,而不是栈内存。另外,我是这么理解的,比如说你从服务器获取一系列响应数据,你得事先分配一块足够大的内存区域,用来存储所有可能的响应数据,然后像使用环形缓冲区一样循环利用这块内存,这样就不会有额外的内存分配操作了,所有可能用到的数据都已经预先分配好了。所以一开始你就应该拥有所需的所有内存,这意味着可以使用字符串,只是得预先定义好字符串占用内存的大小。天呐,这得好好琢磨琢磨,确实很费脑筋,不过环形缓冲区的概念真的很有意思。
“在不使用堆内存分配的情况下,动态申请内存的唯一方式是使用栈内存。根据规则一,在没有递归的情况下,可以静态推导出栈内存使用的上限,这样就能证明应用程序始终在其预先分配的内存范围内运行。”这听起来有点疯狂,但其实挺酷的,仔细想想还挺有道理。
我听说在游戏开发里有这样一种做法,如果我说错了也请大家指正。在游戏开发中,每个子团队会有各自的资源预算,包括内存和 CPU 时间。在你负责的程序部分,你只能使用分配给你的那部分资源。一旦超出预算,就会有类似这样的提示:“嘿,物理模拟团队,你们用的时间太多了,能不能想想办法?” 我觉得这听起来挺不错的。我知道有这么回事,我举的这个例子是希望普通的游戏开发者也能理解。就好比今年两家小的游戏工作室因为资源超支没拿到奖金,大致就是这么个情况。宽泛来讲,实际情况比在 Twitch 聊天里说的要复杂一些,但差不多就是这样。我只是以一个普通游戏开发者的角度来解释这个规则,我开发过一些小游戏,但我也知道自己还算不上专业的游戏开发者。
任何函数的长度都不应超过以标准参考格式打印在一张纸上的长度,即每行写一条语句、每行写一个声明。通常情况下,这意味着每个函数的代码行数不应超过 60 行。
理由:每个函数都应该是代码中的一个逻辑单元,可以作为一个单元来理解和验证。跨越计算机显示器多个屏幕或打印时多页的逻辑单元要难得多。过长的函数往往是代码结构不佳的表现。
ThePrime Time:
好的,这挺合理的。60 行代码的空间足够你把事情弄清楚了。鲍勃大叔(Uncle Bob ,著名编程大师 Robert C. Martin)规定每个函数一般只能有三到五行代码,相比之下 60 行代码算很多了。不过这里说的是打印在一张纸上,对吧?就是说代码打印在单张纸上,大概就是这个意思。注意,这其实也不算特别严格的硬性规定,但从能打印在纸上这个角度来说,它又算是个硬性规定。我觉得 60 行代码能表达很多内容,肯定有办法打破这个规则,但我感觉自己通常能轻松写出最多 60 行代码的函数,我觉得这一点都不难。没错,Ghost 标准库有数千行代码,但我不会把 Ghost 标准库当作史上最整洁、最出色的代码之一。老实说,我个人觉得 Ghost 标准库读起来真的很糟糕。
这条规则的理由是,每个函数都应该是代码中的一个逻辑单元,能够作为一个整体被理解和验证。如果一个逻辑单元跨越计算机显示器的多个屏幕,或者打印出来有好多页,理解起来就困难得多。过长的函数往往意味着代码结构不佳。我基本上同意这个观点,我觉得实际上很少能见到超过 60 行代码的函数。而且一般来说,当你遇到这样的函数时,要么是因为功能本身非常复杂,由于行为的关联性必须写在一起;要么这个函数写得很糟糕。除非你写 React 代码,我觉得我说的这些还是适用的。
代码的断言密度平均每个函数至少应有两个断言。断言用于检查在实际执行中不应发生的异常情况。断言必须始终无副作用,并且应定义为布尔测试。当断言失败时,必须采取明确的恢复措施,例如,向执行失败断言的函数的调用者返回错误条件。任何静态检查工具能够证明永远不会失败或永远不会成立的断言都违反此规则。(也就是说,不能通过添加无用的 “assert (true)” 语句来满足该规则。)