Archive for the ‘软件开发’ Category

链表迷魂阵

2009/12/15

只要粗粗看过数据结构,对链表的印象一定是插入、删除操作都很快。不过对 C++ 标准库里的 list(也就是 std::list )就得多加小心。比如下面的代码:

std::list node_list;
...
NodeData a = node_list.front();
...
node_list.remove(a);

熟悉 STL 的人可能一眼发现其中的陷阱:node_list.remove(a) 可不是一个 O(1) 的操作(虽然经典链表的删除操作是)。这是因为 std::list<T>::remove(const T&) 实际是一个经典链表的查找操作加上一个经典删除操作。虽然能看出这个问题的人也许不在少数,但是犯下这个错误的人也不在少数。而且,当前者遇到后者编写的代码时,修改起来往往很头疼。因为虽然 NodeData 是从链表中取出的,但是它并没有存储前后节点的信息。所以要想把上面的有缺陷的代码改正,必须把所有涉及到 NodeData 参数传递和临时存储的地方都加以修改。所以说,使用 std::list::front() 这样的拷贝语义函数的行为本身看起来就是个错误。

这里先插上两句说说所谓『经典』链表(因为后文会拿来比较)。很多数据结构的书里的链表就是把 NodeData 这个对象本身加上一个 prev 指针和一个 next 指针,用来分别指向前一个和后一个节点。所以『经典』链表的数据结构和节点数据本身由一个对象表示。经典链表对 NodeData 的定义是侵入式的 —— 如果你希望把一个原来和链表操作完全没有联系的对象或者 struct 加入链表,就必须修改它本身的结构。相对的,std::list 的方式是把链表看成一种『容器』,包容原有的对象而无需修改其结构。

所以你的第一反应也许是应该永远用 std::list::begin() 或者 --std::list::end() 之类的操作返回 iterator,相比 NodeData,iterator 更像经典链表的节点,带有前后节点的联系。但是 C++ 永远不会给你简单的方案。这个方案的问题在当在代码的多处拥有指向同一个 NodeData 的 iterator 时,调用任何一个 iterator 或者 list 本身的 erase() 方法都会导致其它 iterator 失效。而且,C++ 标准库仅仅告诉你唯一的以定义行为是其它 iterator 会『失效』。你甚至没有一个 valid() 方法来检验一个 iterator 是否失效。C++ 标准期待的是你无论如何知道这一状态并且决不能再对那些 iterator 做任何操作。

所以 std::list 相比『经典』链表有两个致命问题。第一是直接取出 NodeData 的操作丢失了和 list 本身的联系,让后续操作承受性能损失。第二,『经典』链表可以通过设置 prev/next 让任何拥有 NodeData 指针的代码轻易的判断一个节点数据是否还在链表中,而 std::list 对 iterator 行为的粗略定义让这一点变得不可行。

接下来我们把问题搞得更全面(也更复杂)一些,考虑 NodeData 的生命周期。C++ 标准库一贯的拷贝语义让这个问题得到了(幼稚地)解决。但是对于 std::list 来说,我们已经看到拷贝语义的操作会让取出的节点丢失和 list 的联系,而总是取出 iterator 又会带来失效的问题。

当然,经典链表虽然没有 iterator 失效问题,但是仍然要面对何时销毁节点数据本身的问题。不过,讽刺的是并非所有的数据都是可复制(copiable)的,所以 std::list 里面存储的也经常并非数据本身,而是数据的指针。因此,在同样面对 std::list<NodeData> 的『联系切断』和『iterator 失效』两难之际,std::list<NodeData*> 还必须面对经典链表的销毁节点数据的时机问题。更遗憾的是,C++ 的内存管理法宝 shared_ptr 在这个问题上完全无能为力。一个 std::list< shared_ptr<NodeData> > 同样拥有『联系切断』和『iterator 失效』的矛盾。这时只有经典链表,加上在 NodeData 中实现手工引用计数才算一个比较完美的方案。

链表是 C++ 的好几个设计理念走麦城的地方。链表重在『链』,它的灵活性就在于『链』。把链表作为『容器』,特别是和 STL 其它容器一样保持拷贝语义操作就毁了链表。而且基于拷贝构造和自动析构的共享指针和手工的引用计数相比也并非处处领先。

如果一开始能抛开 STL 那种容器的概念和对数据节点不侵入的要求,C++ 的链表设计不会这么差。比如 Linux 内核的链表设计,把链表节点作为链表数据的一部分,让链表数据包含节点,而不是反之。这样的设计用 C++ 模版也完全可以作到。(当然我更喜欢 Linux 内核的基于宏的设计,而且 Linux 的设计通过一个可被优化的 forced cast 同样保证了类型安全。)

后记:

写这篇 blog 的时候又重温了一下 Java 的 LinkedList 文档,发现 C++ 还不是最差的。LinkedList 的文档是,至少 6.0 还如此 —— 对 LinkedList 做的任何结构更改(什么意思?删除节点算吗?)都会导致所有已经获取的 iterator 失效。什么玩艺,这样的话我还用链表干什么?

对 Basic 的误解

2009/09/20

事情的开始是上周无意中找到了原本以为丢了的《Go To》。这本书读起来很有意思,比如我从中找到了 C++ 和 COBOL 的相似之处。所以,当初以为弄丢了的时候很失望,这次『失而复得』也很惊喜。接着上次停下的地方开始读下去,很快的结束 UNIX 的章节,进入关于 Basic 语言的历史。

我一直以来认为 Basic 不是一个成功的尝试。对于软件开发人员来说,Basic 很像小孩子用的简易学行车,没有必不可少的需要,用的不好还有让孩子骨骼变形的危险。Visual Basic 也是整个业界一次尝试简化开发的歧路(Delphi 部分的修正了这个错误,不过根本的问题是快速应用开发(Rapid App Development)本身就是一个急功近利的产物。今天,GUI 开发中传统的 RAD 已经接近消亡,取而代之的是 Cocoa、GTK、QT、Flash 等等根基更为稳固的技术)。

这次读《Go To》,发现这完全是对 Basic 语言的发明者 Thomas Kurtz 本意的误解。Kurtz 丝毫没有希望 Basic 成为计算机技术人员的入门语言,他的愿望是希望给文科学生提供一个接触计算机技术的机会。这些文科学生将来并不会从事计算机技术和科学的研究,但是他们更可能在社会生活中作出高层决定。如果他们完全不了解计算机技术,那么他们在一个计算机技术主导的社会中作出的决定是否明智就很成问题。也就是说,发明 Basic 语言的本意是社会层面的,是一次对大众和潜在的高层管理人员的公关行为。(相对来说,在中国推广 Basic 语言的行为有一种误导的倾向。Visual Basic 这种尝试也是对其本意的一种偏离。)从这个意义上说,不但 Basic 语言的结果是成功的,能够提出这个设想本身就说明美国的知识阶层是有远见的。

如果乔布斯会编程

2009/08/27

Forklore.org 是一个相当不错的网站,专门收录第一代 Macintosh 设计期间的珍闻轶事。今天有件事情让我想到了里面的一篇:PC Board Esthetics

事情开始是吴雨鸿为修改一个 bug 写了一些代码并由整个 team 来 review。代码的算法很复杂。凭经验我感觉应该有更容易理解的方法。经过长时间的讨论,证明雨鸿的方法的复杂度也不是全无理由,因为原来程序的数据结构中有一个单向关联(unidirectional association)。如果用比较清晰的算法来写,需要蛮力遍历比较多的数据。(再次说明《Art of UNIX Programming》里面指出的数据结构清晰的重要性。)不过我认为在复杂算法和蛮力算法之间进行选择并不困难。When in doubt, use brutal force 应该是永恒的真理。

讨论 UNIX 原则和文化并非此文的主旨。问题在于,我突然遐想 Apple 的软件工程师在这种事情上会如何收场——特别是在 Steve Jobs 过问的情况下?在 PC Board Esthetics 里,Steve Jobs 激动地驳斥了一位工程师以用户不可见 PC 板为理由而放弃美学追求的意见——『高超的木匠不用糟木头做柜子背面!』 即使面对更为实际的电气方面的反对意见,Jobs 仍然坚持先按美观的方式做出来试试。结果 Macintosh 设计团队无怨无悔地多花了 5000 美元来证明美观的设计确实在电气方面存在难以克服的缺点。『退而』采用了朴素的设计。

硬件如此,软件又当如何?正如《In the Beginning It was the Command Line》里面说的,Bill Gates 自叹在品味上不如 Jobs,但是也有资本嘲笑 Jobs 不会编程。看来似乎 Apple 的软件工程师们可以大大地松一口气。可能也不尽然,Jeff Johnson 这位大牛在他的 blog 里介绍了脱离 XIB(以前叫 NIB)文件使用 Cocoa 开发的系列文章,在最后一篇里说 Jobs 来信说所有文章他都看过,明确承认大多数 Apple 的应用程序其实都是没有 NIB 的,还指出了哪些技术是 Apple 正在使用的。是个玩笑还是 Jobs 过人的精力再次给我们惊讶,谁知道呢?

如果 Jobs 会编程,Apple 的软件工程师会遭受什么样的折磨呢?就拿今天的例子来说。也许 Jobs 会笃信 UNIX 的格言,毫不犹豫的让手下把程序改成简洁的蛮力算法。也许 Jobs 凭借一贯的独特美学品位不会轻易接受蛮力算法,但是也绝对不会为了省去两个循环就允许工程师们用各种匪夷所思的手段去摆弄数据结构。他一定会要求一个可以理解的算法,并且用最生动的注释来说明。虽然是注释,排版也必需讲究,最好是能够形式地严格证明这个精巧的算法和蛮力算法在逻辑上等价。所以最后的结果可能正如那块折磨硬件工程师的 PC 板,软件工程师们在无怨无悔地多花了几天时间研究优美的算法是否可行之后重新采用蛮力算法。

一周杂记:显示、形式证明和Git

2009/08/19

这周一直在看 OpenGL 的材料,没时间思考什么大块的东西(正在惶恐是不是以后会越来越多地写下这句话)。零零碎碎的有些可以记的东西。首先说个题目里没有的东西,国内访问不到的网站越来越多了。昨天居然连 arstechnica.com 都被墙了几个小时(也许是地震?)。Wordpress-china.com 回光返照了一天,还没来得及迎接我的一篇文章,就再次倒下了。

大多数时间在看《 Interactive Computer Graphics: A Top-Down Approach Using OpenGL, Fifth Edition 》。同时看 Apple 的 OpenGL Programming Guide 。前者的第一章根本没谈 OpenGL,海侃了一番计算机图形的发展,本打算略过,最后还是坚持读了。不算白费功夫,确实搞清了很多一直以来疑问。比如 LCD、LED 和等离子三种显示方式有什么不同——前者每个像素是一个液晶极化挡板,依靠遮挡背光来变换颜色,后两者每个像素都是一个独立光源,但是发光原理不同。还有为什么 GPU 比 CPU 处理图像更快,这也是 MMX 指令集的原理——多步骤流水线,同一组指令(对于软件呈现为单一条指令)处理大量数据,代价是处理分支的能力很弱或者没有。

这周在网上看到的最激动人心的新闻是 L4 微内核的安全性得到了形式证明。这些年来一直在学习如果把软件做的更优秀和健壮。虽然感受颇多,但是都是一些近乎于无形的东西。L4 的形式证明说明业界已经开始得到一些有形的突破性成果。也许明天会达到形式证明一个系统的功能性正确,或者证伪一个系统的功能性正确并且指出错误。

这周学到的最有用的知识的途径是通过一个愚蠢的错误。昨天我对甫鸼说打算在家里建一个 Git server。甫鸼当下大不解:为什么是 server ?」没说两句我开始意识到自己一定是犯了一个愚蠢的错误。立刻打开 Wiki 的 Git 词条。果然第二段就赫然写着:『 Every Git working directory is a full-fledged repository with complete history and full revision tracking capabilities, not dependent on network access or a central server. 』

希望在自己的笔记本上建立单机的代码库已经很久了。两年多以前就在思考为什么没有人写一个不依赖 server 的 version control 系统。现在才明白,这就是分布式 version control 系统的基本功能。这个名称很早就听说了,如果自己能看一下大致的介绍,也就不会困惑许久甚至打算自己写一个。都说不了解 UNIX 的用更差的方式发明 UNIX,我也是在自己的头脑里用更差的方式发明了分布式 version control 这个概念——其实还不到那一步,只是想到了本地版本管理而已,也就是 Git 最初实现的第一步。虽然把 version control 视为人类文明发展的奠基石,居然连分布式系统的概念都不清楚。学习了 Git 也连带看了 Linus 对 CVS 一类系统的批判。今天试用了 Git ,很好用也很强大。Linus 不愧是大师。Linus 和 Steve Jobs 的共同点就是他们设计的第一个产品就改变了世界,但是他们还能继续创造第二个。

对 HTML 5 的再思考

2009/08/11

六月份写《从HTML 5想到的》的时候,对 HTML 5 主导感觉是其对技术收敛的积极作用。不过最近 Khronos 组织又开始推动 WebGL ,让我感到事情渐渐过了头。似乎 Mozilla 和 Khronos 组织正在以错误的角色,用错误的方式来解决一个假设的(似乎也是错误的)未来问题。

正如《从HTML 5想到的》最后谈到的,浏览器 plug-in 并非如同 OS native 技术一样发散而不可收敛。寻求公有开放的视频标准的动机来源于对 plug-in 未来走向不确定性的恐惧。虽然未雨绸缪是好事,但是 plug-in 的竞争并未结束,从 Adobe 以往的经历来看,ActionScript 自发地进入开放领域也并非不可能。早早的冠以一个 HTML 标准来定型两个最主要的 plug-in 应用领域(视频和 3D),很有可能扼杀本来应该发生的竞争,或者走向反方,凡是客观上会扼杀这种竞争的行为,本身都会遭受失败。所以,Mozilla 和 Khronos 组织这种过分的警觉近乎于 FUD 驱动的行为模式,要么有可能扼杀本来通过竞争或者厂商自主开放的更优秀的技术,要么会因为这种扼杀的企图导致本身被市场抛弃。

目前的 plug-in 主要是私有实现,这确是事实。但是 plug-in 架构本身有什么不可解决的问题吗?我并不这么想。事实上,足够复杂的软件系统,尤其是开源系统,应该保持核心部分的精简,同时为外围开发者提供灵活的 plug-in 框架。现在的局面是一部分 Web 需要视频和 3D 应用。但是为此就抛弃 plug-in 的扩展机制,把 3D 和视频的处理并入 HTML 标准,HTML 和 JavaScript 就变成了一个什么都装的垃圾堆。 如果要改变目前主流视频应用(和未来可能的 3D 应用)的不开放局面,开源社区的当务之急应该是推动开源的 plug-in 实现和开源 plug-in 架构的标准化。这样的做法,比把与 HTML 风格迥异的功能硬塞进 HTML 标准,会遭遇更少的阻力,取得的开放效果也丝毫不差。

把视频和 3D 并入 HTML 体现的是一种反 UNIX 的文化。UNIX 从来都不指望让一个语言完成两个任务,也避免让过多的应该由库来承担的任务进入语言核心。HTML 5 和 WebGL 无疑是希望让一个语言来完成过多的功能,或者是把特殊用途的库并入语言核心,从而剥夺了开发者自由选择的权力。Mozilla和 Khronos 组织原本完全可以着手开发自己的开源 plug-in 和 Adobe Flash 等 plug-in 在视频和 3D 领域公平竞争。强行扩充标准是一种没有自信的行为。打着开放和统一的旗号剥夺开发者和用户的权力,这样的做法更像微软捆绑产品的行为模式。现在微软也要半途加入 HTML 标准的讨论,不知道是不是因为在 Mozilla和 Khronos 组织身上嗅到了同类的气味,所以也赶来分一杯羹。

扩充 HTML 标准的另一个负面影响是导致事实上的标准分裂。计算机工业的历史证明,任何基于文字描述的标准只要足够复杂,都会事实上分裂。CORBA 是最为臭名昭著的例子。C++ 98 也是一个不怎么正面的例子。健康的开放标准通过两种路径来避免分裂。一是尽量保持标准的简单和功能单一;二是通过一个开放的 code base 而非文字描述来形成事实上的标准。前一种的典型例子就是 RFC,后一种的例子有 Perl,Python 等语言的实现。两种途径可以很好的结合,比如 TCP/IP 的 RFC 通过 BSD 的 code base 更加巩固。但是总的说来,对于私有的,或者 multi-vendor 的代码实现,还是只能适用第一种方式,就是保持标准的尽量简单。HTML 原本设计为 presentation 语言,加上 JavaScript,CSS 和 AJAX 的应用,已经是一个混沌而且脆弱的集合了。如果再为所欲为的塞进关联不甚紧密的功能,那么这个标准的分裂就指日可待了。统一 Web 视频和 3D 应用不能再走那种强制标准的老路。正确的做法是保持 HTML 标准的简洁,推动统一的 plug-in 接口,同时开发具有竞争力的开源 plug-in 实现来吸引用户以及迫使 Adobe 等公司尽快开放自己的私有 plug-in。这样才是保持整个系统健康开放的正确途径。

总之,对于 HTML 5 的发展到 WebGL 的出现,初始的欣赏已经变成对 HTML 标准的畸形膨胀的担心。这条路继续下去,所谓的公开标准无法从 Flash 等私有 plug-in 中夺取市场和用户,开源社区和用户得不到真正的开放,反而会失去一个原本统一简洁的标准。

C++:进程开销和内部复杂度

2009/07/27

C++ 为什么会被设计成现在这个样子,我一直认为和 Bjarne Stroustrup 的个人背景有很大关系。按照 Stroustrup 自己在 《The Design and Evolution of C++》里的描述,设计 C++ 的动机来自于他完成博士论文的时候编写一个模拟器缺乏合适的工具。从这段描述里,我冒昧地认为 Stroustrup 暴露了他早期的局限性,他意识到必须有更好的工具来管理系统的复杂性,但是眼光仅仅局限在源代码的层次。他的模拟器是一个单独的 monolith,在寻求更好设计时,Stroustrup 仅仅追求如何更好的管理这个 monolith 的内部结构,未曾考虑任何更高一级的方法——比如把 monolith 分割成相互协作的更小的模块——来提高系统的可维护性。

C++ 与进程开销

C++ 在 UNIX 上受到的欢迎程度最低,正是因为 UNIX  提供了更多的组织系统的方法,这些方法能够,而且经常是更好的代替源代码级别的方式。UNIX 不仅提供了进程和 IPC,让这些方式成为可能,而且还从一开始就不断寻求降低这些方法的开销,让它们相对于源码级的管理方式——也就是语言——更加有竞争力。所以,在 UNIX 上 C++ 没有获得它在其它一些操作系统中获得的那种主导地位。

在《The Art of UNIX Programming》的3.1.3节说的很清楚,“如果操作系统让创建进程的操作过于昂贵,或者让进程控制的操作过于困难或者过于死板,⋯⋯就会鼓励 (在一个较大的 monolithic 模块内部使用) 像 C++ 一类的源代码级别的内部分层结构,而不是 C 这样的 (在相互协作的小模块中) 相对平坦的内部结构。

我常说一句不太准确的玩笑话—— “C++ 就是微软捧起来的” 。其实也不是全无道理。作为在 PC 发展的黄金时代最广泛应用的操作系统,Windows 没有提供太多的系统级机制来管理应用软件的复杂度,开发者只能着眼于源代码级别的内部结构,寻求 C++ 之类的语言帮助。

C++ 的兴起和 PC 上 anti-UNIX 风格暂时的主导地位有很大关系。Stroustrup 的发明是对 UNIX 上管理系统复杂度的方式的一种不高明的重复。这种重复为那些由于历史原因或者因为经济原因 (比如过弱的硬件支持和操作系统支持) 没有普及 UNIX 文化的领域提供了一种似是而非的,拥有短期效益的替代品,因而受到了欢迎。

C++ 与内部复杂度

UNIX 文化鼓励通过进程和进程协作来降低单独模块的内部复杂度,而不赞成用 C++ 的方式来 “管理” 内部复杂度。事实上,UNIX 文化认为 C++ 的方式在鼓励过度的内部设计而不是有效的对其进行整体管理。

另一方面,C++ 不光忽略了在单个 monolith 的层次之上的管理方式,还低估了现有手段在单个 monolith 中应对复杂度的能力。做出这种错误判断的不只 Stroustrup 一人,在 90 年代操作系统领域兴盛一时的对 micro-kernel 的研究中,研究者和很多业界开发者都认为操作系统内核的复杂度已经到了必需舍弃 monolithic 式的内核设计,由 micro-kernel 加 user-space 进程实现的服务来替代。Linux kernel 的出现及时证明,用 C 这样的技术也足以很好的管理单个 monolithic kernel 的复杂度。Linux kernel 不光和很多 monolithic 内核同样稳定,还在性能和设计复杂度上拥有天然的优势。(比如,对于 Minix 的单线程文件系统的抱怨,其设计者反驳说该 feature 很好实现——但是始终没有真正实现,而对于 monolithic 内核来说,多线程文件系统几乎是自然而然的设计。)

今天,如果你把系统中某个组件的复杂度设计的比 Linux kernel 还高,那是一种罪过而不能被认为是必需使用 C++ 的理由。

结论

C++ 的兴起,在我看来很大程度上是不是一段普通的历史,而是反映了整个业界的一个错误。正如《The Art of UNIX Programming》所说,上世纪80年代,由于各个 UNIX 厂商的愚蠢,UNIX 文化曾经一度奄奄一息。而 UNIX 社区的开发者们又一再忽略兴起的 PC 市场 (而忘记了 UNIX 自己就是在 mainframe 的厂商忽视了 mini-computer 的情形下发展起来的)。在整个世界缺乏 UNIX 文化的情形下,出现了 C++ 这样畸形的尝试。

我承认世界并非完美。有些错误已经成为文化的一部分被保留下来。但是 UNIX 文化因为它强大的生命力终于逐渐回归。这显示出 C++ 不属于那类可以出于历史和文化原因而被永远保留的错误。这个错误至少应该不被扩大,并且可以被逐渐纠正。

C++与垃圾回收

2009/07/11

垃圾回收不是自动变速

最近几天遇到的好几个和内存回收有关的 C++ 程序的 bug 。有对同一个指针两次调用 delete ;还有在 delete 了一个对象之后又通过其它地方保存的指针调用了这个对象的方法。当然从开始学 C++ 就明白要绝对避免这些问题,但是如果两段没有配合好的代码在 call stack 里相隔几十个函数调用的时候,避免并不像一开始想想的那么容易。

造成内存回收问题的根源在于程序的复杂度增长带来了程序运行时的不确定性,程序员无法准确判断一个对象真正不再被使用的时机。最著名的不确定性的来源当属多线程。但是单线程的程序也会有及高的不确定性。今天在 Mac OS X 或者 Windows 之类的操作系统上编写一个拥有 UI 的应用程序,可以很少或者根本不使用多线程——程序的大多数动作都可以在消息处理线程内完成。这样的单线程机制可以让 UI Framework 的设计和使用简单一些,能在大多数情况下避免线程同步操作的使用。但是当程序越来越复杂时,即使不引入多线程,单一的消息处理线程本身具有的不确定度绝对不比多线程低。

一些比较费时的函数有时会在整个函数的运行过程中的某些步骤把控制权暂时交还给系统的消息处理框架。如果在某个消息处理函数中调用了这样的函数,就会导致在这个消息处理函数会被不能确定的其它消息处理函数打断。有时候一个顺序过程的几个步骤会在多个消息处理函数调用中分别完成,触发这些消息处理函数的消息可能由一个函数统一排入消息队列,也有可能是由完成上一个步骤的消息处理函数把触发下一个步骤的消息排入队列。而且,看似单独的用户操作往往会触发几个甚至十几个消息。比如,最简单的鼠标双击在很多系统中都会触发一个鼠标单击和一个鼠标双击事件。考虑到前面提到的情况,处理这两个事件的消息处理函数可能是顺序运行的,也可能是后一个函数间插在第一个函数的运行过程中;而这两个消息处理函数本身还有可能向消息队列排入自己创建的消息,导致更大的不确定性。可以说,今天的消息处理线程本身已经和一个小型操作系统的进程调度模块的复杂度不相上下。本身是单线程,却已经有了多线程的逻辑。

从架构设计的角度看,我们应该尽量通过合理的设计来减少这样的不确定性。但是,从实际看来,很难把这种不确定性降低到能够准确判断一个对象可以在哪行代码的位置被回收的程度。而且,如今的程序要集成不同的 API。这些 API 和消息处理线程集成的方式往往有很大差别,这些差别的细微之处很少被文档完全提及,即使文档完全描述了这些细节,程序员也无法从中分析出两个或者多个不同的 API 集成在一起会发生的所有情况。

每个关于资源回收的 bug 都要根据具体出错的情况,找出原先对回收对象的位置的错误判断,然后找出更合理的回收时机。虽然最后这些 bug 的病症都通过修改回收对象的时机解除了,仍然难以确保没有隐藏的问题。回头看看,在一个拥有垃圾回收的系统中,这些 bug 都不会存在——没有必要耗费精力分析一个对象到底何时可以被安全的回收,当没有人使用它的时候它就会回收。我们关心的其实不是这个对象到底什么时候应该被回收,我们只关心它最终会被回收。一个缺乏垃圾回收的系统让我们不得不玩一场不停回答“什么时候应该被回收”的问题的游戏。

想到这里,我记起当一个激烈批评 Java 的人被问到 Java 是否一无是处的时候,他说 Java 无可争议的积极的历史功绩是把垃圾回收这个概念带回了主流的开发领域。尽管 Java 的垃圾回收最初实现的很不理想,尽管 Java 正在被其它拥有垃圾回收功能语言替代,但是由于它出现的合适时机以及 Sun 在其中所施展的市场手段让它拥有这个荣誉。

如果说推广垃圾回收概念是 Java 的历史功绩。那么没能实现垃圾回收可以说是 C++ 的历史罪责。在提高处理软件复杂度的能力的尝试中,C++ 是认知度最高的,也是在资源消耗上最为节俭的一种尝试。所以 C++ 成了一个标杆——人们开始认为 C++ 有的特性都是大型软件开发必需的,C++ 没有的特性都是大型软件开发可选的——前者就像汽车中的变速箱,是必需的;后者就像自动变速箱,高手是可以不用的。垃圾回收是 C++ 始终不曾具备的特性,但是我开始认为它不是自动变速箱,它是每个大型软件的开发都不可缺少的。抛弃垃圾回收的人认为只是把驾驶的汽车从自动变速换成了手动变速,其实是把一个好的变速箱换成了一个漏油的变速箱。

错失的机会和无用的补救

从 C++ 所处时代的外部环境来说,它有机会引入垃圾回收吗?常常有人用 C++ 出现时的硬件条件来为 C++ 缺少垃圾回收进行辩护。同时代的 Smalltalk 推广的失败,和早期Java 垃圾回收器的糟糕表现为这个说法增加了旁证。

但是,Smalltalk 和 Java 的垃圾回收只是一个特例。像 Eiffel 语言的垃圾回收器的效率证明了垃圾回收完全可以实现的很高效。更重要的是,C++ 没有必要非得实现像 Java 那样基于 root-reach 的使用单独线程的垃圾回收,在硬件条件比较差的时代,一个基于引用计数的垃圾回收器的效率是完全可以接受的。所以,在早期阶段失去引入垃圾回收的机会很难说是一种由于历史原因造成的无奈和必需的牺牲,更像是一个由于短视和偏见造成的错误判断。

C++ 的辩护者的另一个理论是 C++ 是有垃圾回收器的。C++ 既有基于 root-reach 的垃圾回收方案,也有著名的 shared_ptr 来充当基于引用计数的垃圾回收。我认为,这两个机制本身的设计是成功的。(我曾经遇到过一些 bug 用普通的分析很难确定对象应该何时被销毁,其中一些 bug 涉及的指针范围很小,所以我把相关的裸指针全部替换为 shared_ptr 很容易就解决了问题。)但是它们作用在实际中大大降低了,原因在于它们是基于库而不是基于语言的机制。

垃圾回收的内存管理方式需要整个系统的所有模块共同遵守。只要有一个模块放弃了垃圾回收,其它模块的垃圾回收也就名存实亡了。这个原则对于基于引用计数的垃圾回收更为重要。因为基于引用计数的垃圾回收明显在 C++ 使用者中认同率更高,所以这个原则对于 C++ 的垃圾回收也更为重要。由于没有语言强制,当程序复杂度增高,集成的第三方代码来源复杂之后,如果垃圾回收只是一个库而不是一个语言的强制特性的话,系统的开发者和维护者会越来越无力保证所有模块都采用垃圾回收,也没办法避免不同的模块采用实现细节千差万别的垃圾回收。这样整个系统的垃圾回收根本无法作为一个降低复杂度的工具,它本身就成了更大的复杂度的根源。(像上文括号中提到的那类 bug,你并不是总能把所有的裸指针很容易的替换成 shared_ptr。不说有时候你不能获得源代码,就算在获得所有源码的情况下,修改低层库的一个指针的存储方式带来的代码修改量也是不可接受的。)

在这里我们可以把 C++ 的 shared_ptr 和 Objective C 1.0 的引用计数方案做一个比较。从某种意义上说,C++ 的 shared_ptr 更有优势,因为它利用程序的 block scope 来自动实现引用计数的增减,而 Objective C 需要程序员自己通过 retain 和 release 消息来维护引用计数。但是由于 C++ 不强制使用 shared_ptr,从 shared_ptr 中也可以取出裸指针,在复杂程序中只要少部分模块直接使用裸指针或者 delete 操作,就会让 shared_ptr 带来的益处大大降低甚至消失。Objective C 1.0 的方案虽然不能自动增减引用计数,但是由于它是强制性的,保证了它能够真正的降低整个系统的复杂度。

而且,随着硬件水平的提高,基于引用计数的垃圾回收逐渐被淘汰,被基于 root-reach 的垃圾回收取代。C++ 采用裸指针以及把 C++ 对象混同于普通的 C struct 来存储是引入 root-reach 垃圾回收的一大难以克服的障碍。Objective C 也兼容 C,但是它把自己的对象和 C 的元素相对独立开来,让 Objective C 2.0 可以没有太大困难的引入 root-reach 垃圾回收。

结论

一个技术的前景,还要看它的现状。被采用的越多的技术,就越能吸引越多的开发者。技术的推广超过某个临界点就会开始不可逆转的向整个业界渗透的趋势。

在采用垃圾回收的过程中,Mac OS X 走在了前列,它自带的主要应用,以及苹果开发的其它主要软件都是使用具有垃圾回收的 Objective C (包括 1.0 的引用计数)。在 Mac OS X 上用 Python 和 Ruby 等语言写的软件也不少。所以开发 Mac OS X 应用使用垃圾回收已经成为主流。QT、GTK 和 Carbon 反而已经成为边缘。Linux 有许许多多用 Python、Ruby 编写的采用垃圾回收的应用。像 Emacs 这样的风格更是一个传奇——自己独立实现一个带有垃圾回收的解释器。不过用 C++ 编写程序仍然在 Linux 上和拥有垃圾回收的语言分庭抗礼。而Windows是一个荒谬的现象。微软早就在叫嚷 C++ 的消亡。除了企业开发和一些小工具,Windows 上却一个采用垃圾回收的大型应用都不存在。

缺乏垃圾回收决定了 C++ 不是一种适宜大规模软件开发的语言。C++ 的目标——能够比 C 语言能更容易的应对复杂度更高的软件——可以说很大程度上没有达到。漏油的变速箱时常贴贴补补,加加变速油还是可以硬撑的,但是使用它绝对不是一个愉快的体验。另一方面,凭借多年的积累,C++ 在很多平台上拥有基本相似而且开放的实现——这方面其它语言不是支持的平台比较少,就是开放程度不高,所以在某些领域 C++ 的这些资本还可以暂时抵消它的缺点,C++ 还可以用 QT、GTK 这样的形式得到应用。但是我认为 C++ 这些资本的价值也会随着 Python、Objective C 这类语言对更多平台支持的增加(同时,随着那些拒不对垃圾回收提供更好支持和推广的平台的消亡)而逐渐消失。希望软件的内存管理全面的转向垃圾回收。垃圾回收不再是某种语言的特性,而是像函数、文件这样的概念,成为软件开发一个基本的不可缺少的概念。

缓存与文件系统——之一

2009/06/18


当你觉得需要一个缓存的时候,把情况再探究得深入一些然后问自己为什么需要这个缓存。

Unix认为世界是静止不动的。

妥协并不讨好。

——《The Art of UNIX Programming》

最近一段时间的主要工作是维护和开发一个文件浏览器类型软件(类似于Mac OS X的Finder和Windows的Explorer)。这个软件与Finder和Explorer的一些重要的不同之处有,从功能上说更加偏重图片管理,从技术上说设置了一个缓存,用来存储经常遇到的图片的缩小版本。缓存会让显示文件缩略图(Thumbnail)的速度大大加快。应该说这个缓存功能对于管理大量高分辨率图像是很有用的。但是它未能避免违背《The Art of UNIX Programming》的一些告诫。尽管违背这些告诫有很好的理由,而且大部分用户还是对违背这些告诫的代价和收益表示认可,但是维护中因此遇到的越来越多的麻烦导致我开始思考是否这些设计上可以有更好的替代。

缓存是在谈及提高效率时通常被首先考虑的技术之一,但负面影响也在《The Art of UNIX Programming》里被强调——和主存的同步问题和命中率问题很难解决。这个软件的缓存比较特别,由于存储的是图片,而且是主存的缩小版本,所以该缓存可以存储所有访问过的主存图片,而不必用类似最近最少(LRU)算法去丢弃某些部分(至少不必像CPU缓存那么频繁),因此不存在传统的命中率丢失问题。但是它有另一种形式的命中率问题。如果没有这个缓存的存在,那么一个程序要显示一个图片的缩略图,只需要把图片读出,用down-sample算法缩小,然后绘制在屏幕上。有了这个缓存,除了上述的操作,程序还需要把down-sample的结果存储到缓存中(由于绘制在屏幕上的缩略图每次大小不完全相同,存储到缓存中的缩小版本的分辨率还要比屏幕缩略图需要的高,这样以后读取缓存来绘制缩略图的时候仍然需要down-sample,虽然down-sample的程度减小了)。另外,为了用批处理提高效果,缓存的生成是以文件夹为单位。这样即使一个文件夹用户只浏览了前几个图片,后端的线程也会为整个文件夹生成缓存。如果用户偶尔浏览一些以后很少用到的文件夹,对磁盘I/O和磁盘空间可能都会造成一定程度的浪费。所以在某些情况下这个缓存会拖慢而不是提高效率。这说明任何类型的缓存(甚至任何类型的优化策略)都会在某些极端情况下劣于简单的直接访问(蛮力策略)。

《The Art of UNIX Programming》提到的缓存的同步问题主要受到主存更新方式的影响。但是现代软件越来越多的采用plug-in架构和具备online live update的功能,所以缓存的同步也开始受到软件本身更新方式的影响。如果一些plug-in或者软件本身的更新改变了生成缓存图片的方式,那么如何同步已经缓存的图片就成为一个复杂的问题。比如,一个plug-in的升级可能改正了原来有色彩失真的缓存生成方式,但是原来哪些文件会造成色彩失真并不是一个可以简单的用某些轻量级算法判断的标准。所以保险的方法只能是在升级plug-in的同时清除所有已有的缓存。由于plug-in架构本身是为了屏蔽不同plug-in实现的细节,所以缓存中哪些图片是由哪些plug-in生成的也往往不容易判断,所以最后常常只能通过清除整个缓存来解决。

主存的更新是缓存同步的最主要挑战。对于这个软件来说,这个挑战的更加艰巨。因为这个软件的设计是把整个文件系统作为主存。正如《The Art of UNIX Programming》指出的,UNIX文化建议利用文件系统达到重用,但是没有过多的考虑通过并发访问文件系统来协作,UNIX假定程序的运行是短暂的,在运行期其涉及到的文件不会被其它程序访问;假定程序间的文件访问是交替的而不是并发的;假定程序对频繁修改的文件独享,而只会共享少量罕有修改的文件。把整个文件系统作为主存,通过设置缓存来提高性能,这样的设计会遇到很多问题。比如,对于正在被写入的大文件,很难判断是正在写入过程中,还是写入已经完成。如果在写入过程中被生成缓存,那么缓存文件就是错误的,但是没有机会得到改正(除非用户手动清除缓存——但是缓存策略的主旨就是避免用户经常手动清除缓存)。由于所有操作系统在文件系统方面受到UNIX很大影响,所以它们都很大程度上有同样的问题。解决的办法往往很难做到高可移植性,往往非常复杂,会引入更多的线程同步问题。

将软件设计成为一个类似Finder和Explorer的全系统浏览器,还有一个问题,那就是是否要用其本身的GUI反映一个文件系统的所有细节。显然,出于并不希望复制Finder和Explorer和突出自己特有功能的目的,该软件选择了简化某些细节的做法。比如不会显示文件的读写权限,只显示文件的锁定状态。但是,和很多复杂系统一样,文件系统的很多概念是相互联系,但是又部分正交的。很难完美的掩饰某些细节而不造成混淆。比如,文件是否可以锁定是受到文件读写权限的限制,所以文件锁定状态的改变会因为读写权限的原因而失败,最终用户还是必须了解被隐藏的概念和细节,这就抵消了最初了通过简单的UI简化用户操作的初衷。其实就连Mac OS X的Finder本身也有这样的问题——OS X的HFS拥有传统的user-group-other权限和ACL的权限,但是Finder没有区别显示而是混在一个GUI权限列表中;另外,HFS的锁定(lock)有user-lock和supervisor-lock两种,Finder也没有区别显示,这样Finder对于一些ACL比较复杂和一些拥有supervisor-lock的文件的显示结果就比较奇怪。这说明文件系统的细节的复杂程度连专门浏览器的GUI都有些难以应付。当你希望隐藏细节的时候,“妥协并不讨好。”

而且,不要忘记,所有这些被显示或者被隐藏的细节,都可能被其它的程序在任何时候修改。在决定了让你的应用可以访问整个文件系统的同时,相应的就必须承担其它应用和你的应用竞争访问的后果。(有人认为Windows那种强制文件锁(mandatory file lock)可以解决问题。其实强制锁引入的问题比解决的问题多,而且其功能与文件权限概念有重叠和混淆。)更不要忘记,在被显示的细节里,有一部分会被放到缓存中。这些问题加在一起,会把系统卷入无穷无尽的边缘情况(edge case)中。

有了这些观察和思考,我开始理解为什么类似iPhoto的软件开始采用另一种设计模式——私有数据库。iPhoto类的软件没有被设计成直接处理文件系统任意区域的软件,而是要求用户必须把待处理的图片“引入(import)”。引入操作实际是把图片置于一个受到保护的私有数据库。引入私有数据库是否意味着要重复实现很多文件系统已经提供的功能?并不是必然的,因为这个私有数据库可以普通文件夹——唯一的特殊之处是不对其它程序开放(比如位置难于访问,或者在文档中说明,或者利用其它程序的一些功能——比如Finder中显示为单个文件——从技术上完全阻止直接访问内部),对其编程访问仍然可以利用文件系统的操作,同时省去了诸多的文件并发访问问题。在私有数据库内部,文件的细节也更好控制,比如你完全可以控制数据库内的任何文件都没有ACL(甚至拥有统一的user-group-other权限),也没有supervisor-lock,因为修改这些细节的只有你自己的程序。这时一个简化的UI也极少会导致混淆(即使会也是易于修改的bug而不是上面那种由基本设计导致的问题)。在私有数据库模式中,缓存的两个固有问题不会消失,但是会得到大大的缓解。也可以说,如果为了图片管理中的性能目的而必须引入缓存,那么私有数据库模式是一个很值得考虑的选择。

现代操作系统是一个非常复杂的高度协作的系统。通过单个应用程序达到一个全系统级别的简化操作就算不是完全不可能,也是一项有极端挑战性的工作。因为这意味着你的应用程序就像一个把全系统都覆盖在后面的幕布,但是同时你又必须允许你的用户绕过幕布去操作这个系统(因为你把全系统都盖住了,但是用户又不可能只用你这一个应用)——这种矛盾的策略让你很难在这个幕布之前演出一幕没有纰漏的剧目。更为可行的办法仍然是针对自己的需求,把应用程序的操作涉及范围尽量局部化,这样,你的幕布仅仅覆盖了系统的一小部分,你更为容易说服用户永远不要绕过这个幕布。

后记:出于篇幅的考虑,有些问题的细节没能全面展开。这样可能造成一个假像,让一些喜欢思考的不易被轻易说服的读者认为文中列举的问题没有严重到必须采用私有数据库架构的地步。所以我考虑冠以“之一”来让以后的“之n”等展开说明那些被质疑的比较多的问题。

回忆Java乱谈

2009/06/07

昨天聊天被甫鸼问到有没有看过什么Java写open source项目,回答没有。被追问为何没有,回答说真正对open source开始感兴趣的时候已经对Java失去兴趣了。

回想当年对open source还只是草草尝试、没有上手的时候,感觉阅读CC++的代码真的很头疼(其实只是因为当时还没有掌握基本的grep操作,只会用UltraEditsearch in files)。那时候对我来说编译过程就更神秘了(后来花费了两个月的时间读Linux kernelmake file,其中包括一个星期阅读make文档花费,这种神秘感才彻底消除——不管你能不能看懂代码,也不管你的修改是不是在编译之后真的起作用,只有你真正明白到底是什么样的一组命令把一堆C文件变成了一个没有扩展名的文件你才能开始感觉一切在掌握之中)。那时候我对Java的评价是open source设计的完美语言,因为one-true-way的风格让代码只有一种写法,编译过程简单,根本没有连接和后期的binary文件处理。

讽刺的是,当我真正开始推崇open source的时候,已经对Java懒得看上一眼了。这发生在将近四年前,稍候在工作中完全脱离Java也已经近三年。其间也偶尔考虑过Java的成败。这时被甫鸼问起,想来也应该写下来一些感想。

Java失败(姑且认为是失败了吧,可能有人不同意这种说法)的最主要的原因就是Sun只做平台不做应用——甚至还有更绝的,当年有种说法是鼓吹Sun只做标准,连平台实现也不做了,言语中还对能靠写写标准就能赚钱的企业十分羡慕。可惜的是用户可不买帐,没有end-to-end(一头是硬件,一头是最终用户能看得到的产品)的方案,用户不会掏钱。看到Windows独占市场的人都嚷嚷当年的Wintel联盟,可是是不是没看到有多少用户仅仅是因为离不开Office而不得不Windows掏钱的呢?

Sun不好好做应用,天天大吹大擂平台本身。最后的结果是不管如何大家都开始认为这个平台本身就能赚钱了,吸引了一大批围着平台打主意的厂商。微软的J++被干下去了,IBM自己的JDK最终推出了。Sun这下子有点恼火,马上打定主意,对Java标准还得看的死死的。

Sun对待Java标准的态度极大的伤害了Java平台。事实证明,所谓JCP一类的民主,相对于真正的Linux kernel风格的open source来说,都是不可救药的官僚,根本起不到改进软件的作用。Sun也有自己的委屈,所谓一统就死,一放就乱,标准都放开了,Java标准岂不是都乱了。其实问题要从两方面考虑,第一是得相信大众的智慧,好的软件是进化出来的,不是一下子设计出来的,不放到严酷的环境中自然演化不行。第二是如果真的乱了,说明软件本身的接口复杂度过大(参考《HTML 5想到的[2》),换句话说,是不是Java本身的野心太大了?一统武林的想法是好的,但是别成了岳不群。

其实Java这么庞大,也未必是每个模块都开放用户才高兴。Sun应该学学苹果,把Mac OS X的内核和SafariHTML engine和盘托给open source社区,自己安安心心做Cocoa,做OpenGL优化,做高级的色彩管理和字体渲染,做Safari的用户体验。Sun如果早早把JVMopen source社区,自己把Swing的架构、控件和主题做到最好,好好做上层应用而不是把这块丢给ApacheJBoss,那么JVM本身未必会分裂(谁会让一个用户都喜欢的Swing或者App Server运行不起来呢),Java本身未必会失败。

回头看看我对Java失去兴趣的时候也是对open source真正开始入门的时候。这仅仅是一个巧合吗?也许Java满足的只是一种虚假的需求。话说初学者推崇备至的技术有些到了高手看来很难用(反之的情况也有)。软件开发是否也是这样呢?也许很多人只是欠缺一些阅读分析代码的技巧;或者是有些人被C++的滥用语言特性迷惑而失去了对现有语言的信心;或者是当时更为抽象的动态脚本语言比如Python还不为大多数人所知。Sun的强大宣传力量让很多人一时无从分析自己真正需要的是什么,转而寻求一种激进的替代品(话说人类历史上那个被经济危机打乱的年代似乎也有类似的情况)。

过去的已经过去。由Java的现状和它为自己赢得的名声来看,我认为它永远不会回归主流了。我们的未来的语言会如何?C大概会一直占据底层系统开发的地位。C++继续受到C和高层语言的打压。高层应用的仍然保留大量C++代码,但是在寻求转变。原本认为由Java占领的阵地由JavaScript、Flash/Flex、AIR、Objective-C、Python、Ruby、Perl等工具来占领。这些工具能够比Java成功都在于克服了上面所说Java的一个或者多个弱点——或者更开放,或者在早期就拥有优秀的无法让用户割舍的应用。

从HTML 5想到的[2]

2009/06/06

MIT风格注重界面的简洁,“新泽西”风格注重实现的简单。⋯⋯ 虽然遵循MIT风格更有可能达到更好的抽象度,但是(较差的)“新泽西”风格的软件更容易传播。假以时日,“新泽西”风格的软件更能吸引人们的注意力,从而得到更大的改进。良莠因此对调。

——《The Art of UNIX Programming》

 

HTML替代plug-in的趋势也许意味着“新泽西”模式再一次战胜MIT模式。由于HTML最初低下的表现力(expressiveness),各大公司并不屑于把HTML标准作为宝贵的财产牢牢的控制,而是漫不经心把标准交给国际组织,形成了HTML/CSS/JavaScript更为开放的姿态。

HTML的接口比plug-in技术更为复杂,HTML的数据、表示、代码相互混杂的接口比plug-in的编程脚本语言难用的多。复杂的接口意味着意味着更少的操作原语(primitive)[1],以及原语和底层实现能更直接的对应。这意味着,首先产生歧义的机会更少,其次接口的实现更为容易,用户能够更快的得到参考实现作为进一步消除标准歧义、统一接口行为的依据。

从更广的视角来看,软件业解决一个特定问题时选择的接口复杂度的过程呈现一种在震荡中趋于稳定的模式。最初由于技术不成熟选择过于粗糙的复杂接口,而后开始进行抽象简化接口的努力。简化使用的需求推动寻求更高的抽象层次,但过高的抽象层次会被自己的实现复杂度压垮,因为过于抽象的接口定义的会因为实现复杂度过大而歧义丛生,导致实现的分裂。最后接口的抽象度逐渐在简化使用和接口统一的双重需求下收敛于有利于两方面的最优点。CORBA一类的RPC技术最后普遍退回到基于HTTP请求的页面访问服务或者基于socket的应用协议接口就是典型实例。

认识技术竞争的本质和对统一接口强烈的需求,在分析软件技术现状的时候会抛弃对高度抽象的所谓优雅接口的不切实际的追求。对粗糙接口的坦然面对也是一种放弃执着的体现。软件的复杂度不仅仅受到人类个体智力的限制,也受到人类社会行为的限制[2]。

 

注[1]:接口复杂度的高低并非取决于原语的多少,而是取决于使用每个原语时的难易。因此高复杂度的接口往往拥有更少的原语(比如汇编语言拥有更少的原语,底层网络协议接口也比高层网络协议的原语更少)。这也是接口复杂度和实现复杂度成为tradeoff关系的原因(实现复杂度很大程度取决于原语数量)。

注[2]:这种限制即技术奇异点。技术并非不能越过奇异点,但是越过之后开始自身发展而超出人类的理解能力。