精读 Out of the Tar Pi(上)
June 04, 2023
原文地址:http://curtclifton.net/papers/MoseleyMarks06a.pdf 共12个章节,讨论复杂度性对编程的影响,论文包含66页,分三篇文章进行精读
1 Introduction
复杂性是成功开发大规模软件系统的主要困难。我们遵循布鲁克斯的观点,区分偶然和本质上的困难,但不同意他的前提:当代系统中大部分剩余复杂性都是本质上的。我们确定了复杂性的常见原因,并讨论了可以采取的通用方法来消除它们(如果它们是偶然产生)。为了使事情更具体化,我们接着给出一个基于函数式编程和科德数据关系模型潜在最小化复杂度方法的概述。
“software ware 危机”最早在1968年被发现,而在接下来的几十年里,它变得更加深刻而非消退。开发和维护大型软件系统中最大的问题是复杂性——大型系统很难理解。我们认为,在许多系统中导致这种复杂性的主要因素是状态处理以及在尝试分析和推理系统时增加的负担。其他密切相关的因素包括代码量以及对整个系统控制流程明确关注。
应对状态困难的传统方法包括面向对象编程(将状态与相关行为紧密耦合)和函数式编程(在其纯粹形式中完全避免了状态和副作用)。当应用于传统大规模系统时,这些方法各自都存在不同且各异的问题。
我们认为,可以从两者中获得有用的想法,并与关系数据库世界的一些想法相结合,这种方法具有显着的潜力来简化大规模软件系统的构建。
这篇论文分为两个部分:
在第一部分中,我们专注于复杂性。在第2节中,我们从总体上看待复杂性,并证明了它是危机的根源,然后在第3节中探讨了我们当前如何试图理解系统。在第4节中,我们研究了复杂性的原因(即使我们难以理解的因素),然后在第5节中讨论了处理这些复杂性原因的经典方法。在第6节中,我们定义了“偶然”和“本质”,然后在第7节中提出了对应复杂性原因的替代方法建议——强调避免问题而不是应对问题。
在论文的后半部分,我们更详细地考虑了一种可能的方法,该方法遵循我们推荐的策略。我们从第8节对关系模型进行回顾,并在第9节概述了潜在的方法。在第10节中,我们简要示例了该方法的使用方式。 最后,在第11节中,我们将我们的方法与其他方法进行对比,然后在第12节中给出结论。
2 Complexity
在他的经典论文《没有银弹》中,布鲁克斯(Brooks)[Bro86l] 确定了软件系统的四个属性,这使得构建软件变得困难:复杂性、一致性、可变性和不可见性。其中我们认为复杂性是唯一重要的一个——其他可以被归类为复杂形式,或者仅因系统中的复杂而成问题。 复杂性是当今软件中绝大多数问题的根源。不可靠性、延迟交付、缺乏安全性,甚至在大型系统中的性能不佳,这些问题都可以追溯到难以管理的复杂性。复杂性作为这些问题的主要原因,是因为理解一个系统是避免所有这些问题的先决条件,而复杂性恰恰破坏了这一点。 复杂性的相关性已得到广泛认可。正如 Dijkstra 所说 [Dij97,EWD1243]》
“如果我们拒绝被我们自己制造的复杂性所压垮,我们就必须保持它的简明性、解缰性和简单性…”。
经济学家还专门发表了一篇关于软件复杂性的文章 [Eco04],指出据估计软件问题每年在美国经济中造成590亿美元的损失。
能够思考和推理我们的系统(尤其是对系统变化的影响)至关重要。复杂性的危险以及简单性的重要性在这方面也是 ACM 图灵奖演讲的热门话题。在他1990年的演讲中,科尔巴托(Corbato)说道 [Cor91]:
“对于雄心勃勃的系统而言,复杂性是一个普遍的问题。”,“…强调简单和优雅的价值是很重要的,因为复杂性会使问题变得更加复杂。”
一系列的名人佐证 最后结论是 这是一个不幸的事实: Simplicity is Hard
最后一个要点是,我们在本文中讨论的复杂性类型是使大型系统难以理解的类型。正是这种复杂性导致我们在创建和维护此类系统时耗费了巨大的资源。这种复杂性类型与复杂性理论无关,复杂性理论是计算机科学的一个分支,研究计算机在执行程序时消耗的资源。这两者是完全无关的——编写几行代码的小程序可以非常简单(按照我们的定义),但在复杂性理论上属于最高复杂性类别。从此以后,我们只讨论第一类复杂性。在讨论我们通常如何尝试理解系统之后,我们将查看我们认为是复杂性的主要共同原因(使理解困难的因素)。
3 Approaches to Understanding
前面我们论述了复杂性的危险来自于其对我们理解系统的影响。因此,考虑常用于尝试理解系统的机制是很有帮助的。然后,我们可以进一步考虑潜在的复杂性原因对这些方法的影响。目前有两种广泛使用的方法来理解系统(或系统组件):
- 测试(Testing)是从外部尝试理解系统的方法,将系统看作是一个“黑盒子”。根据对系统在特定情况下的行为观察,得出对系统的结论。测试可以由人类或机器执行。前者在整个系统测试中更常见,后者在单独组件测试中更常见。
- 非正式推理(Informal Reasoning)是通过从内部检查系统来尝试理解系统的方法。希望通过使用可用的额外信息,获得更准确的理解。
在这两种方法中,非正式推理远远比测试更为重要。这是因为正如我们将在下文中看到的,测试存在固有的限制,而非正式推理(作为开发过程的固有部分)始终被使用。另一个理由是,非正式推理的改进将减少错误的产生,而测试的改进只能导致更多错误被检测出来。正如 Dijkstra 在他的图灵奖演讲中所说[Dij72,EWD340]:
那些想要真正可靠的软件的人会发现,他们必须从一开始就找到避免大多数 bug 的方法。
测试的关键问题是,使用一组特定输入进行的任何测试都无法告诉您系统或组件在给定不同输入时的行为。巨大数量的可能输入通常排除了对其进行全面测试的可能性,因此对测试的不可避免关注始终是 是否执行了正确的测试?对这个问题,只能得到一个否定的答案。
一堆大佬话讲依靠测试是很危险的。
由于所有这些方法的局限性,简单性才是至关重要的。在投资于测试和投资于简单性之间,后者往往是更好的选择,因为它将促进未来所有理解该系统的尝试。
4 Causes of Complexity
在任何非常规的系统中,问题本身都有一些复杂性需要解决。然而,在真正的大型系统中,我们经常遇到一些复杂性,其作为“问题固有的一部分”的地位可能存在一些疑问。现在,我们考虑一些导致复杂性的原因。
Complexity caused by State
任何曾经给 IT 打过电话,并被告知“再试一次”、“重新加载文档”、“重启程序”、“重新启动计算机”或“重新安装程序”、甚至“重新安装操作系统,然后程序”的人,都直接经历过由于编写可靠、易懂的软件而导致的问题。
这些话之所以会让很多人感到熟悉,是因为它们经常被使用,而且通常能够成功解决问题。它们之所以能够通常成功解决问题的原因是,许多系统在处理状态时存在错误,这些错误存在的原因是 状态的存在 使得程序难以理解,状态使程序变得复杂。
又是一些引用来佐证上面结论
Impact of State on Testing
Brooks 所指出的状态对测试的影响的严重性难以过分强调。状态影响着所有类型的测试——从系统级测试(在这种测试中,测试人员将受到与刚刚提到的倒霉用户相同的问题的束缚)到组件级或单元测试。关键问题在于,在处于特定状态的系统或组件上进行的任何类型的测试都无法告诉您有关该系统或组件在另一个状态下的行为的任何信息。
测试有状态系统(无论是在组件级别还是系统级别)的常见方法是启动系统,使其处于某种“干净”或“初始”(虽然大部分是隐藏的)状态,使用测试输入执行所需的测试,然后依赖于(通常情况下是基于错误的假设)系统会无论其隐藏的内部状态如何,每次使用相同的输入运行测试时都会以相同的方式运行。
本质上,这种方法只是把状态问题掩盖起来。当测试一个具有复杂内部隐藏状态的有状态系统时,实际上没有其他选择。
当然,困难在于并非总是能够“逃脱”——如果某个事件序列(输入)可以导致系统“进入糟糕的状态”(具体而言,是与进行测试时不同的内部隐藏状态),那么问题可能会发生。这正是在本节开头讨论的假设性支持服务台呼叫者所面临的情况。提出的解决方法都是试图将系统强制恢复到“良好的内部状态”。
这个问题(即在一个状态下的测试对于处于不同状态的系统毫无意义)直接对应了上面讨论的测试的一个根本性问题之一——即针对一组输入进行测试对于具有不同输入集的行为毫无意义。事实上,由状态引起的问题通常更糟糕——特别是在测试系统的大块内容时——因为即使可能的输入数量非常大,系统可能处于的可能状态数量通常更大。
这两个类似的问题——一个是测试本质上固有的,另一个是由状态引起的——可怕地结合在一起。每个问题都引入了巨大的不确定性,如果所审查的系统/组件具有状态性质,那么我们几乎没有什么可以确定的东西。
Impact of State on Informal Reasoning
除了给从外部理解系统带来问题外,状态也妨碍了开发人员试图从内部推理(通常是基于非正式的方式)系统的预期行为。
用于进行这种非正式推理的心理过程通常围绕着一种逐案例的行为模拟:「如果这个变量处于这个状态,那么这将发生——这是正确的——否则那将发生——这也是正确的」。随着状态的数量增加,因此必须考虑的可能情景的数量增加,这种心理方法的有效性几乎与测试一样迅速地崩溃(它通过对一组相似值的抽象实现了某种优势,这些值可以被视为以相同方式处理)。
除了在外部理解系统时造成问题外,状态还妨碍了开发人员试图从内部推断系统的预期行为(通常是以非正式方式)。
用于进行这种非正式推理的心理过程通常围绕着对行为 case by case 的心理模拟:“如果这个变量处于这个状态,那么这将发生——这是正确的——否则那将发生——这也是正确的”。随着状态数量的增加,因此必须考虑的可能情景数量的增加,这种心理方法的有效性几乎与测试同样迅速崩溃(它通过抽象相似值的集合来获得一定的优势,可以看到它们被同样对待)。
其中一个问题(既影响测试又影响推理)是可能状态数目增长的解指数速率ーー对于我们加上的每一个状态位,我们将可能状态总数增加一倍。另一个问题是污染,这是非正式推理的一个特殊问题。
考虑一个由一些有状态的过程和一些无状态的过程组成的系统。我们已经讨论了理解有状态部分的困难,但我们希望那些没有状态的过程本身会更容易理解。然而,很遗憾,情况很大程度上并非如此。如果涉及的过程(本身是无状态的)使用了任何其他有状态的过程,即使是间接地,那么一切都会不确定,我们的过程会被污染,我们只能在状态的背景下理解它。如果我们尝试做其他事情,我们将再次面临以上讨论的所有经典与状态相关的问题的风险。正如前面所说,状态的问题在于“一旦让骆驼的鼻子进了帐篷,它的其他部分就会跟随”。
由于上述所有原因,我们相信在大多数当代大型系统中,复杂性的最主要原因仍然是状态,我们在限制和管理状态方面所做的越多,效果就会越好。
Complexity caused by Control
控制基本上是关于事物发生的顺序。 控制的问题在于我们经常不想去关心这个问题。显然,考虑到我们想要构建一个真正会发生事情的系统,某个时刻顺序将对某人具有相关性,但是不必要地关注这个问题存在着重大风险。 大多数传统的编程语言确实需要关注顺序——通常情况下,事物发生的顺序由编程语言的语句在程序的文本形式中的书写顺序来控制。然后,该顺序会通过显式的分支指令进行修改(可能附带条件),并且通常会提供子程序,这些子程序将在隐式堆栈中被调用。 当然,有多种评估顺序可供选择,但在广泛使用的语言中几乎没有差异。
问题在于当控制是语言的隐含部分时(几乎总是如此),那么每个程序的片段都必须在这个上下文中理解 —— 即使(通常情况下)程序员可能希望在这方面不表达任何内容。当程序员被迫(通过使用具有隐式控制流的语言)指定控制流时,他们被迫指定系统应该如何工作的一个方面,而不仅仅是简单地说明期望的结果。实际上,他们被迫过度指定了问题。考虑下面的简单伪代码: 在这种情况下,很明显程序员对于这些事情最终发生的顺序(即如何发生)没有任何关注。程序员只关心在某些值之间指定一种关系,但由于选择了任意的控制流程,他们被迫说得更多。 通常在这种情况下,编译器会努力确立这样一个要求(顺序)可以被安全地忽略的事实,而这个要求是由于语言的语义而被程序员强制提出的。 在像上面这样简单的情况下,往往很少考虑这个问题,但重要的是要意识到两件完全不必要的事情正在发生——首先是人为地强加了一个顺序,然后又进行了进一步的工作来消除它。
这个看似无关紧要的情况实际上会显著复杂化非正式推理的过程。这是因为阅读上面的代码的人必须有效地复制假设编译器的工作 —— 他们必须(根据语言语义的定义)从一个假设开始,即指定的顺序是重要的,然后通过进一步的检查确定它并不重要(在比上述情况不那么琐碎的情况下,确定这一点可能非常困难)。问题在于,对这种确定的错误可能导致引入非常微妙和难以发现的错误。
需要注意的是,问题不在上面程序的文字表达方式上 —— 毕竟,它必须按照某种顺序书写 —— 而完全在于我们假设的命令式语言的语义。可以将完全相同的程序文本视为在一个语义上不基于程序内文本顺序定义运行时顺序的语言中的有效程序。
在考虑了控制对非正式推理的影响后,我们现在来看看与控制相关的第二个问题,即并发,它也会影响测试。 并发涉及基本的控制,如分支,但与顺序执行相反,大多数语言通常会显式地指定并发。最常见的模型是“共享状态并发”,其中提供了显式同步的规范。这对非正式推理的影响是众所周知的,困难之处在于随着阅读程序,必须进一步考虑的场景数量增加(在这方面,问题与上述状态问题相似,它也增加了需要进行心理考虑的场景数量)。
并发也影响测试,因为在这种情况下,即使我们以某种方式确保了一致的初始状态,在重复对系统进行测试时,也无法确保结果的一致性。在存在并发的情况下,以已知的初始状态和一组输入运行测试对于下一次以完全相同的输入和完全相同的初始状态运行相同的测试来说,并不能提供任何关于将会发生的信息…而事情确实无法变得更糟了。
Complexity caused by Code Volume
我们要详细考察的最后一个复杂性原因是庞大的代码量。
这个原因在很多方面基本上是一个次要效应——很多代码只是关注于状态管理或控制规范。因此,我们通常不会明确提到代码量。然而,它确实值得简要独立地关注,至少有两个原因——首先,因为它是最容易衡量的复杂性形式,其次,它与其他复杂性原因产生不良的互动,这是需要考虑的重要因素。
布鲁克斯(Brooks)在《人月神话》(Bro86)中指出:
“开发软件产品中的许多经典问题都源于这种固有复杂性以及它与规模的非线性增加。”
基本上,我们同意在大多数现有系统中,这是正确的(我们对“固有”一词持有异议,正如之前提到的 —— 即在大多数系统中,复杂性确实随着代码规模的增加呈非线性增长)。而这种非线性增长意味着将代码量减少到绝对最低限度至关重要。
我们还想提请大家注意 Dijkstra’s 关于这个问题的一个想法:
有人提出过一种自然法则,声称所需的智力努力量与程序长度的平方成正比。但是,谢天谢地,没有人能够证明这个法则。这是因为这个法则未必是真实的……我倾向于这样的假设——迄今为止,经验未能反驳——通过适当地运用我们的抽象能力,构思或理解一个程序所需的心智相比程序长度不会增长得更多。
我们同意这一观点,这也是我们在上面提到的。我们相信,通过有效地管理我们讨论过的两个主要复杂性原因,即状态和控制,复杂性随着代码量的增加以非线性方式增长的问题变得不那么明显。
Other causes of complexity
最后还有其他原因,例如: 重复的代码、从未实际使用过的代码(“死代码”)、不必要的抽象、没有抽象、模块性差、缺少文档…
所有这些其他原因都可归结为以下三个相互关联的原则:
- 复杂性滋生复杂性。复杂性的次级原因有很多。这涵盖了所有由于无法清楚地理解系统而引入的复杂性。重复是一个典型的例子 —— 如果(由于状态、控制或代码量)不清楚功能是否已经存在,或者很难判断已经存在的功能是否完全符合要求,就会倾向于重复。尤其是在时间紧迫的情况下,这一点尤为真实。
- 简单是困难的。这一点在前面已经提到过 —— 要实现简单可能需要付出相当大的努力。第一个解决方案往往不是最简单的,特别是如果存在现有的复杂性或时间压力。只有在认识到简单的重要性、追求简单并珍视简单时,才能达到简单。
- 权力导致滥用。我们所说的是,在没有语言强制保证(即对语言的能力进行限制)的情况下,错误(和滥用)就会发生。这就是为什么垃圾回收是好的 —— 它消除了手动内存管理的能力。同样的原则适用于状态 —— 另一种权力。在这种情况下,意味着我们需要非常警惕任何允许状态存在的语言,无论它对状态的使用有多么的限制(明显的例子是 MI 和 Scheme)。底线是,语言越强大(即在语言内实现的功能越多),理解构建在其中的系统就越困难。
其中一些原因是由人性导致的,其他原因则是由环境问题造成的,但我们相信,通过专注于对第4.1-4.3节中讨论的复杂性原因进行有效管理,可以大大减轻这些问题。
Written by xi ming You should follow him on Github