2.2 编写规范——原则:保持简洁、通俗


  • 实际上,当您停止相信它时,它不会消失。 —Philip K. DickQ19
  • 通常没有乐趣的阅读会使我们的书写变得不努力。 —Samuel JohnsonQ72
  • 问题……不是精确的语言,而是清晰的语言。 —Richard FeynmanQ26
  • 不可能用一种不会被误解的方式表达:总会有一些人误解你的表达。 —Karl PopperQ64

如何规范的编写?分为两个步骤

(1)写下规范状态(抽象状态)

你必须知道状态才能开始,找到最简单和最清晰的抽象状态总是需要付出努力。这很困难,因为你必须从所考虑的代码细节中摆脱出来,并考虑客户真正的需求。为此所需的基础和心理是基本的离散数学关系和对客户的良好理解。

人们常说抽象状态不是真实的,或者说规范是一种幻觉;只有RAM字节,磁盘块和机器指令是真实的。我不理解,一个物理学家会说只有硅中电子的量子力学才是真实的。他们的意思可能是指该规范实际上并未描述系统的行为。这可以通过几种方式发生:

  • 可能是错误的:代码执行了规范不允许的操作,这是应该修复的错误(无论是在规范中还是在代码中)。

  • 可能忽略重要的细节:正弦例程的准确性如何,或者发生故障时会发生什么情况。

  • 泄漏可能会忽略不重要的细节,这是需要你们判断的问题。

对于文件系统示例,规范状态具有文件 F ,目录 D 和节点 Node 。状态是一个映射 s = Node → (F or D) ,给出了节点的当前内容,以及 D 的根节点。一个文件是一对 F=(size:Nat,data:seq Byte) ,目录是一个(部分)函数 D = Name → NodeD 的必须的节点组织成一个图,其中 F 是叶节点,而 D 则形成一个以 root 为根的树;这才是状态的不变性。

(2)写下规范动作

每个动作如何依赖状态 S 并改变状态。你可能需要英文注释来指导开发人员的直觉。

现在,你拥有了客户需要知道的一些知识,如果你还没有做类似的很多事情,那么你可能无法为客户做出一份恰当体面的文档编制工作。

例如:这里有三个文件系统操作(有所简化)。构造许多 x 使得 p(x) 在 a(x) 中,这意味着当 p(x) 为真时再执行 a(𝑥)。

read(node,i) = if s(node) ∈ F and i < s(node).size then return s(node).datai

write(node,i,b) = if s(node) ∈ F and i < s(node).size then s(node).datai := b

open(pn:seq Name):Node =

let match(pn:seq Name,path:seq Node) = ∀i ∈[1..pn.size-1]|s(pathi-1) (pni) = pathi in

let var path suchthat path0 = root and match(pn,path) in return pathpn.size

如示例所示,准确地记录动作需要做很多工作,你可能需要一个好的符号(语言)来清楚,简洁地进行操作。MIT课程6.826“计算机系统原理”的讲义中有许多实际的示例,但很不幸地使用了一种组合语言,这些示例都进行了详细的研究。R53

一种规范应该简单,应该完整,并且可以接受足够小且足够快速的代码。好的规格很难。每个规范都是一种小型编程语言,具有自己的类型和内置操作,并且语言设计很困难。另外规范不能承诺提供超出代码可交付范围的承诺,不是最好的代码,而是你可以实际编写的代码。用丹尼斯·里奇(Dennis Ritchie)的话来说:”尽管有这些限制,但 I/O 系统仍然可以正常工作。它的目的是改进设计,而不是添加功能,因为相信通过适当的设计,会使功能变得廉价方便。” R73

关于并发,没有什么特别的,除了它使代码(甚至可能是规范)没有确定性:由于下一状态可能来自任何线程,所以当前状态不能确定下一步。同样,失败也没有什么特别的。崩溃或组件的不良行为只是另一种动作。崩溃会引起麻烦,因为它们可能破坏你希望保留的状态,并且由于它们增加了不确定性,则你无法控制很多状态。但这是生活中你必须处理的事实,而不是方法中的缺陷。

如果存在并发性,则文件系统操作通常不是原子操作(即不是一起执行的操作),它们只能进行单个状态更改。实际上,在 Windows 和 Unix 中,它们都不是原子的。取而代之的是,有一个开始操作收集参数,而结束操作则返回结果,并且其他影响结果的操作可能会在两者之间发生。这样的非原子操作更难于推理,但是它们的代码运行起来却方便得多。

该规范根据抽象状态(包括客户端调用的参数和操作结果)描述了系统所有可能的可见行为。有时人们会谈论“功能规范”,这是一个模糊的概念,其基于错误的观念,即系统没有内部状态或动作,因此除了动作返回的结果外,没有什么可说的。但是可靠性、故障、资源消耗、等待时间和系统配置都是规范可以描述的可见行为。

规犯正确吗?换句话说,实际上是你希望系统执行的操作吗?无法证明是这样;你会以什么标准来判断?有时,你可以证明所需属性遵循规范,例如对资源消耗的限制,但是最好的选择是保持简洁。20页的规格将是不正确的。规范也需要模块化。重复的部分,可能更改了某些参数,就像程序中的例程一样。给它起一个有意义的名称并且只写一次正文。

2.2.1 规格泄露和规格不良

规格通常不完整或泄漏;它们并未描述客户端可见的所有系统行为。最值得注意的是,通常不会对所消耗的资源特别是运行时间规范太多。这是可以的,具体取决于客户端的需求,但是对于与物理世界(包括用户)进行交互的系统而言,可预测的响应时间非常重要,尤其是对于应该引起平滑运动(例如在触摸设备上滚动)的交互而言。而且,当频繁使用的抽象代码被更改并且应用程序突然运行慢得多时,后果可能是灾难性的。理想情况下,将有一个近似的预期或最坏情况下的时序规范,该规范是根据代码调用原语的成本编写的:缓存命中,缓存未命中,远程过程调用,网络文件访问等。但是,通常需要保证是概率性的,最好的办法是监视它并报告意外的延迟。不要忽略这个问题,否则你会伤心后悔的。其他资源(例如RAM,存储空间或网络带宽)也存在类似问题。如果代码依赖于不受控制的外部资源,这可能会失败或无法正常运行,例如网络,则规范必须要说明这一点。例如,当假定为快速状态的动作(例如统计行为)变得非常慢或根本无法完成时,说明网络文件系统已出现许多问题。

规范的重点是抑制无关紧要的细节,但是泄露的规范也可能是安全性的问题,因为可观察到的状态变化不是规范的一部分,例如时序、功耗、缓存分配、声学或电磁辐射等。可以透露,这些在密码学中被称为辅助通道,在辅助通道中已经进行了广泛的研究。一般而言,抽象并不是秘密。如果你担心副渠道,请不要共享资源。

有时,规范需要泄露,从某种程度上讲,它暴露一些内部秘密,才能为客户端提供快速运行所需的访问权限。R22 增加系统某个部分构成另一部分的假设数量可能会使工作量减少,或许会少很多

Bug-for-Bug : 代码应该保守秘密;它不应该公开规范中没有的系统属性,您可能希望更改这些属性,因为客户可能会依赖它们。一个可悲的例子是Windows 3的一个相当受欢迎的应用程序(可能有1000万用户)。这款应用程序有一个程序,它对一个字符串做了一些小改动,可能去掉了所有的标点符号。由于一个bug,在一个地方它用一个伪字符串指针调用了这个例程,而这个伪字符串指针恰好指向堆栈末端的垃圾区域。例程会运行,对垃圾进行足够的修改以满足它,然后返回;没有人注意到没有被处理的字符串。然而,当Windows 95出现的时候,很多东西都不一样了,以至于伪指针有了不同的值,而Windows 95有了更多的内存保护,所以现在这个伪调用导致了一个segfault,导致应用程序崩溃。在segfault例程中对这个应用程序和这个调用进行特殊检查,它执行任何必要的操作来满足应用程序。这就是bug-for-bug兼容的含义。

泄漏不一定是一件坏事,总的来说是不可避免的。但是规范的其他一些属性通常是不好的:

  • 客户端的复杂性很难理解,也很难编写代码。这通常是由于未遵循状态和动作的规定,或暴露了有关应为秘密的代码的事实。
  • 脆性使规范取决于可能发生变化的环境的详细信息,或者取决于容易出错的环境的详细信息。
  • 规范无法报告的代码中的错误或失败表示代码不符合规范。一个常见的示例是一个同步API,即使它确实是远程的,缓慢的并且是不稳定的,也可以使代码看起来是本地的,快速且可靠的。
  • 同样,如果无法报告这些问题或设置优先级,则争用或重载可能会使代码不符合规范。
  • 当代码具有客户端要依赖的属性时,即使它们不在规范中,也会发生事实上的规范,无论是功能还是性能。除非您可以更改客户端,否则您将陷入困境。有时虚拟化可以解决这些问题。

TPM 1.1 : 上世纪90年代末,我和微软的保罗•英格兰(Paul England)发明了最终出现在可信平台模块(Trusted Platform Module, TPM)中的机制。到2002年,TPM已经标准化,后来我想知道它是如何工作的一些细节。所以我下载了specR87并开始阅读它,但我马上就卡住了。即使我发明了这些机制,我也无法理解标准的语言。

2.2.2 可执行规格

另一类规范是可执行的:计算机可以足够快地运行它,以使其客户端实际使用它,也许不做有用的工作,但至少要看他们是否喜欢它的功能。R44

这有一些优点:

  • 你可以尝试出来。

  • 你可以将其用作测试实际代码的参照。

  • 您可以将其发展成为足以交付的代码。

尝试一下它通常比思考它来了解它是否是您想要的更好。如果它是较大系统中模块的规格并且足够快(可能在超级计算机上运行),则可以在该模块有实际代码之前运行该系统。

可执行的规范也有一些缺点:

  • 可能不够清晰,无法用作规范;你需要强大的基元和坚强的意愿,以免放置太多细节。

  • 非确定性很难,规范中的一个选择可能会限制代码太多。

  • 它无法充分利用数学的力量。例如,它不能说“存在通过此网络的路径就像…”

一个相关的想法是参考实现。有时,这意味着可执行的规范,但更常见的是,它意味着实用但未经优化的代码,目的是清楚说明规范本身是切实可行的,并通常指导实施者该如何进行。