像 NASA 一样编码的 10 条规则(适用于解释型语言)
前言——亲爱的初学者、亲爱的非初学者、亲爱的读者。这篇文章内容很丰富,需要你从不同的角度去理解。偶尔,不妨退一步,重新思考一下这里解释的所有概念。这些年来,它们帮助了我很多,我希望它们也能对你有所帮助。这篇文章是我根据自己的工作(主要是 Web 开发)对这些概念的解读。
美国宇航局的喷气推进实验室 (JPL ) ,负责一些最令人惊叹的科学研究,以其“十的幂”规则而闻名(参见原文)。事实上,如果你要把一个机器人送到火星,ping 时间只有 40 分钟,而且没有物理访问权限,那么你一定要确保你的代码没有 bug。
这些规则是为嵌入式软件制定的,但为什么不是每个人都能从中受益呢?我们能把它们应用到 JavaScript 和 Python 等其他语言上吗?这样一来,Web 应用程序就更稳定了。
这是我多年来一直在思考的问题,以下是我对适用于解释型语言和 Web 开发的 10 条规则的解读。
1. 避免使用复杂的流程结构,例如 goto 和递归。
原始规则——将所有代码限制为非常简单的控制流结构——不要使用
goto
语句setjmp
或longjmp
结构以及直接或间接递归。
goto
当你使用奇怪的结构时,你的代码会变得难以分析和预测。在被认为有害之后出现的几代代码确实避免使用它。我们目前正处于讨论continue
是否goto
应该禁止使用它的阶段。
我的看法是,在循环中,它和在 a 中continue
完全一样(尤其是在 JS 有了块级作用域的情况下),所以如果你这么说,那你基本上就是在对这个问题视而不见。但这是 JS 特有的实现细节。return
forEach()
continue
goto
一般来说,你应该避免所有令人费解或难以发现的事情,因为如果你的脑力花在理解跳跃的怪癖上,那么你就不会把它花在实际的逻辑上,然后你可能会在不知情的情况下隐藏一些错误。
我会让你自己判断把什么放在这个类别中,但我肯定会把:
goto
当然- PHP
continue
和break
数字一起使用,这简直是疯狂 switch
构造,因为它们通常需要一个break
来关闭块,而且我保证会有错误。一系列的if
/else if
可以以不混淆的方式完成相同的工作
除此之外,当然要避免递归,原因如下:
- 由于它们建立在调用栈上,而调用栈的大小非常有限,你无法真正控制递归的深度。即使你的代码合理,也可能因为递归次数过多而失败。
- 你在做递归的时候有没有这种感觉?你真的不知道代码什么时候会停止?很难想象一个递归,也很难证明它最终会正确停止。
- 使用迭代算法而不是递归算法也更符合以下规则,因为您可以(再次)更好地控制所处理问题的大小
值得一提的是,递归通常可以作为算法的直观实现,但通常远非最佳。例如,在求职面试中,我们经常被要求使用递归函数来实现阶乘函数,但这远不如迭代实现效率高。正则表达式也可能带来灾难性的后果。
2. 所有循环必须有固定的边界。这可以防止代码失控。
原始规则——所有循环都必须有一个固定的上限。检查工具必须能够静态地证明循环迭代次数的预设上限不会被超过。如果无法静态地证明循环上限,则认为该规则被违反。
这条规则的理念与禁止递归相同:你想要防止代码失控。实现这一点的方法是确保能够很容易地静态证明循环不会超过给定的迭代次数。
我们用 Python 举个例子。你可以这样做:
def iter_max(it, max_iter):
cnt = 0
for x in it:
assert cnt < max_iter
yield x
cnt += 1
def main():
for i in iter_max(range(0, 100), 10):
print(i)
然而,像 Python 这样的语言在很多情况下会自行限制迭代次数。所以,如果你能证明输入列表不会太长,很多情况下就不需要这样做了。
一个很好的应用就是分页:确保你使用的页面大小合理,这样你就不需要永远运行的循环。始终思考你的代码,让它只处理有限量的数据,并让专门为此设计的工具(比如你的数据库引擎)来处理无限量的数据。
3.避免堆内存分配。
原始规则——初始化后不要使用动态内存分配。
当然,这在解释型语言中毫无意义,因为几乎所有内存都是动态分配的。但这并不意味着这条规则不适用于它们。这条规则的核心思想是,除了在 C 语言中必须使用的繁琐的内存管理技术之外,设定程序内存消耗的上限也非常重要。
因此,对于解释型语言来说,这意味着当您编写代码时,您应该能够知道,对于任何接受的输入,内存消耗都不会超过某个点。
虽然很难绝对地证明这一点,但有一些很好的线索和原则可以遵循。更具体地说,重复前面的部分,分页是一项必不可少的技术。如果您只处理页面,并且知道每个页面的内容是有限的(数据库字段的长度有限等等),那么很容易证明至少来自这些页面的数据可以包含在一个上限之内。
因此,如果您再次使用页面,您就会知道分配给每个页面的内存是合理的,并且您的系统可以处理它。
4. 将功能限制在单个打印页面上。
原始规则——任何函数的长度都不应超过标准参考格式在一张纸上打印的长度,即每个语句一行,每个声明一行。通常,这意味着每个函数的代码行数不超过 60 行。
这是两件不同的事情。
首先,人脑只能完全理解有限的逻辑,符号页面看起来也差不多。虽然这个估计完全是随意的,但你会发现,你可以轻松地将代码组织成与代码大小差不多或更小的函数,并且很容易理解这些函数。没有人喜欢遇到一个 1000 行、却似乎同时执行无数操作的函数。我们都经历过这种情况,我们知道不应该发生这种情况。
其次,当函数很小——或者说尽可能小——时,你可以考虑赋予它尽可能低的计算能力。让它处理最小的数据单元,并使其成为一个非常简单的算法。这将使你的代码解耦,并使其更易于维护。
让我强调一下这条规则的任意性。它之所以有效,正是因为它的任意性。有人决定他们不希望看到一个函数超过一页纸的长度,因为如果再长的话,使用起来就不方便了。而且他们也注意到这是可行的。起初我拒绝了这条规则,但十多年后,我必须说,只要你遵循上述任何一个目标,你的代码就总能写进一页纸里。所以,是的,这是一条好规则。
5.每个函数至少使用两个运行时断言。
原始规则——代码的断言密度平均每个函数至少应有两个断言。断言用于检查实际执行中绝对不会发生的异常情况。断言必须始终没有副作用,并且应定义为布尔测试。当断言失败时,必须采取明确的恢复措施,例如,向执行失败断言的函数的调用者返回错误条件。任何静态检查工具能够证明其永远不会失败或永远不会成立的断言都违反了此规则。(即,不能通过添加无用的“assert(true)”语句来满足此规则。)
这很棘手,因为您需要了解什么才算断言。
在原始规则中,断言被视为布尔测试,用于验证“函数的前置条件和后置条件、参数值、函数的返回值以及循环不变量”。如果测试失败,则函数必须采取相应措施,通常返回错误代码。
在 C 或 Go 的上下文中,它通常就这么简单。在几乎所有其他语言的上下文中,它意味着引发异常。并且根据语言的不同,很多断言都是自动生成的。
以 Python 为例,你可以这样做:
assert "foo" in bar
do_something(bar["foo"])
但如果这样做也会引发异常,那么为什么还要费心呢?
do_something(bar["foo"])
对我来说,当输入很差劲时,总是很想通过回退到默认值来让输入值看起来总是正确的。但这通常没什么用。你应该尽可能地让代码出错,并使用异常报告工具(我个人喜欢Sentry,但市面上也有很多)。这样你就能知道哪里出了问题,并能够修复你的代码。
当然,这意味着你的代码会在运行时失败。不过没关系!运行时可不是生产环境。如果你在将应用程序投入生产之前进行全面的测试,就能发现大部分错误。这样,你的真实用户也会遇到一些错误,但你也会收到通知,而不是默默地失败。
补充一点,如果你无法控制输入,比如你正在通过示例 API 进行操作,那么失败并不总是一个好主意。如果输入不正确,抛出异常,你会得到 500 错误,这实际上并不是一个表示错误输入的好方法(因为它更可能是 4xx 状态码范围内的某个值)。在这种情况下,你需要事先对输入进行适当的验证。但是,根据代码使用者的不同,你可能希望或不希望报告异常。以下是一些示例:
- 当外部工具调用你的 API 时,你需要报告异常,因为你想知道外部工具是否出了问题。
- 您的其他服务调用了您的 API。在这种情况下,您也需要报告异常,因为这是您自己的操作错误。
- 普通公众会调用您的 API。在这种情况下,您可能不希望每次有人犯错时都收到电子邮件。
简而言之,这一切都是为了了解您感兴趣的故障,以提高代码的稳定性。
6.将数据范围限制到尽可能小。
原始规则——数据对象必须在尽可能最小的范围级别上声明。
简而言之,不要使用全局变量。将数据隐藏在应用程序内部,并确保代码的不同部分不会相互干扰。
您可以将数据隐藏在类、模块、二阶函数等中。
不过,当你进行单元测试时,你会发现这有时会适得其反,因为你只想在测试时手动设置这些数据。这可能意味着你需要隐藏数据,但保留一种通常不会使用的方法来修改它。这_name
在 Python 或private
其他语言中很常见(但仍然可以通过反射访问)。
7. 检查所有非 void 函数的返回值,或者强制转换为 void 以指示返回值无用。
原始规则——每个调用函数必须检查非 void 函数的返回值,并且必须在每个函数内部检查参数的有效性。
在 C 语言中,最常用的错误指示方式是通过相应函数的返回值(或通过引用错误变量)。然而,大多数解释型语言并非如此,因为错误是通过异常来指示的。即使是PHP 7也对此进行了改进(即使你执行了一些非致命操作,仍然会在 JSON 中间以 HTML 格式打印警告)。
所以实际上这条规则是:让错误冒泡,直到你能够处理它们(通过恢复和/或记录错误)。在支持异常的语言中,这很简单,只要在能够正确处理之前不要捕获异常即可。
换个角度来看:不要过早捕获异常,也不要默默地丢弃它们。异常的本质是必要时导致代码崩溃,处理异常的正确方法是报告它们并修复错误。尤其是在 Web 开发中,异常只会导致 500 响应代码,而不会导致整个前端严重崩溃。
8. 谨慎使用预处理器。
原始规则——预处理器的使用必须仅限于包含头文件和简单的宏定义。不允许使用标记粘贴、可变参数列表(省略号)和递归宏调用。所有宏必须扩展为完整的语法单元。条件编译指令的使用通常也存在争议,但并非总是可以避免。这意味着,即使在大型软件开发工作中,除了避免多次包含同一头文件的标准样板之外,也很少需要使用超过一两个条件编译指令。每次此类使用都应由基于工具的检查器标记,并
在代码中说明其合理性。
在 C 代码中,宏是隐藏混乱的特别有效的方法。它们允许你生成C 代码,就像编写 HTML 模板一样。很容易理解它会被滥用,实际上你可以看看IOCCC 的参赛者,他们通常大量使用 C 宏来生成完全不可读的代码。
然而,C(和 C++)几乎是唯一使用这种机制的主流语言,那么如何将其翻译成其他语言呢?我们解决了这个问题吗?将代码编译成其他代码然后再执行,听起来是不是有点耳熟?
是的,我说的是我们在 Webpack 配置中放入的大量东西。
初始规则承认宏的必要性,但要求将其限制为“简单的宏定义”。Webpack 的“简单宏”是什么?好的转译器和坏的转译器分别是什么?
我的理由很简单:
- 保持堆栈尽可能小。转译器越少,需要处理的复杂性就越低。
- 尽可能保持主流。例如,即使在 Python 或 PHP 项目中,我也始终使用 Webpack 来编译 JS/CSS。然后,我会对清单文件进行简单的包装,以便在服务器端获取正确的文件路径。这使得我能够与其他 JS 框架保持兼容,而无需编写太多简单的包装器。换句话说:远离Django Pipeline之类的东西。
- 尽可能接近真实代码。使用 ES6+ 是个不错的选择,因为它是之前 JS 版本的超集,所以你可以将转译视为一个简单的兼容性层。但我不建议将 Dart、Python 或类似的语言转译成 JS。
- 只有当它能为你的日常工作带来实际价值时才去做。例如,CoffeeScript 只是 JavaScript 的混淆版本,所以可能不值得你费心,而像 Stylus/LESS/Sass 这样将变量和 mixins 引入 CSS 的工具,会极大地帮助你维护 CSS 代码。
您自己可以判断哪些转译器适合您的项目。只是不要让自己被那些不值得您浪费时间的无用工具所困扰。
9. 将指针的使用限制为单次取消引用,并且不要使用函数指针。
原始规则——应限制指针的使用。具体来说,最多允许一层的解引用。指针解引用操作不得隐藏在宏定义或 typedef 声明中。不允许使用函数指针。
任何做过 C 语言基础示例的人都知道指针有多让人头疼。这就像《盗梦空间》,只不过在计算机内存中,你真的不知道该如何深入地追踪指针。
举个例子,我们需要一个qsort()
函数。我们希望能够对任何类型的数据进行排序,但在编译之前不需要知道任何相关信息。看一下函数签名:
void qsort( void *ptr, size_t count, size_t size,
int (*comp)(const void *, const void *) );
这绝对是你在标准库文档中见过的最令人恐惧的不安全的事情之一。然而,它允许标准库对任何类型的数据进行排序,而其他更现代的语言仍然有一些略显笨拙的解决方案。
当然,当你为这类事情打开大门时,你也就为各种指针的疯狂打开了大门。正如你所知,大门一旦打开,人们就会通过。因此,C 语言就有了这条规则。
但是对于解释型语言来说呢?我们首先会解释为什么引用不好,然后解释如何实现编写泛型代码的初衷。
不要使用引用
指针并不存在,但一些古老而晦涩的语言,例如PHP,仍然认为拥有它是个好主意。然而,大多数其他语言只使用一种名为“共享调用”的策略。其理念很简单,就是传递可以修改自身的对象,而不是传递引用。
反对引用的核心观点是,除了在 C 语言中内存不安全且难以理解之外,引用还会产生副作用。例如,在 PHP 中:
function read($source, &$n) {
$content = // some way to get the content
$n = // some way to get the read length
return $content;
}
$n = 0;
$content = read("foo", $n);
print($n);
这是一个常见的、受 C 语言启发的引用用例。然而,在这种情况下,你真正想要做的是
function read($source) {
$content = // some way to get the content
$n = // some way to get the read length
return [$content, $n];
}
list($content, $n) = read("foo");
print($n);
您只需要两个返回值,而不是一个。您还可以返回数据对象,这些对象可以容纳您想要的任何信息,并且将来可以进行扩展而不会破坏现有代码。
所有这些都不会影响调用函数的范围,这非常好。
不过,另一个安全点是,当你修改一个对象时,你可能会影响该对象的其他用户。例如,这就是Moment.js 的一个常见陷阱。让我们来看看。
function add(obj, attr, value) {
obj[attr] = (obj[attr] || 0) + value;
return obj;
}
const a = {foo: 1};
const b = add(a, "foo", 1);
console.log(a.foo); // 2
console.log(b.foo); // 2
另一方面,你可以这样做:
function add(obj, attr, value) {
const patch = {};
patch[attr] = (obj[attr] || 0) + value;
return Object.assign({}, obj, patch);
}
const a = {foo: 1};
const b = add(a, "foo", 1);
console.log(a.foo); // 1
console.log(b.foo); // 2
和 都a
保持b
具有不同值的不同对象,因为函数在返回之前add()
对 进行了复制。a
让我们用规则的最终形式来结束这个已经太长的部分:
除非你的函数明确地想要改变参数,否则不要改变参数。如果要改变参数,请通过共享而不是引用来实现。
例如, ESLint 中的no-param-reassign规则以及Object.freeze()方法。或者在 Python 中,很多情况下可以使用NamedTuple 。
性能注意事项:如果更改对象的大小,则底层过程基本上是为其分配一个新的连续内存区域,然后进行复制。因此,修改通常就是复制,所以不必担心复制对象。
利用弱动态类型
现在我们已经关闭了引用的疯狂之门,如果我们想保持DRY ,我们仍然需要编写通用代码。
好消息是,虽然编译型语言受物理规则和计算机工作方式的约束,但解释型语言却可以在其基础上添加大量额外的支持逻辑。
具体来说,它们主要依赖于鸭子类型。当然,你可以添加某种程度的静态类型检查,例如TypeScript、Python 的类型提示或 PHP 的类型声明。借鉴其他规则的智慧:
- 规则 5——进行多次断言。对一个实际上不存在的对象进行某些操作会引发异常,你可以捕获并报告该异常。
- 规则 10 — 不允许出现警告(下文解释)。使用各种类型检查机制,您可以依赖静态分析器来帮助您发现运行时可能出现的错误。
这两条规则可以防止你编写危险的通用代码。这将导致以下规则
只要使用尽可能多的工具来捕捉错误,你就可以编写通用代码,尤其是你需要遵循规则 5 和 10。
10. 编译时应考虑所有可能的警告;在发布软件之前应解决所有警告。
最初的完整规则是:
从开发第一天起,所有代码都必须在编译器最严格的设置下启用 allcompiler 警告进行编译。所有代码都必须在这些设置下编译,且无任何警告。所有代码都必须每天使用至少一个(最好多个)最先进的静态源代码分析器进行检查,并且分析结果必须为零警告。
当然,解释的代码不一定会被编译,因此它与编译器警告本身无关,而是与获取警告有关。
幸运的是,有大量的警告来源:
- JetBrains 的所有IDE在发现代码问题方面都非常出色。最近,这些 IDE 教会了我很多不同语言的模式。这正是我更喜欢这类 IDE 而不是简单的代码编辑器的主要原因:它们的警告非常智能且有用。
- 适用于所有语言的 Linters
- SonarQube等自动代码审查工具
- 拼写检查器也非常重要,因为它能让你嗅出拼写错误,而无需进行类型分析或任何复杂的静态代码分析。这是一种非常有效的方法,可以避免因为输入了
reuslts
而浪费时间results
。
关于警告,最重要的是你必须训练你的大脑去发现它们。IDE 中的一个警告就能让我抓狂,而我知道有些人根本就看不到它们。
关于警告的最后一点是,与编译型语言不同,这里的警告并非总是 100% 确定。它们的确定性大概是 95%,有时仅仅是 IDE 的一个 bug。在这种情况下,您应该明确禁用该警告,并在可能的情况下简要解释一下为什么您确定不需要应用此警告。但是,在这样做之前请仔细考虑,因为通常 IDE 是正确的。
关键要点
上面的长篇讨论告诉我们,这 10 条规则是为 C 语言制定的,虽然你可以在解释型语言中运用这些理念,但实际上并不能直接将它们转化为其他 10 条规则。让我们为解释型语言制定新的 10 + 2 条规则。
- 规则 1 — 不要使用
goto
,合理化使用continue
和break
,不要使用switch
。 - 规则 2 — 证明你的问题永远不会产生失控代码。
- 规则 3:限制数据大小。通常使用分页、map/reduce、分块等。
- 规则 4 — 编写适合你头脑的代码。如果它适合在页面上显示,那么它就适合你的头脑。
- 规则 5:检查一切是否正确。出错时进行失败检查。监控故障。参见规则 7。
- 规则 6:不要使用类似全局变量的变量。将数据存储在尽可能小的范围内。
- 规则 7 — 让异常冒泡,直到您正确恢复和/或报告它们。
- 规则 8 — 如果你使用转译器,请确保它们解决的问题比它们带来的问题多
- 规则 9.1 — 即使你的语言支持,也不要使用引用
- 规则 9.2 — 复制参数而不是改变它们,除非这是函数的明确目的
- 规则 9.3 — 尽可能多地使用类型安全特性
- 规则 10:使用多种 linters 和工具来分析你的代码。不要忽略任何警告。
如果你退一步思考,所有这些规则都可以归结为一条规则来统治它们。
你的电脑、内存、硬盘,甚至你的大脑,都受到各种限制。你需要将问题、代码和数据分割成适合你的电脑、内存、硬盘和大脑的小盒子,这样才能将它们组合在一起。
—
莫菲斯我
我认为这是编程的核心规则,并且我将它作为普遍的原理应用于我所做的一切与计算机相关的事情。
文章来源:https://dev.to/xowap/10-rules-to-code-like-nasa-applied-to-interpreted-languages-40dd