再抽象一点

软件开发是控制复杂度的艺术,是『抽象』[1] 的艺术。软件开发者要熟悉和操作各种抽象。无法想像离开进程、套接字(socket)、内存地址空间(address space)、互斥量等等这些抽象如何构建有用的软件系统。但是,也有大量的垃圾抽象,它们声称能带来的好处无法抵消造成的麻烦 [2] ,仅仅当情况和设计者提供的例子最接近甚至完全相同的时候才能隐藏复杂度,而在其它情况下 —— 比如新的 use case ,和第三方库集成,满足性能需求,提高用户体验 [3] ,或者在学习阶段等等 —— 都必须一再让其开发者对本来应该隐藏底层的细节不得不进行深入了解。

优秀的抽象往往也是稳定的。尽管底层系统沧海桑田,优秀的抽象概念不会变化,还会默默地借助底层的发展为开发者提供免费的午餐。而垃圾抽象们每每梢有风吹草动,就要推出一个划时代的新框架,呼吁号召大家抛弃遗产(legacy)。为什么不同抽象的差异这么明显?

两种方向

Donald Knuth 在《Coder at Works》中解释他设计软件的方法:一方面,有一个总的目标,从总目标开始把问题细化(break down),分别解决每一个问题,在解决的过程中再做进一步的细化,这是自顶向下的方式(top-down);另一方面,对手头的原始接口 [4] 从直觉和经验出发进行初步加工,简化原始接口的一般复杂性,较少直接考虑最终目标,这是自底向上(bottom-up)。除非是非常简单的问题,几乎不可能通过纯粹的 top-down 或者纯粹的 bottom-up 达到目标。当 top-down 方式被过于频繁的 break down 不断打断,我们就会转入 bottom-up 模式。在 bottom-up 过程中,如果底层接口中显而易见的可简化部分被处理完,进一步的简化方向不能确定,我们就会转入 top-down 模式。Eric Steven Raymond 在那本著名的《The Art of UNIX Programming》里也阐述过类似的理论。

大型通用开发领域的『抽象』基本属于 bottom-up 方式 —— 不直接关注系统的最终目标,集中精力隐藏中间环节的复杂度。而且,在通用软件领域的竞争和协作的大环境中抽象意味着执行 bottom-up 减少接口复杂度和执行 top-down 完成最终目标的是不同的人群。这意味着 bottom-up 的执行者必须预测 top-down 的执行者的意图,通过预测后者的行为,降低接口的整体复杂度。而且,应该承认,为了降低接口复杂度,就必须牺牲底层接口的灵活性。不牺牲任何灵活性的抽象是不存在的(除非这个抽象没有降低接口复杂度 —— 失败的抽象,或者底层接口的某些灵活性本来就是画蛇添足)。所以,只有成功的预测才能保证牺牲的值得。

直接交流

预测他人行为最好的方式是直接问他要干什么。『抽象』的构建者与很难大范围的与高层开发者直接交流,即是由于交流成本,也是必须避免得到的需求过于分散。如果缩小直接交流的对象范围,最后构建出来的『抽象』往往会成为一个高层项目的附庸,或者专门为特定的需求特殊的小群体(niche)采用。先不谈为 niche 设计的抽象,作为高层项目附庸的抽象重用度不高,基本可以算 top-down 的行为通过重构(refactor)的延伸,但是这种努力绝不算失败,Linux kernel 中经过重构形成的 CPU 抽象接口( MMU 抽象、SMP 内存同步操作等等 )是个成功的例子。尤其是考虑到通过其它预测方式创造出来的所谓通用抽象被高层成功接纳的几率,我觉得更应该提倡这种不那么有野心的抽象方式。

相反,有些抽象的设计者有意避免通过直接交流决定抽象的主要概念和功能,从而期望让其抽象保持最大的通用性。比如进程这类抽象适用于整个计算机行业,今天已经很少有计算行为脱离进程这个抽象来完成了(除去一些嵌入式设备)。也几乎不会有网络应用脱离 socket 来开发。但是有更多的避免直接交流而设计出的抽象并不成功。比如,很多公司已经或者正在试图把所有操作系统的 GUI 开发接口统一到单一的抽象(比如 Java Swing/SWT,Adobe AIR,Qt 在 KDE 之外的努力等等)。这些努力拒绝向某个或者某种应用倾斜,而把自己定义为适合绝大多数应用 UI 开发的抽象。就我认识的业界现状,只能说这样的努力都很失败 [5] 。下面谈谈通过直接交流之外的方式设计抽象的成败原因。

控制行为

预测一个人更好的方式是规定他要干什么。对于抽象的构建者来说,最好的方式是完全不让高层用户接触底层接口。但是,这样要面对双重压力,第一种压力是开发者无法忍受灵活性的牺牲,要求直接接触底层接口。因为灵活性的牺牲往往意味着不能满足用户的特定需求,特别是那些底层接口可以实现但是由于被高层抽象牺牲灵活性而变成不可能的需求。第二种压力是底层的实现者总是要极力推广自己接口,没有人设计一个接口从一开始就是为了让另一个人来封装的(如果这样最初的设计者就是承认自己的接口设计的很烂),在高层抽象的实现者鼓吹复杂度的降低时,底层实现者也会极力展示那些用高层抽象无法达到的效果。

两种情况可以降低上面的两种压力。第一是底层的提供者为了实现简单,提供的接口复杂度非常高,几乎不考虑解决实际问题的难度,大多数开发者因为过高的复杂度主动避免直接接触底层接口。第二是底层接口的承载体由于成本或者其它原因不能被大多数开发者接触到。高级语言、进程、套接字等等成功地被广泛接受的抽象,在其早期都满足其中一种或者两种情况。但是,这两个缓解压力的情况并不是经常满足的。提供高复杂度接口的行为几乎被限制在了硬件设计行业,PC 等开放平台占据主导地位也让第二种情况变得越来越罕见 [6] 。

并且,在竞争条件下,只要有相当一部分人采用了某个级别的抽象,他们在最终产品的性能和细节上的竞争力往往就会超过采用更高级别抽象的最终产品(假设更高级别抽象做出了性能和灵活性的牺牲)。这样,即使一个更高级别的抽象最初吸引了很多人甚至是相对多数的开发者采用,只要低级别的抽象有一定用户比例,他们的产品的高竞争力也会最终迫使大多数开发者回归。这意味着在竞争条件下,只要有一个开发者对一个抽象施加了第一种压力,他的行为就会迫使其它开发者把这种压力增大几倍。一个抽象成为主流的关键在于早期就要抢占绝大多数低层接口的直接用户,保证不再流失用户。这里的绝大多数不是 51% ,而可能是 85%,95% 甚至更高。而这种快速抢占用户群的情形只有满足上面提到的缓解压力的情况满足的时候才能发生。一个不是非常难用的接口,是不会轻易流失用户的。高级语言在出现之后就很快就让绝大多数开发者 [7] 放弃了直接使用低层的汇编。而之后的 C 语言等比较低级的语言不管有多少缺点,都不会像汇编那样迅速丧失几乎所有的用户,尽管一旦有更高级的新语言出现(特别是背后有大财团的那种),C 就会失去部分开发者,但是随后它们在竞争中总是能和更高级的语言在争夺开发者上进行拉锯。一个抽象概念的提出是应该一击必杀,打持久战就意味着在大型通用开发领域输给了底层接口。

另外,在协作条件下,主流采用的抽象级别也会影响后来者采用的抽象级别。如果你需要的很多第三方支持只提供底层的抽象(比如基于引用计数的直接内存模型),就很难在自己的系统里采用高级抽象(比如纯粹的不带直接内存访问的对象模型)。使用某一种抽象方式的模块越多,就会有更多的模块采用同类的抽象。基本上,90 年代之后,商业软件的开发抽象层次停留在 native 进程(而非 managed 虚拟机)、直接内存模型(有时附带引用计数)、C 语言这一层面上很长时间。类似竞争条件下的那种压力倍增的效应在协作田间下也存在,虽然程度稍低。

以网络通信来看,CORBA 等基于 remote-procedure-call 概念的技术曾经希望以更直观的类似函数调用的方式取代基于 socket 链接的 application-protocol 方式。因为大多数系统仅仅提供对 Socket 完全兼容的实现,最终今天主流的网络开发完全退回到 application-protocol 方式。这是协作和竞争条件共同施加压力的结果。

以编程语言来看,一般只在 C 语言之下的级别才符合缓解压力的两种情况。由于汇编语言等比 C 更低层的抽象过于和机器的实现方式接近,大多数开发者对它们不会有全面的认识,所以少数的编译器和操作系统实现者完全垄断了这种接口的使用。这些少数的接口垄断者可以完全控制上层使用低层的方式,从而可以相对来说比较自由的牺牲某些灵活性而不会受到过多的来自上层开发者的压力。同时,因为都处在被编译器和操作系统将低层接口隔离的境地,上层开发者也不会因为竞争压力而采用更低层的接口。更高级的语言一般建立在 C 之上,有些在操作系统之上还提供虚拟机实现。对于它们来说不幸的是,不管它们声称 C 和操作系统的接口多么难用,这些接口还是还是能被一个本科生基本掌握(相反,汇编和硬件接口对于工作过几年的人来说仍然有一定困难)。所以,不管它们提供了多少便利性,在商业软件开发的竞争和协作中总是会受到直接使用 C 和操作系统的产品的排挤。不管有多少人推广更高级的语言,只要在大型通用开发中有一小部分,比如说 5% 喜欢 C 并且真的能够做出好的产品,他们产生的压力就足以诋毁对更高级语言的推广。更高级语言今天还是只能为专业定制化、企业开发和原型开发广泛采用,因为在这些领域竞争压力和协作的需要比商业级开发小得多。

猜测行为

当你并不甘心依附于某个高层项目,又找不到合适的底层复杂接口,也没有财力去忽悠或者控制别人应该用什么样的平台,剩下的就只有猜测用户对抽象的需求了。遗憾的是你几乎肯定会进入这样的境地:因为你封装的接口并不极端复杂,所以能理解并且直接使用它的人很多,尽管这些人也都希望简化这个接口,但是他们各自希望的简化方式大相径庭,你永远没法讨好所有人、大多数人、甚至没法讨好足够多的人。更糟糕的是,那些一开始喜欢你的抽象的人发现,他们的对手直接用底层接口往往能更精细的实现一个功能,于是开始对你提一些不符合你的抽象设计原则的要求,结果是或者你的抽象开始越来越凌乱,或者在在你拒绝他们的要求之后这些人回到底层接口。

结论

今天,并不是每个从事计算机开发的程序员都了解关于 MMU,寄存器,缓存级别,以太网这样的知识,但是他们中相当一部分都了解进程、套接字、消息循环、事件驱动这样的概念。这样的一部分人存在于商业软件开发的行业中,即使他们不占相对多数,通过竞争和协作的互动,也足以让比他们熟悉的抽象层次更高的抽象失去主流市场。

尽管抽象是控制复杂度的利器,但是大型通用开发领域对于新抽象的接纳是极为抵触的。今天当我们有冲动提出一个新抽象的时候,除非作为长期谨慎的研究行为,应该慎重思考是否应该放弃,或者应该根据现有的抽象概念实现新的可重用组件。除非是原型、临时使用的专用程序、或者为特定行业开发的非通用应用,应该拒绝那些没有占据绝对主导地位的抽象方式。

注释:

  1. 本文只关注面向开发者的中间层次抽象。抽象体现为 API,库,底层服务等形式,同一种抽象可以有不同的具体体现。比如,POSIX 的文件操作和 OS X 的 File Manager 是两种 API,但是它们同样基于无结构文件的创建、读、写、权限的文件抽象。比如,UNIX 和 Windows 对文件的权限设置不同,但是它们同样采用基于 owner,group,everyone 这样的访问权限列表抽象。IMP4 和 POP3 都是采用 application-protocol 这样的抽象概念。
  2. 同下文,带来的好处和麻烦都是指在大型通用开发的领域。一种技术在开发大型通用应用和开发小型原型中的优势和劣势会有很大区别。一般来说,前者在解决主要问题之外,还要比后者更多的考虑边缘情况(edge case)和长期使用维护成本。
  3. 性能需求和用户体验在小型原型、企业或者特殊行业开发、临时小型工具中的需求最低。这方面需求的降低往往会连带降低对新 use case 和第三方库集成的需求。
  4. 『原始』指底层机制提供的接口的高复杂度和低组织结构化。
  5. 这些跨平台 UI 框架在内部工具开发和原型开发上取得了一些成功,这里的失败是指在大型通用软件市场方面的失败。
  6. 有趣的是移动设备的开发似乎又提供了这种可能。
  7. 时间上和今天的技术相比并不快,但是考虑当时的还在 PC 成为大众化产品之前,在计算资源贫乏的情况下当时接纳高级语言的速度是很快的。

发表评论

Fill in your details below or click an icon to log in:

WordPress.com 徽标

You are commenting using your WordPress.com account. Log Out /  更改 )

Google+ photo

You are commenting using your Google+ account. Log Out /  更改 )

Twitter picture

You are commenting using your Twitter account. Log Out /  更改 )

Facebook photo

You are commenting using your Facebook account. Log Out /  更改 )

Connecting to %s


%d 博主赞过: