Archive for 2011年9月

为什么我不喜欢用建筑比喻软件

2011/09/24

最近在读《 Code Complete, 2nd Edition 》。谈论比喻 (metaphor) 的重要性时,这本书把建筑作为软件开发的主要比喻之一,并且认为这个比喻很贴切很有用。我认为这本书有很多正确的结论,但不包括对这个比喻的看法以及随之而来的某些直接推论。

对于一个建筑,很容易区分哪些是基础,哪些是附属(或者分辨各个部分更接近基础还是更接近附属的程度)。墙面的装潢很容易修改,改变墙体的构造就很困难。对摩天大楼来说,改变顶层的尖塔很容易,而改变首层的承重结构就几乎不可能。基础和附属在修改成本方面的巨大区别令人直观的意识到规划的重要性。构建一个各部分都「摸得着、掰得下来」的狗窝和构建一个很多东西「封在里面、压在底下」的摩天大厦绝对需要完全不同的规划方式、设计手段、和管理模式。对于后者,构建之前的确需要仔细分析,针对基础部分的决定一旦作出很难更改。

但是把这个比喻延伸到软件会引入众多似是而非的问题。可以从修改的难度来界定软件的基础和附属吗?可以从暴露的程度来区分软件的核心和外围吗?让我们尝试几种判断的方法。

首先,可以把软件系统中靠近硬件的部分,或者被最多的模块依赖的部分称为基础吗?可是,有无数的成功实例说明应用软件可以从一个操作系统移植到另一个操作系统,或者一个操作系统从一种内核迁移到另一种内核。在整个软件栈 (software stack) 中这种釜底抽薪的替换工作并不鲜见。在软件栈稍靠上的地方,也不乏保留上层模块而替换相对底层模块的例子。就在最近的 Adobe Creative Suite 5 for Mac 大部分产品从 CS4 的基于 Carbon 转换到基于 Cocoa。

可以把靠近用户的部分称为基础吗?大多数完全不了解编程的人可能都会反对。软件的界面、皮肤、look and feel 都是可以不断变化的。可以把与具体算法无关的机制称为基础吗?不久前我的 blog 里关于微内核和 XNU 的文章里可以看到内核的演化中,各个模块的运行级别可以在内核态和用户态之间切换,通信机制也可以发生变化。可以把与具体机制无关的算法称为基础吗?Linux kernel 的调度算法在不改变进程切换机制的情况下从线性时间复杂度变成常数时间复杂度。Mac OS X 的 built-in VNC 实现 (Share Screen.app) 在不修改基本机制的情况下加入了大大优于其它实现的屏幕数据压缩算法。符合标准的接口和协议是不能轻易修改的基础吗,比如 x86 指令集或者明文协议?你总是可以用某种中间翻译层来替换它们,比如 VMWare、Linux 的 Wine、Windows 下的 Cygwin、以及 Mac 下的 Rosetta 或者 ssh tunnel 这样的 gateway 方案。

所以,软件和建筑根本的不同是前者不可能存在被「封在里面、压在下面」的「基础」。《 The Art of UNIX Programming 》里告诫程序员,让软件系统长久保持生命力的唯一手段,是把它分解成功能独立而且可以替换的模块。换句话说,软件系统中出现了如同建筑中那种不能轻易修改的基础才是失败的设计。软件需要一定程度的规划,却并非如同建筑所比喻的那样。相反,这种规划是为了让软件具有完全不同于建筑的灵活性。软件同样需要事前考虑避免重复投入,但是在软件系统中,并没有建筑中那种一损俱损的中心基础模块,修复不同模块的设计缺陷的花费并无数量级的区别。适度的事前规划软件开发是必要的,但是我见过很多的新手惧怕的风险其实来自「建筑的基础」这个蹩脚的比喻,事后往往证明,很多在他们看来很严重的必须在 coding 之前分析透彻的潜在问题,都能非常容易在 coding 进行到中后期的时候被解决。而且在这个时候,他们往往已经从 coding 和初步的 testing 中吸取了很多事前分析无法获得的知识和分析手段。而他们认为的一些「关键」组件,即使不幸存在设计缺陷,也并不比外围组件的缺陷修复起来更难。

MVC:用来打破的原则

2011/09/07

去年的《 C 是 MVC 的 C 》简单写了些 MVC 的基本原则,特别是关于 controller 这个经常被忽视和误解的模块。有人认为对 controller 的不同认识叫做 MVC 的不同「流派」或者「变种」,我不能认同。我相信只有符合《 C 是 MVC 的 C 》里所述原则的 MVC 是降低整体复杂度的最佳方案。另外,我最近在读《 The No-Designer’s Design Book 》,并且向没机会系统学习设计的 UI 开发者推荐这本书。为了在一本书的篇幅里让 no-designers 把握设计的关键,它没有讨论什么不同的「流派」,而是把产生好设计的要素归结为四大通用原则。

有人可能会对上面两件事产生同样的质疑 —— 面对一个博大的领域,拘泥于几条有限的原则,是不是纸上谈兵,削足适履?我这篇 blog 就是想写些实际开发中遭遇的细节与原则之间的冲突。对于这种冲突,《 The No-Designer’s Design Book 》有两句精彩的总结:

  • You must know what the rule is before you can break it.
  • Either do it all the way or don’t do it. Don’t be a wimp.

UI 程序的设计开发就是关于遵循和打破 MVC 原则的权衡,而这个权衡恰恰说明了坚持唯一原则的重要性。我相信在唯一原则下有意识地考虑取舍优于在几个所谓 「流派」之间选择或者摇摆。

反面向对象的 Model

设计模式产生于面向对象的方法。但 MVC 最有意思的一点是 model 的设计原则体现出反面向对象思想。面向对象的第一要旨是数据和行为的统一。Model 表示一组数据,对于一组数据来说,最重要的行为有两类,一是在某种场景中体现自己,二是对外界的交互产生反应改变自己。MVC 模式里,第一类行为被划分为 view 的职责,第二类行为被归为 controller 的职责。Model 呢?Model 就是一组数据而已。Model 模块被剥夺了「对象」的特质。MVC 的理想状态是回归到「数据」加「算法」的传统模型。从另一个角度看,我经常说面向对象技术不适合 UI 之外的领域。作为 MVC 中和 UI 本身关系最远的模块,model 印证了这个观点。

主要行为被 view 和 controller 剥夺之后,model 勉强保留了一个行为 —— 保持数据的一致性。在《无毒副作用》中提到过,保持一致性并非本质上不可或缺的能力,没有冗余的理想 model 无需任何行为就能天然地保持数据一致性。所谓冗余就是指 model 中的一部分数据总是可以被另一部分数据计算出来。那些不能从其它数据计算出来的最基本的数据即「自由变量」,被计算出的数据即「依赖变量」。保持一致性的行为就是用自由变量重新计算和更新依赖变量的操作。在理想条件下,「依赖变量」应该作为 view 重新绘制时的临时数据而非 model 的一部分。

但现实中 model 终归无法避免冗余,所以本质上不必要的行为在实际中是不可或缺的。保持数据一致性的行为是 model 设计中细节复杂度的主要来源。比如在复杂耗时的数据变换中,如何在达到最终数据一致的过程中,找到保持局部一致性的中间状态,并且通知 view 进行平滑的显示。这在《并发与并行》中有所讨论。Model 是理想中的傀儡,在向现实的妥协中成长为一个负责的对象。

性能与一致性

在计算机领域,最伟大最纯粹的设想往往只为一个原因妥协 —— 性能。MVC 也不例外,它的理想是通过牺牲性能来降低整体复杂度:无冗余的 model 从根本上杜绝数据不一致的问题。一旦需要,view 就遍历整个 model,计算所有绘制所需的「依赖变量」,最后重新绘制界面。每个 UI 的实现,为了避免 premature optimization,都应该从这个状态开始。但是当 UI 真正发布的时候不可能还保持这种干净的状态。UI 迭代开发过程的重要部分就是不断根据需要在 model 中添加「依赖变量」来满足 view 的性能需求。引入「依赖变量」会增加 model 数据不一致的机会。如果为了抵消这种风险而过多添加和调用保持数据一致的代码,反而会导致性能损失。开发 UI 的整个过程是仔细权衡数据冗余和一致性级别的调优过程。也许构建强大的服务后端难度不亚于建设一个电站,但构建精致的 UI 也不亚于设计一台精巧的引擎。

如上所述,model 的设计要考虑几种平衡:

  1. 如何以及在何种程度上引入「依赖变量」。
  2. 在何时进行保持数据一致性的工作。其可能的时机包括 controller 的事件处理,view 的更新,后台 thread 或者 timer。

这些决定会影响 controller 和 view 的处理时间,而 controller 和 view 的处理方式恰恰是不同平台处理方式差异最大的地方:

  1. 平台会以什么样的频度对 controller 和 view 分别发出事件?比如,在不同系统中,mouse move 的频度有很大区别。
  2. Model/controller 通知 view 更新的机制会被如何处理?在有些平台上,这种通知是不可靠的, 在频率过高的时候会被 view 忽略,而在另一些平台上,每次通知都会得到 view 的完全处理。

所以,UI 的用户体验问题经常会从这种平台差异中显现。虽然没有统一的方式来解决它们,但是有一个很好用的简单方法来判断问题的类属:如果在  controller 或 view 的处理函数中稍稍改变处理频度(比如三次调用中的两次处理成 no-op 直接返回),问题的症状会发生变化甚至消失,那么基本上就是数据冗余和保持一致性的级别问题。对于如何设计「依赖变量」,不光要考虑冗余数据量的多少,也要考虑冗余的层次,即某些「依赖变量」是由其它依赖变量计算而来,为高一级的依赖变量。在需要快速响应的情况下,不更新或者只更新少量低一级的依赖变量,同时尽量使用之前计算好的依赖变量。在可以放慢响应速度的时候再更新各级依赖变量。

另一方面,某些「自由变量」在加入时间约束之后,可以简化为更少量更精简的数据,也就是在 model 暂时存入「变化量」,让 model 暂时维护 change 而非纯粹的 state,可以进一步减少 controller 的处理时间。从这个意义上说,自由变量是时间和变化量的「依赖变量」,在 model 中暂存变化量是对自由变量的「延迟存储」,是另一个方向上的冗余。

背离理想状态是为了更好的处理某些操作,但背离的程度越高,MVC 系统的弹性就越差。MVC 的弹性表现在可以处理设计者预料之外的用户操作。理想状态的 MVC 可能会因为性能低下而表现出响应不够敏捷,或者显示的更新不够平滑,但是不会陷入不一致的状态,甚至是除非关闭程序否则无法恢复的状态。而增加数据冗余的系统面对用户的随意操作其崩溃的危险也会增加。关于理想 MVC 系统弹性的讨论详见《 C 是 MVC 的 C 》。

Model 的 View 还是 Viewer 的 View

View 是 model 的 view 吗?回想一下别人或者你自己解释为什么系统中要有多个 view 的时候是否举过这个例子:有一组数据,又要显示成直方图,又要显示成饼图,又要做成表格 ⋯⋯  这个例子里,view 是 viewer 的 view 而不是 model 的,因为 view 的类型是为了满足 viewer 的解读而不是 model 的结构。但是,作为 viewer 的 view 面临一个难题,viewer 需要的并非总是直方、饼、表那样泾渭分明的形式,他可能需要一个表格里有饼图,另一个饼图里有表格。另一方面,model 里的某些数据可能在多个 view 里都要显示成同一种形式。

如果把 view 按照 viewer 的解读形式来组织,那么在处理混合显示时就可能不得不在多个 view 模块里加入重复的代码。有人说给不同 view 加上 common base class,建立 view 的继承树。我不相信这种方法指导下产生的代码是可持续维护的。另一个诱惑是把这些代码移到 model 中,让数据自己显示自己。但 model 夺取本该属于 view 的行为是对「关注隔离 (separation of concern) 」的违背。而且,MVC 系统只有一个 model,让它背负 view 的责任容易让 model 需要实现的操作数量面临组合爆炸 (combination explorsion) 的困境。

所以,除了 viewer 的 view,在复杂系统里还必须实现 model 的 view。为某些数据编写负责显示它们的 view 模块,让 viewer 的 view 来重用。在有些 UI framework 里有这种 model 的 view,比如 Java Swing 中的 renderer。这些模块始终都应该是 view 的一部分,而非 model 的行为。除了「维护数据一致性」,不要随意给 model 增加任何行为,因为「维护数据一致性」本身已经是背离理想状态的产物。

最成功的对象

如果说面向对象在 UI 之外的领域是失败的,那么反过来考虑,如果 view 都不能作为「名副其实」的对象那么面向对象这门技术也就一无是处了。而 view 确实在某种意义上是最「成功」的对象,它的行为最复杂,同时也拥有数据。

View 的行为复杂不必多说,它的绘制代码是用户直接接触的整个应用最炫的部分。但 view 不适合直接拥有数据,因为那是 model 的责任。那么 view 如何拥有数据?答案是:MVC 是可以嵌套的,而这种嵌套就直接作用在 view 上。在嵌套形式中,内层 MVC 作为外层 MVC 的 view 的一部分,外层 view 操作或者直接作为内存 MVC 的 controller ,外层 view 的一部分或者全部绘制行为是从外层 model 向内层 model 搬运(经过转换的)数据。经典设计模式中的「组合模式 (composition patter) 」的一些实际应用就是多层嵌套 MVC。常用 UI framework 提供的现成 control 通常就是局部的微型 MVC 系统。嵌套 MVC 是 view 的主要数据构成,也是 model 数据冗余的另一种形式,维护一致性的时机一般在最外层 view 的绘制操作中。

所以,当系统的某一部分过于复杂但又有很强的内在联系,不必强求把数据和行为分拆到系统的最外层 MVC,而是可以把复杂的部分实现成内层 MVC。代价是内外层的 model 之间会有一定程度的数据冗余。

MVC 模式的三个模块,controller 趋于分散(见《 C 是 MVC 的 C 》),model 趋于纯数据结构,view 强于行为,有时也间接拥有数据。MVC 和面向对象是共同产生的,它们的外延也趋于一致。MVC 看似描述了三个对象,其实是一个对象拆开的三个部分。