书评:软件设计哲学

2020-07-02 17:35:54

我正在努力阅读所有关于软件设计的好文章。这非常容易,因为还没有太多的东西被写出来:事实证明,写一篇关于如何将俄罗斯方块AI作为一个容器化的Kotlin微服务编写的文章要比深入了解如何编写好的代码要容易得多。因此,当我听说John Ousterhout的新书“软件设计哲学”时,我立即订购了一本。

我记得斯坦福大学研究生访问日的约翰·欧斯特胡特(John Ousterhout)饰演一个高个子,他用一个自嘲的笑话自我介绍,并邀请所有被录取的博士到他家共进晚餐。我还知道他是凯·奥斯特胡特(Kay Ousterhout)和艾米·奥斯特胡特(Amy Ousterhout)的父亲,我最近在Strange Loop遇到了他们,艾米·奥斯特胡特是第一对都获得享有盛誉的赫兹联谊会(Hertz Fellowship)的姐妹。

170页的“软件设计哲学”(以下简称:POSD)是一本不起眼的书。约翰的背景是系统,而不是软件工程或编程语言,他从来没有声称自己有特别的专业知识。但他的从业者信誉是巨大的。我喜欢拆解开源项目,并将它们转化为不应该做的案例研究,我的学生如此之多,以至于我要求我写一个关于好代码的案例研究,这一次是如此之多。Ousterhout的分布式内存存储系统RAMCloud现在在我的候选名单上:从5分钟的时间来看,它是我见过的最干净、文档最完整的代码之一。而且,考虑到他是一个管理大型实验室的忙碌的教授,他自己写了大量的文章。他也产生了很大的影响:他是Tcl语言及其Tk框架的创建者,我在2005年了解到Tcl语言及其Tk框架是编写GUI的方法(™)。

POSD最好是作为如何操作的战术指南来阅读。大约四分之一的内容花在命名和评论上,其余的大部分是关于特定模式的。他很少尝试从战术建议跳到原则上,要么是试图将听起来相似的提示混为一谈,要么是因为他看不到代码以外的程序的意义(稍后将详细介绍)。他在第19章滑稽地说明了缺乏原则,他承诺将书中的“原则”应用于几种软件趋势,然后在这一章的其余部分充满了关于单元测试和OOP的标准(但可靠的)建议,但没有提到本书的其余部分。总体而言,这本书的建议比像Clean Code这样的初学者书籍水平更高,但它的大部分内容对于高级软件工程师来说都是熟悉的,而且新奇的部分也是参差不齐的。

在像“代码简单性”这样的其他书籍之后,POSD首先高瞻远瞩地解释了好代码的好处和复杂性的危险。它的前几章全面介绍了软件组织的基本概念:分离抽象级别、隔离复杂性以及何时拆分功能。第5章是我见过的对Parnas关于信息隐藏思想的最平易近人的介绍之一。但在第4章,他介绍了这本书的中心思想:深度模块。Ousterhout解释说,接口不仅仅是代码中编写的函数签名。它还包括非正式元素:高级行为、对排序的约束;开发人员使用它需要知道的任何内容。许多模块都很肤浅:它们需要做很多解释,但实际上做的并不多。一个好的模块是深入的:接口应该比实现简单得多。

说“接口应该比实现短?”听起来很不错。你怎么测试它?

对于Ousterhout来说,界面只是一个评论和一些关于它是否易于使用和考虑的讨论。直觉和经验是这里唯一的仲裁者。这就暴露了他的主要盲点。

我之前已经解释过,软件设计的重要信息不在代码中(2级),而在逻辑中:很少具体写下来,但仍然塑造代码的规范和推理。我将这些构件分组到聚合的“第3级构造”中。Ousterhout描述的“非正式接口”就是这样的3级结构,但它们和代码一样真实,而且,与Ousterhout相反,有很多编程语言可以让您写下并检查它们。

这样做的经验使我们在谈论软件设计时有了具体的基础。这就是我们如何进入软件工程的后严格阶段,并且知道当我们使用诸如“接口”和“复杂性”之类的术语时我们的意思。它保护我们不会发表令人困惑和自相矛盾的声明。奥斯特胡特缺乏这种洞察力,这就是他被烧伤的原因。

我会稍作停顿,告诉你们:总的来说,我喜欢这本书。这本书写得很好,书中有很多我认为有用的建议,尽管它的基础不稳固,但更多的建议根本不依赖于此。不过,奥斯特胡特对此小题大做,所以我将用几页纸来解释为什么它是错误的。这些想法很重要,因为它们是通向更高水平掌握的一部分。

我的观点是,Ousterhout的“非正式接口”只是将正式规范翻译成英语。我们对接口的任何问题都可以通过问“规范是什么样子的?”来回答。虽然我无法在不深入欧斯特胡特头脑的情况下证明这些通信,但我发现这个镜头在帮助解释软件设计方面不合理地有效。因此,在本文的其余部分,我将交替使用“规范”和“接口”这两个词。

我同意规范通常应该比代码简单得多。但是,任何有实际规范形式化经验的人都可以告诉您,在一些有趣的情况下,规范比实现更复杂,而且应该比实现更复杂。

这是对的:有些时候,实际上希望有一个比代码更复杂的规范。两个主要原因是幽灵状态和不精确。幻影状态是来自验证的一个概念,它描述了某些类型的“微妙”代码。这是一个有趣的话题,值得发表自己的博客文章;我不会再提了。(简而言之:这是指一个简单的动作,比如翻转一下,实际上代表了一些概念上的复杂东西。)。

规范之所以更长,正是因为它创建了一个抽象障碍。如果您在设计系统的其余部分时假设Fudarkameter恰好是70度,那么Fudarkameter就变得很难更改或更换。通过削弱对模块的假设,代码变得更具进化能力。

除此之外,还有另一个根本原因:从内部描述某事要比从外部描述容易得多。给你看一个苹果比回答你向它提出的每一个问题要容易得多。(种子在哪里?我掉下来的时候它会怎么滚?)。虽然你可以说的关于一个苹果的东西比世界上所有的苹果都多,但是关于一些苹果的事情可能比关于一个苹果的事情更多。

作为一个例子,让我们以堆栈数据结构为例,我希望大家都同意这是一个有用的抽象。堆栈是具有推入和弹出操作的序列,遵循后进先出的顺序。链表实现非常简短:只需添加和删除列表前面的元素。但是如果您使用堆栈,并且您不想使用此实现的内部细节,那么您需要一种不引用底层序列的方式来考虑它。一种解决方案是使用堆栈公理,该公理说“如果您将某个东西压入堆栈,然后从堆栈中弹出,您会得到旧值”和“如果您曾经将某个东西压入堆栈,那么它不是空的。”我们已经从解释堆栈操作如何操作内存的内部视图,到解释它们的交互和可观察行为的外部视图。

在我与Ousterhout教授的公开通信中,我通过写下堆栈数据结构的实现和接口(包括堆栈公理)来说明这一点。我的实现是30个令牌;接口是54个。

也许您可以找到一种更短的方式来解释堆栈,但这看起来并不好。看起来,Ousterhout的建议实际上是在告诉我们,我们不应该在代码中使用堆栈(或者,至少,只使用更复杂的实现,比如无锁的并发堆栈)。

栈的接口很容易比实现大,因为它们太小了。现在,让我们来看一些更大的东西。我不需要非常努力地寻找一个例子,因为Ousterhout给了我一个例子。

Unix操作系统及其后代(如Linux)提供的文件IO机制就是一个很好的深度接口示例。I/O的基本系统调用只有5个,签名简单:

int open(const char*path,int标志,mode_t权限);ssize_t read(int fd,void*buffer,size_t count);ssize_t write(int fd,const void*buffer,size_t count);off_t lSeek(int fd,off_t Offset,int reference encePosition);int close(Int FD);

POSIX文件API是一个很好的示例,但不是深度接口。相反,它是一个很好的例子,说明了当简化为C样式的函数签名时,具有非常复杂接口的代码可能看起来很简单。它是一个有状态API,具有有趣的顺序和调用之间的交互。OPEN的标志和权限参数隐藏了巨大的复杂性,隐藏的要求如“应该正好指定这五位中的一位”。OPEN可能会返回20个不同的错误代码,每个错误代码都有自己的含义,并且许多错误代码都引用了特定的实现。

SibylFS的作者试图写下开放接口的准确描述。他们的带注释版本的POSIX标准版本超过3000字。不包括基本的机器,他们花了200多行用高阶逻辑写下了开放的性质,又花了70行来给出开放和关闭之间的相互作用。

相比之下,虽然很难计算特性的大小,但它们的模型实现只有40行代码。

是的,Linux中的实际版本要长得多,即使不包括它所基于的更通用的“inode”机制。而且,您只需对API有一定的了解就可以过得去。但是,在研究了它的语义(一个真正的3级构件)之后,我们现在对这个API的复杂性有了更真实的认识,而不仅仅是“简单的”签名。

可以做很多事情来改进这个API,但是实现可以更短是有根本原因的。这是一个接口,旨在描述开放的每一种可能的实现。紧随其后的应用程序可以与它们中的任何一个一起使用。那么,它怎么会比最简单的实现更简单呢?

因此,当与其更简单的实现结合在一起时,开放确实是Ousterhout所唾弃的浅薄API之一。考虑到它意味着包含的多样性如此之多,在某种程度上这是不可避免的。

(Ousterhout的反驳是:“您只是在谈论规范,而不是它们用来编写能够工作的代码有多容易。”看一下规范中的内容,我想说了解它如何解释文件路径和O_RDONLY标志的作用在很大程度上都是了解如何使用它的一部分。)。

也许一个更具穿透力的例子是针对简单复制磁盘的这个写入功能。这是一个系统,其行为类似于一个磁盘,但将所有内容复制到两个底层磁盘,因此即使其中一个磁盘出现故障,它仍然可以运行。下面是Write函数,从Coq音译为C:

这个函数的规格是什么?它在两个磁盘上都被写为b,如果磁盘死了,则不会对其执行任何操作。如果系统中途崩溃,则要么写入都不成功,要么磁盘1写入成功,或者两个写入都成功。在Coq中:

{|pre:=disk0 state?|=eq d/\disk1 state?|=eq d;post:=un r state';=>;r=tt/\disk0 state';?|=eq(DiskUpd A B)/\disk1 state';?|=eq(DiskUpd A B);RECOVERED:=FUN_STATE';=&gT;WRITE_RECOVER_CONDITION d a状态&。|})[.]。定义WRITE_RECOVER_CONDITION d a b状态';:=(disk0 state';?|=eq d/\disk1 state';?|=eq d)\/(disk0 state';?|=eq(DiskUpd A B)/\disk1 state';?|=eq d)\/(disk0 state';?|=eq(DiskUpd A B)/\disk1。

是的,这是内部接口。外部API的规范更简单,但仍然比代码长。

在那个文件的其他地方有更多的乐趣。我对恢复过程的规范总共有70行复杂的代码,相比之下,用于实现的简单代码行只有29行。这是因为,在编写这类代码时,您需要不断地询问“如果此行发生崩溃会发生什么”。很容易忽略这一点,并认为代码很简单,但逻辑是赤裸裸的。因此,接口比代码长得多。

因此,Ousterhout对深层模块的深刻见解是有缺陷的,基于它的建议是不可靠的。使用它,他抨击了制作小型类/方法的普遍智慧,但没有给出一种方法来区分何时这样做是抽象某些东西还是仅仅添加间接的东西。

整本书中有许多较小的缺陷,这些缺陷来自于没有直接接触到级别3的构造。例如,在早期关于耦合的讨论中,他讨论了二进制协议的解析和序列化代码如何相互依赖,但更准确地说,它们都依赖于该协议,该协议是3级的,存在于代码之外。(实际上,如果要使用工具从序列化程序合成解析器,则需要首先推断协议,然后从协议生成解析代码。)。

在第9章之后,本书不再试图将广泛的编码原则引入更软的领域,也不再尝试更具体的编码实践。第10章“定义不存在的错误”对我来说是最不寻常、最发人深省的一章。我来这里的时候,期待着一些我教授的“使无效状态无法代表”之类的东西。我实际上发现的是更改函数规范以容忍更多输入/情况的不同技巧的拼凑。

当我试图将这一章中的每一条建议都归纳出来时,我发现有些建议实际上与其他建议相反。在第10.9节中,他恳求我们“设计不存在的特殊情况”。具体地说,他解释了在文本编辑应用程序中,如何将应用程序状态建模为“选择始终存在,但可能为空”,从而不再需要特殊代码来处理没有选择的情况。换句话说,从函数的规范中去掉一个条件。但是在第10.5节中,他告诉我们应该在函数的规范中添加一个条件,即使Java的子字符串方法定义为越界索引。我不能完全肯定他是错的(正如我在我的Strange Loop演讲中所讨论的那样,这可以归结为:有没有一种清晰的方式来描述这种行为?),但我发现他声称这使代码“更简单”,只是比他关于Unix文件API的说法稍微可信一点。

接下来的7章是本书的软部分。第11章认为,你应该为每件事考虑至少两种设计,这是决策过程中多重跟踪的一个例子。下面关于评论的章节写得很好,尽管有时是道德说教,但对于我不同意的部分,我没有坚实的基础。我非常赞同他在实现接口(打破注释之间的隐藏耦合)时编写“在<;其他文件>;中查看注释”的做法。在RAMCloud代码库中看到它的运行非常漂亮。

直到倒数第二章“为性能而设计”,奥斯特胡特才从狂热爱好者转变为专家。这一章的中心是他的“围绕关键路径进行设计”的概念,这让人想起Carmack对内联代码的评论,以及一个清晰的RAMCloud案例研究。这一章闪耀着胜利的经验,我很乐意读他关于这个主题的一本书。我只希望它能早点来。

在这本书的后半部分,我只发现了两条值得注意的建议,我认为它们是不好的。

为什么它被声明为一个列表,尽管它是一个数组列表,Ousterhout问道?这是不是让它变得不那么明显了?毕竟,ArrayList有它们自己的性能属性。

是的,但是它不必要地将代码绑定到ArrayList的特定实现,并且会使代码更难更改。Joshua Bloch在他的书Efficient Java中的第52点“通过它们的接口引用对象”中使用了一个几乎相同的示例,彻底证明了相反的建议。

不过,在与Ousterhout讨论了这个例子之后,听起来他、布洛赫和我都同意这一点。Ousterhout告诉人们这一点的前提是,除非他们需要ArrayList的特定性能保证,否则他们不会使用ArrayList,这是我从未遇到过的情况。(相反,当我需要编写使用较少内存的代码时,我不得不遵循一个需要ArrayList<;Integer>;的接口,这让我感到焦头烂额。)。正是这样的删减细节和警告,让欧斯特胡特拥有了他的短书篇幅,但也将合理的建议变成了一些成熟的、容易被滥用的东西,并破坏了它相对于原始直觉的许多价值。

第二条不好的(好的,误导性的)建议来自对分布式操作系统Sprite中的一个有害错误的讨论。在极少数情况下,某些数据会被随机覆盖。罪魁祸首是当表示文件中的逻辑块的整数(称为“块”)被用作磁盘上的物理块(也称为“块”)的地址时。与斯波尔斯基相呼应的是,奥斯特胡特推荐了他的解决办法:为每个变量想出一个完美的名称。

认真对待名字,不要对两个不同的概念使用相同的名字,这是一个好主意。而且,正如之前在Spoelsky上的评论者所指出的那样,初级防御机制有一个更好的选择。

ART的设计者,也就是在所有Android设备上运行的Java VM,也有类似的问题。他们有许多不同类型的指针,不应该分配给对方。对垃圾回收器控制的对象的引用需要与运行时内部的对象分开。有些是压缩为32位的64位指针,不能直接取消引用。

他们的见解是,这些值已经被当作不同的类型对待,因此将其显式处理几乎没有复杂性成本。因此,他们的解决方案(请参阅:此处和此处)是为每种指针创建单独的类型。现在没有混淆两个这样的指针的风险。编译器可以使用此信息来重载赋值,从而缩短代码。而且它可以用C++完成,运行时开销为零。

“使用更精确的类型”是许多软件工程问题的答案。

奥斯特胡特创建了一个谷歌小组,唯一的目的是分享对他的书的反馈,我认为这是令人钦佩的,并希望更多的作者效仿。在我发表这篇评论之前,我们对它进行了几个星期的讨论,包括“抽象”和“复杂性”的定义等重大问题,以及瀑布方法的历史等小众问题。如果你想看到他对这篇评论的更多细节,你可以在这里阅读。

POSD是我读过的三本软件设计书籍中的一本,我将其归入“中级”类别,也是第一本包含足够多代码示例以便于清晰交流的此类书籍。

..