The Essence of Software.Chapter.11.概念完整性
当一个由概念组成的系统运行时,每个概念都像一个小机器一样运行,控制着动作何时可能发生,以及它对概念状态的影响。同步机制可以进一步约束动作,使一个概念的动作与另一个概念的特定动作同时发生。
一个概念不能直接修改另一个概念的状态,也不能以某种方式改变其某个动作的行为。这是至关重要的,也是使概念本身具备可理解性的原因。
但是,只有在使用第 6 章中的同步机制正确地组合概念时,这种模块化才成立。如果实现概念的框架允许它们以其他方式交互,或者代码中存在错误,那么概念可能会以意想不到的方式运行,从而违反其规约。
设计人员也可能会破坏一个概念,调整其行为,以便在与其他概念组合时,它符合特定应用的需要。有些调整可能会在保留概念规约的同时增加一些新功能,但其他调整可能会破坏它。
由于所有这些原因,在将一个概念与其他概念组合时,保持其完整性 (integrity) 至关重要。在本章中,我将向你展示一些违反完整性的例子以及它们引起的问题。
一些违反完整性的情况(例如我们的第一个例子,报复性的餐馆老板)是显而易见的,一旦发现就很容易修复。有些(例如第二个,字体格式)则很微妙,代表了一场尚未解决的持续设计斗争。还有一些(例如第三个,Google Drive)虽然并不微妙,但只有付出相当大的努力才能修复。
一次公然的违反:报复性的餐馆老板
设想一个餐馆预订应用,它有一个包含预订 (reserve) 和取消 (cancel) 桌子动作的 预订 (reservation) 概念,以及一个让用户发布他们访问过的餐馆评分的 评价 (review) 概念。
这两个概念都有其定义的行为及其操作原理:对于 预订,如果你预订了并在正确的时间出现,就会有一张桌子可用;对于 评价,总体评分反映了之前提交的个人评分。
当组合这些概念时,设计人员可以将它们同步在一起。例如,她可能会决定,除非你预订了某家餐馆(或者甚至在那里吃过饭),否则你不能对它进行评价。这种同步将通过排除某些行为来约束应用——特别是那些用户评价他们从未预订过的餐馆的行为。尽管有这种同步,但当通过特定概念的视角看待时,该应用的每一个行为仍然是有意义的。
现在假设一位因为差评而感到沮丧的餐馆老板决定入侵这个应用,以惩罚不知感恩的顾客。他修改了行为,使得输入差评的顾客能够进行随后的预订,但随后发现——即使从未有过 取消 动作——当他们到达餐馆时,没有预订记录,因此也没有桌子。
这种入侵不对应于任何合法的同步。它不仅将这两个概念耦合在一起,而且破坏了 预订 概念。该概念的操作原理表明,如果你进行了预订并且没有取消它,就会有一张桌子可用。由于这种入侵,该原理不再适用,该应用也无法从原始概念的角度来理解。这就是我所说的破坏完整性。
另一方面,假设报复性的餐馆老板入侵了该应用,使得当任何顾客发布低评分时,会对该顾客在任何餐馆的任何预订执行 取消 动作。这个倒霉的顾客可能会收到取消通知(由于与 通知 (notification) 概念的同步),尽管他从未打算取消。
这种行为,无论多么刻薄和令人讨厌,都没有违反完整性,因为这种新行为从 预订 概念的规约方面是完全可以理解的。发现未经他们同意就发出了取消操作可能会让顾客感到恼火,但这行为仍然与该概念保持一致(其规约对于谁被允许取消预订的问题保持沉默)。

字体格式:一个长期存在的设计问题
在最早的文字处理器中,文本格式由三个简单的属性组成:粗体、斜体和下划线(图 11.1)。每个属性都有一个相关的动作来切换它,所以如果你对纯文本应用 粗体 (bold) 动作,它就会变成粗体;如果你再次应用它,它将恢复为纯文本。这个概念是如此熟悉,并且仍然被如此广泛地部署,以至于还要去命名它似乎有些愚蠢。但为了我们讨论的方便,我们姑且称之为 格式切换 (format toggle)。今天你可以在从电子邮件客户端到嵌入式富文本编辑器的数千个应用中找到它。
格式化文本的另一个重要的(也是早期的)概念是 字体 (typeface)。它的行为更简单:有一个字体列表,你可以选择一个并将其应用到某些文本。在早期,格式切换 概念被实现为一种转换,应用于由 字体 概念提供的字符:通过对字母形状应用倾斜来使字符变为斜体,通过增加字重的不同转换来使其变为粗体。
然而,真正的排版斜体从来都不只是罗马字体的倾斜版本,而是通常更加流畅和具有书法风格;同样,字体的较粗版本也不仅仅是更胖而已。
随着计算机排版技术的进步,以及 PostScript 字体的出现,在单独的字体文件中提供字体的不同粗体和斜体版本变得很常见,并且转换只用于缩放。文字处理器的实现者能够通过一个巧妙的技巧来维护这两个概念:格式切换 和 字体。当你将某些文本设置为斜体时,它切换到斜体字体文件;然后将其设置为粗体会切换到粗斜体字体文件;再次将其设置为斜体将切换到粗体字体文件;依此类推。通过这种方式,设计保留了这两个概念的完整性。

然后,随着专业字体的到来,麻烦出现了。现在,除了每种字体仅有几个变体之外,还提供了一个大得多的集合。这些与旧字体之间的区别通常是增加了诸如半粗体 (semibold)(介于罗马体和粗体之间)和黑体 (black)(比粗体更重)等字重,以及用于不同尺寸的附加变体,例如展示字体(用于设置非常大尺寸的文本)或标题字体(用于设置非常小尺寸的文本)。
有了这些丰富的内容,一切就乱套了,格式切换 也不再起作用了。图 11.2 显示了在 Apple 的 TextEdit 中发生的情况。
你可以看到我选择了 Helvetica 字体族,它有六个变体。第一行被设置为细体 (Light) 变体。然后我将文本复制到了第二行和第三行。对于第二行,我应用了一次粗体动作,对于第三行,我应用了两次。如果 格式切换 工作正确,两次应用粗体动作应该带你回到起点,所以第一行和第三行看起来应该完全相同。但是它们并非如此,因为应用一次粗体将字体从 Helvetica Light 更改为 Helvetica Bold,而再次应用则将其更改为 Helvetica Regular(而不是恢复为 Helvetica Light)。

简而言之,TextEdit 中 格式切换 的实现不符合其规约,但这不是因为代码中存在错误。问题更深层次,涉及这两个概念之间的交互。字体 概念的扩展破坏了 格式切换 概念。
Apple 试图在其生产力应用(如 Pages)中修复这个问题。对话框看起来就像 TextEdit,但粗体和斜体动作的行为不同。如果你将 Helvetica Light 中的某些文本加粗,它现在将是 Helvetica Bold(很自然);然而,如果你再次加粗它,它将恢复为 Helvetica Light(符合 格式切换 的规约)。但是这种行为是通过一些隐藏的魔法实现的,这引入了新的问题。
这种批评似乎有些吹毛求疵,但它实际上是桌面出版中的一个严重问题。图 11.3 显示了 Adobe InDesign 中的字符样式对话框。在这里,我正在定义一个名为 强调 (Emphasis) 的样式,用于要被强调的文本。通过将其设为一种样式,我希望能将某段文本是否被强调与它如何被强调(比如通过斜体、粗体甚至下划线)分离开来。对于字符样式的初始定义,我选择了“字体样式” (font style) 为 Italic(斜体)。请注意,这里没有选择“字体族” (font family);这是必不可少的,因为它允许将字符样式应用于不同字体族中的文本。
至少那是我希望的;实际上,它不起作用。为了应用这个 Italic 设置,InDesign 将字体切换到名称为字体族连接字符串 “Italic” 的字体。因此,如果文本在 “Times Regular” 中,它将将其设置为 “Times Italic”。到目前为止一切顺利。但如果文本在 “Helvetica Regular” 中,它将尝试将其设置为 “Helvetica Italic”。正如你从 TextEdit 屏幕截图(图 11.2)中看到的那样,我的 Helvetica 版本将斜体形式称为 “Helvetica Oblique”。所以字符样式实际上不是独立于字体的,并且只能成功地应用于某些字体中的文本。
有过其他尝试来修复这个问题,但似乎没有令人满意的解决方案。格式切换 概念就是无法与更复杂的排版概念相协调。
因 Google Drive 丢失毕生心血
我的妻子将她的大部分工作文档保存在 Google Drive 中。在看到了 Dropbox 中的事故(第 2 章)之后,我很担心她丢失她的工作,并开始寻找保护它的方法。
我了解到 Google Drive 本身并不提供备份,所以我不得不设计我自己的方案。我脑海中浮现出一个显而易见的主意。我会安装 Google Drive 应用并将其所有的云端文件同步到她本地磁盘上的一个文件夹中,然后将该文件夹添加到我已经在她的笔记本电脑上运行的备份实用程序的选择集中。这样,只要她的一个 Google Drive 文件被修改,本地版本就会被更新,然后就会被备份到云端。
我惊讶地发现这个看似直接的方案不起作用。在网上搜索是否有人想出解决这种困境的方案时,我遇到了一个悲惨的故事,一个人依赖这种方案的变体并付出了沉重的代价。
这个故事如图 11.4 所示。左边是起始状态,其中有两个文件,book.gdoc(一个 Google 文档)和 book.pdf(该文档的 PDF 导出),它们都存储在 Google 云中并同步到本地磁盘上的 Google 文件夹中。我们的主人公随后将这些文件移出了本地磁盘上的文件夹,导致了中间显示的状态。Google Drive 同步器随后运行,并试图使本地文件夹和云端文件夹的内容完全相同,它从云端删除了这两个文件。
此时,你可能会想,无论 Google Drive 发生什么,这些文件都安全地存储在本地磁盘上。可悲的是,情况并非如此。正如我们倒霉的用户所报告的那样:
第二天早上,我去打开一个 .gdoc 文件并收到这个错误:“抱歉,您请求的文件不存在。” 我的心沉了下去。昨天的工作发生什么事了?我打开另一个文件。然后再一个。所有文件都是相同的消息。我开始抓狂了。

确实,他的大部分文件都永远消失了。他的总结是:“由于糟糕的用户界面,我丢失了多年来保存为 Google 文档文件的工作和个人记忆。” 然而,正如我们将看到的,问题比用户界面更深层:这是一个违反概念完整性的问题。
我们的用户依赖于 同步 (synchronization) 的行为。这个概念的目的是维护两个项目集合之间的一致性;其操作原理是对一个集合所做的任何更改都会传播到另一个集合。同步与备份不同,它还传播删除操作;这允许你保持项目井井有条。同步的一个基本属性是两个位置中项目的副本应该是相同的。
不幸的是,Google Drive 同步器并不总是创建忠实的副本。对于常规文件(例如 book.pdf)确实如此。但对于 Google 应用文件(例如 book.gdoc),它根本不会将文件的数据复制到磁盘。相反,它创建了一个文件,其中仅包含指向云端文件的链接。这就是为什么尝试在本地磁盘上打开文件会产生错误消息的原因:单击它会在浏览器中为云端的一个不再存在的文件打开一个网页。
因此,除了 同步 之外,还有另一个起作用的概念,我们可以称之为 云应用 (cloud app)。这个概念体现了云端文档通过链接访问的想法。用概念术语来说,组合这两个概念侵犯了 同步 概念的完整性。
从概念设计的角度来看,修复这个问题并没有明显的障碍(与 格式切换 概念的情况相反)。我怀疑在 Google 看来,实现解决方案只是没有优先级,尽管令人惊讶的是,那么多使用 Google Apps 的用户竟然对没有备份并没有那么担心。
经验教训与实践
本章的一些经验教训:
- 当将概念组合起来以构成一个应用时,它们可以被同步(如第 6 章所述),以协调它们的行为。这种同步可以消除一个概念的某些行为,但永远不能添加与概念规约不一致的新行为。
- 但是,如果一个应用的概念组合不正确,可能会产生某种行为,从特定概念的动作和结构来看,该行为破坏了该概念的规约。
- 这些对完整性的违反会让用户感到困惑,因为他们对概念行为的心智模型被打破了。
以及你现在可以应用的一些实践:
- 当使用概念设计应用时,即使你没有精确地定义同步,至少也要让自己确信,概念之间的每次交互原则上至少都可以被视为一种同步。
- 如果你在使用某个应用时遇到麻烦,或者在分析某个可用性问题,并且你发现一个概念正在以一种意想不到的方式表现,请问问自己是否可能是来自另一个概念的干扰引起的。
- 为了确保完整性,请确保声称为通用的概念确实是通用的。在 Google
同步示例中,对完整性的违反在处理不同类型文件的不一致方式中显而易见。
