Archive for the ‘软件开发’ Category

Dict Mac 到 Mac OS X 10.5

2010/05/25

把 Dict Mac 介绍给同事们之后遇到最多的回应是『怎么是 10.6 only 的?』我喜欢用最新的东西,加上在家里测试 10.5 也并不方便,所以开发的时候没多想就选了 10.6 SDK。没想到连公司同事这些 Mac 的重量级用户还有不少没升级到 10.6,于是今天中午花了一个小时把最低平台需求降到了 10.5。其间遇到了几个小问题。

第一是 NSApplication 的 delegate 在 10.5 之下没有类型,在 10.6 下则正式定义为 NSApplicationDelegate 接口。不过真正起作用的 selector 都没有变化。把 delegate 的基类从 NSObject <NSApplicationDelegate> 变成 NSObject 之后就解决了问题。第二个问题是 NSTextView 的背景色设置成 [NSColor clearColor] 在 10.6 下工作的很好,到了 10.5 下就变成了黑色,变成 [NSColor windowBackgroundColor] 才行。虽然效果相同,但是从正常思路来说我是希望让控件的背景透明,而不是让它『碰巧』和窗口同色,而且遇到那些背景色可以在运行的时候定制的窗口,非透明的控件还要多写代码来同步颜色。

上面两个问题解决之后,Dict Mac 就可以正常的用 10.5 SDK 编译了,在 OS X 10.6.2 上运行也一切正常。之后遇到的问题最离奇,程序在 OS X 10.5 上启动后就会崩溃,crash log 报告 selector [DictWindow awakeFromNib] 不存在。Google 之后发现遇到类似问题的人不少,唯一找到的 workaround 是把 [DictWindow awakeFromNib] 里的 [super awakeFromNib] 去掉(完全莫名其妙的改法,这是一个人记得刚刚升级到 Xcode 3.2 的时候并没有这个问题,然后从自己的 version control 找到当时的代码才得到的解决方案)。

降级 SDK 总会遇到一些小麻烦。也能从中看到 SDK 的每次升级都确实给开发工作减轻了不少负担,唯一遗憾的是用户们升级得没有那么快,新 SDK 的推出带来的好处要过上一段不短的时间才能让开发者受益。

Dict Mac 发布

2010/05/22

很多天没有更新 Blog 了,其间也有曾经想写些话题,不过大部分时间还是用来动手做被一件很久之前就想做的事情 —— 自己写个小工具。第一个完成的工具是我每天都离不开的 dict.cn 的客户端 Dict Mac。目前刚刚放到网上的只是 0.01 版。功能还不是很多。不过我自己已经完全切换到 Dict Mac 而不再打开 dict.cn 了。

从开发的角度说,这次也是我学习 Cocoa 的第一个实践项目。Cocoa 的 Interface Builder,以及控件对 URL 和 RTF 格式的处理都很好用。

Open Source 的界限

2010/04/20

最近一件有意思的事情是 Oracle 质问询问 Open Solaris 社区从一个 Open Solaris 这样的 open source 项目如何盈利。老实说比起 IBM 这种虚伪的家伙,我更喜欢 Oracle 的直率 —— 本来么,这个问题几乎人人心中都有。那么,为什么有的 open source 项目 —— 比如 Linux kernel、WebKit、GCC 可以蒸蒸日上;而另一些,比如 MySQL、Open Solaris 则奄奄一息呢?

其实答案还是在于那句老话,open source 是程序员写给自己的工具,它的生存不取决于盈利,不沿循普通的商品交换的资金流动途径,而在于直接提高其创造者社区的生产力。一个 open source 项目能否生存,在于其用户群能否成为自身的维护者和改进者。所以,第一等天然的 open source 项目是程序开发工具。第二等天然的 open source 项目是各种各样的库。同样的功能,提供 GUI 和提供 API 对于一个开源项目来说是生死攸关的差异。使用 GUI 的人极难成为一个产品的维护者。使用 API 的人不是在真正的『使用』产品,而是在创造自己的产品,同时他们有能力也乐于成为他们所依靠的 open source 项目的维护者和改进者。所以作为开源软件 WebKit 之类的库能够得到比 FireFox 之类的成品浏览器更好的发展。事实上,要求 FireFox 把自身的结构整洁化以便能够剥离出其『核心』功能供其它类型的产品使用一直是业界对 FireFox 的一个要求。

一个 open source 项目提供的能够将被其他开发者自由的集成到其它产品中的紧密度是它能否生存的关键。在这一点上,GPL 许可证的项目都游走在生死线上,因为它们很难把自己作为一个非 GPL 产品的不可分隔的一部分。只有 Linux kernel 可以算一个例外,因为 kernel 的特殊边界保证了它可以集成到其它非 GPL 产品中。而 LGPL 和 BSD 的项目就可以生活的好一些。诸如 MySQL 之类的产品,GPL 版只能作为独立的服务集成到商业产品里,自然不如 SQLite 这样能够被无缝集成的项目发展得好。

唯实用主义

2010/04/11

Joel 的 Blog 是个很有意思的关于软件开发的网站。里面有不少睿智的看法,比如『抽象泄漏』。但是有时候 Joel 的实用主义实在有些过度。只要两个方法都能解决同一个问题,就对它们进行无限制的无差别化,这就失去了从更高层次分辨优劣的能力。比如这篇 2003 年的《Biculturalism》,试图和 Eric Raymond 的《Art of UNIX Programming》唱唱反调。

试图和《Art of UNIX Programming》唱反调的 Blog 不止这一篇,比较新的还有这篇微软雇员写的。我觉得,以一篇 Blog 的篇幅想挑战一本书的观点,前者天然的处于下风,除非能一针见血的指出后者根基上的肤浅。可惜,Joel 的 Blog 只是说 Raymond 的观点稍稍过于执着;微软雇员的那篇则是在自设了很多假定的前提下玩逻辑游戏。如果你只是想说对手的观点稍稍有些过激(而不是全然推翻),或者想玩复杂的逻辑游戏,在篇幅和对手差一个数量级的情况下大多是自取其辱 —— 你很难有比对手更细致的分析,也很难把一个逻辑推导所需的前提假设的历史根源阐述的比对手更清楚。

回到 Joel 的这篇《Biculturalism》,开篇头一句『By now, Windows and Unix are functionally more similar than different』。看到这句话,非但没有感到 Windows 和 UNIX 之间的分歧有越来越小的迹象,我脑海里首先想到的就是kernel mode 下的 Windows GDI [1] —— 今天主流操作系统里唯一放在 kernel mode 里的窗口管理和图形界面系统,还有和 GDI 紧紧绑定的 window-based toolkit [2]。也许细心的读者会提醒,人家 Joel 说的是 functionally,你说的差异是 architectural。那么,架构上的差异是否完全对功能透明呢?我看 Joel 自己也不完全这样认为(比如『抽象泄漏』)。就像 Apache Web Server 在 Linux 上缺省采用多进程,在 Windows 上采用多线程 —— 架构上的差异导致的性能差别也会让功能的可用性发生质的改变。

Joel 说,UNIX 的文化在于崇尚为程序员提供便利,Windows 的文化在于崇尚为用户提供便利,我们最终是为用户而不是其它程序员提供产品。说这番话的时候 Joel 一定忽略了 Raymond 书上的引用的 Ken Thompson 的这一句话:

This is a consequence rather than a goal. I abhor a system designed for the “user”, if that word is a coded pejorative meaning “stupid and unsophisticated”.

问题在于,忽略了架构上的简洁的系统,是否还能可以提供完全相同的功能?比如我们提到过的,Windows 把 Toolkit(按钮、下拉框、文本框 ⋯⋯)放到窗口管理的 GDI 架构中,每一个控件都是一个窗口,其中还夹杂了直接访问硬件显存的 Direct Draw 窗口;而 UNIX 的窗口管理器一般采用 Toolkit-Agnostic 方式,窗口管理器只知道窗口的内容是一组二维像素,其中的 Toolkit 完全由应用程序空间的代码绘制。『实用主义者』会说,哪个用户会关心这些?不过,用户总是有设计者原本意想不到的应用场景。比如,今天的应用软件很多时候都加入了平滑动画功能 —— 当窗口的内容改变的时候,软件会显示一系列的中间状态。像图片旋转 90 度这样的操作,往往会短暂的显示中间的旋转状态。通常在旋转的过渡状态矩形图片的角会超出显示区域(因为我们都知道矩形的对角线比边长)。在一个 Toolkit-Agnostic 的窗口管理方式里,因为图片不过是一个像素组,超出的部分会被自动 clip。而在每个控件都是独立窗口的系统中,像 Direct Draw 这样的浮动窗口的显示在这种情况下是很难不显示出丑陋的一面的。如果这个例子只是个美观问题,那么用户还有更为严肃的应用场景。现在使用 VNC 一类的远程控制软件的用户很多。VNC 这类软件在 Linux 和 Mac OS X 上很少出问题。因为 VNC 对于传输基于像素的 Toolkit-Agnostic 方式的显示处理方式单一。而在 Windows 上,VNC 程序遇到不同类型的控件就会有很多问题 [3]。

所以,当 Joel 声称 Windows 和 UNIX 在功能上趋同,而且 Windows 更注重最终用户的时候,他既忘记了自己提出的『抽象泄漏』理论,又忘记了用户总会用意想不到的方式来使用你的软件(stupid and unsophisticated)。这也是唯实用主义在嘲笑别人热衷于设计的内部简洁的时候总是犯的两个错误。

脚注

  1. 关于 GDI 处于 kernel mode 这个问题,按照一般的思路人们会归于旧的 PC 硬件的性能遗留的历史问题。有趣的是,事实正好相反。在 NT 4.0 之前 GDI 是在 user mode 的。
  2. Window-based Toolkit,即每个控件是一个独立的窗口。这样的设计下,窗口管理器必然介入控件的层次关系的管理和消息的分派。与之对应的是 Toolkit-Agnostic 窗口管理,即窗口管理程序不了解控件是如何存在的。整个窗口内容是一个像素矩形区,比如在 Mac OS X 里,控件完全由 Cocoa 或者 Carbon 库生成和负责 sub-window 级别的消息分派(这两个库连接到应用程序的进程空间)。UNIX 的 X 是提供一定程度的 window-based toolkit,不过现代的 Linux/UNIX 程序一般建立在 GTK/QT Toolkit 之上,绕过了 X 本身的控件,此时 X 也作为一个 Toolkit-Agnostic 管理器存在。
  3. 当然这不是 VNC 协议本身的问题,而是具体 VNC 程序实现的问题。不过关键在于 Windows 的架构让 VNC 程序的实现和测试比 Toolkit-Agnostic 下的 VNC 程序更复杂也更费时费力。

API 的退化

2010/03/19

有段在 Windows XP 和 Vista 上工作了四五年都没有问题的代码在 Windows 7 上出了问题。还好问题定位并不困难:一个 COM 接口的方法返回错误值 ——『调用了为另一个线程 marshall 的接口』。获取这个接口的线程用了『Apartment Threaded』模式。这个模式不允许多个线程直接共享一个 COM 接口的指针,线程间传递 COM 接口必须在一个线程中用 CoMarshalInterThreadInterfaceInStream() 把要传递的 COM 指针转换成 IStream 的形式,再在另一个线程里用 CoGetInterfaceAndReleaseStream() 转换回来。在 Apartment Thread 模式中得到的 COM 指针不是直接指向 COM 组件的裸指针,而是一个 proxy 的指针。一个 proxy 对象只能在一个线程中使用,上面的那对 API 就是用 IStream 作为中间载体在不同线程间创建新的 proxy 对象。正是因为这个 proxy 对象的存在,Apartment Thread 模式能够把多线程对一个 COM 组件的并行调用序列化(Serializing),这样才能让没有考虑多线程的 COM 组件能在多线程环境中被使用。出问题的那段代码就是没有遵循这个约定直接传递了在一个线程里创建的 proxy(最初的开发者可能根本不知道 Apartment Thread 里 proxy 的存在,把这个 proxy 当成了可以直接指向 COM 的指针)。

从 COM 出现的时间来说,Apartment Thread 也算是一种承前启后的设计。让设计时没有考虑线程安全的 COM 组件能够不加修改的在多线程环境中使用。尽管现在看来当时(现在似乎也并无改进) Microsoft 介绍 Apartment Thread 的 文档实在够烂,98 年我怎么也读不懂,三年以后 Sun 的 EJB 文档才让我理解了这种设计。

为了能并发访问线程不安全的 COM 组件,引入一个做为中间层的 proxy 是必要的。但是 Microsoft 的设计者欠考虑的是完全没有必要为每个线程引入一个 proxy。即使每个线程必须有一个 proxy,那么也应该用另一个中间层来隐藏这些 proxy,让第三方开发者只看到一个 proxy。在必须引入中间层的一个地方,最多只应该有一个外部可见的中间层。

COM 在这个问题上的历史疏忽不应该留到现在,今天 Microsoft 完全可以用一种不破坏向后兼容的方式来解决这个问题:放松 Apartment Thread 模式下对 proxy 的约束,通过重新实现 proxy 让同一个 proxy 对象可以在多个线程里直接传递和使用。同时把 CoMarshalInterThreadInterfaceInStream()CoGetInterfaceAndReleaseStream() 变成类似 no-op 的操作。这样,无论是不明就里直接传递了指针的代码还是规规矩矩 marshall proxy 的代码都能正常工作。遗憾的是,Windows 7 不但没有让这样的变化名正言顺,反而把 Vista 和之前版本里本来在实现上放松的约定收紧了。这样的做法从严格的文档意义上说不算错误,但是在对待开发者的态度上,是一种缺乏细心的考虑,是 API 的退化。

重大升级!

2010/03/15

最近公司的软件的新版本做得差不多了。给第三方开发者介绍如何开发扩展的例子还没更新。这次 API 上没什么重大变化。所以工作量应该不大。

我找到例子的工程文件,双击。Visual Studio 2008 被启动,然后报告:您打开了一个旧版本的 Visual Studio 创建的工程,是否要转换为新版本的格式?—— 因为上一次公司软件发布的时候市面上最新的 Visual Studio 还是 2005。看来 Visual Studio 2008 对工程文件格式做了大调整。这样的格式转换我很不喜欢,因为前后的文件差异很大,diff 两者很难看到什么地方变化了。只好转换之后再看如何 clean-up 尽量让版本之间的变化比较清晰吧。

转换界面是 Wizard 形式的,如果不直接点 Finish 的话,要点击两次 Next 按钮才会结束。第一步询问是否要备份原始文件再转换,最后一步询问是否要输出转换 log。因为有 Version Control 系统,我既没选备份也没选输出 log,一路 Next 下去。Visual Studio 工作片刻,硬盘响了一会儿。转换完成。我用 Version Control 系统的 diff 查看了一下,变动了一行 —— 文件里 Visual Studio 的版本号从 8 变成了 9。

第二消息循环

2010/03/10

以及,DirectDraw 是这个星球上最屎的 API。

消息驱动应用的出现把软件开发的复杂度提升了一个数量级。消息驱动应用的开发者要处理来自用户、操作系统、其它应用、其它库和扩展、以及自己编写的代码产生的几百种事件。每种事件的个数和发生的顺序都几乎无法预测。预测无穷的组合需要逻辑,而开发过软件的人知道我们从来不用逻辑。

第一消息循环

学过 Win32,Cocoa,或者 QT 开发的人一定知道主消息循环(main event loop)这个概念。(如果你只会用 Java,那么可以学习一下这些东西再来往下看;或者马上放弃这篇文章,并且默念『线程、线程 ⋯⋯』。)主消息循环是一段类似下面的代码(Cocoa 的例子):

只熟悉 Win32 的人只要把 nextEventMatchingMask 当成 GetMessage() ,把 sendEvent 当成 SendMessage() 也可以大致看懂。nextEventMatchingMask 从消息队列里取出形形色色的消息 [1],以 sendEvent 为 call stack 发端调用到开发者编写的不同代码。在一个消息驱动程序里的大部分代码的 call stack 都会包含 sendEvent 或者 SendMessage,没有运用多线程的应用(也就是代码都在主线程里运行 [2])这个比例会达到几乎 100% [3]。

大多数时候,除了在 debugger 的 call stack 里,是看不到这个消息循环的。它已经被封装到诸如 NSApplicationMain() (Cocoa)或者 CApplication::Run() (MFC)这样的函数中。为了和下面的描述对应,我们把这个不常见面的东西称为『第一消息循环』。

第二消息循环

讨论第二消息循环最好先明白它要解决什么问题。第二消息循环要解决的并非单一问题。其中比较重要的一个源自 Windows 3.x 时代。那时候 GUI 环境还是单线程,而且整个 Win16 系统只有一个单独的线程。所以,如果某一个程序处理按钮按下的代码一口气运行了五分钟,那么整个系统也会僵死五分钟。比如下面这个事件处理的片断:

所以,那时候非常『先进』的技术就是在运行时间很长的代码里不时调用 GetMessage()SendMessage() 这对 API。这样就可以给其它的事件处理代码一个运行的机会。比如上面那段代码就会改成下面这样:

运行后会出现这样的 call stack(这个例子里另一个窗口的鼠标拖拽操作的消息得以有机会被处理):

第二消息循环未必是个字面意义上循环,因为 get event 和 send event 并不一定要放到循环里。但是一般来说都是这么做的,所以这个名称就沿用下来。『第二』不是指代码中出现的第二个消息循环,而是指在运行时出现在第一消息循环里的第二层循环(见下文的『第三消息循环』)。

陷阱!

所以,现在你的程序可以在按钮按下但是无法弹起(因为 onClick 不会很快返回)的这段时间里处理其它的事物了。听起来不错!但是也可能是个通向灾难的陷阱。你必须保证开启第二循环的函数在 sendEvent 调用的前后不会访问任何其它消息处理函数可能修改的数据结构。

马上你会意识到,如果对第二消息循环不加限制(就是像第一循环那么简单),那么在 send event 前后保护数据完整性是个不可能完成的任务。消息驱动的程序开发最重要的假设就是事件处理函数可以在某种程度上被认为是近乎原子的操作 [4]。而第二消息循环粗暴的打破了这个假设。如果第二消息循环和第一循环一样,那么任何事件处理函数都可能是被第一或第二循环调起,包括打开第二消息循环的函数,幸运的话你会拥会有第三消息循环

所以第二消息循环的处理必须有严格限制。实际上,在 Win16 时代,用来防止整个系统锁死的第二消息循环是不会 get 和 send 任何属于同一个应用的事件的(也就是不能像例子中那样用 NSEventAnyMask)。如果为了防止用户产生程序僵死的印象,第二消息循环也会用来显示一个进度条,或者一个不断回旋的图标,以及重绘某些重要的窗口。但是对同一应用的消息处理也通常仅限于此。模态对话框也是通过第二消息循环完成,这个循环不会处理任何事件,除了对话框本身的事件和重绘其它窗口。所以,模态对话框的代码通常只会修改那些在打开对话框的时候新创建的数据结构,不会修改和访问程序的其它部分;而且任何窗口的重绘处理都要避免修改任何数据 [5]。

接下来,在后 Win16 时代,第二消息循环最主要的作用是处理鼠标拖拽(drag, drag and drop)。处理鼠标拖拽的第二消息循环只能处理鼠标的 move 和 drag 事件,并且遇到 mouse release 要立刻退出循环 [6]。即便如此,鼠标拖拽仍然是最容易导致应用崩溃的 use case。

今天我们有强占式多任务操作系统,有多线程,完全可以避免仅仅为了防止 UI 僵死而使用第二消息循环。在这方面有许多更好的替代方案。一种做法是把耗时的任务放到主线程之外的其它线程。线程提供了更完备的同步保护操作。在使用第二消息循环的情况下模拟线程的同步保护不是不可能,比如,在 sendEvent 的前面设置一个 flag,后面清空这个 flag,然后在其它事件处理函数里加上这种代码:

但是这样做会让同步保护的操作和要保护的数据在代码中分离。你会越来越无法看清你真正要保护的是什么。最终你只能假定第二消息循环会破坏『一切』数据,让后在『一切』事件处理函数中加上这样的保护。

另一种方法是把很耗时的操作切分成多段,把每段放到一个 timer 里运行 [7]。Timer 的好处在于每个消息处理函数和 timer 函数都是原子操作(除去有其它线程干扰的情形)。缺点在于在每次 timer 运行之间要保持中间数据,并且正确处理有可能被其它事件处理函数修改的数据结构,不过以函数为边界来保证数据结构处于完整的状态的做法带来的复杂度仍然相对低廉。

所以,你没有控制全局!

不加控制的第二消息循环是灾难。有人会傻到使用它吗?用用 Windows 的 DirectDraw 你就知道了。DirectDraw 的很多 API 函数都会在内部开启第二消息循环。使用 DirectDraw 的开发者根本无法对这个循环做任何控制。所以用 DirectDraw 根本没法编写一个安全的应用程序。

举个例子,如果你的程序里对于某个控件的鼠标单击和双击分别对一个 DirectDraw 的内容进行不同的操作,恭喜你!首先,在 DriectDraw 之外,Windows 会为你做一件事情:在用户一次双击鼠标的时候会分别触发一个单击消息和一个双击消息。此时你的单击消息处理函数会首先被调用,如果里面调用了 DirectDraw 的 API,那么很有可能这个 API 会开启第二消息循环,后者会调用你的双击消息处理函数。你应该用什么样的 mental model 来设计这两个函数?最简单的方案就是用 crash 来教育用户!

所以,别把 DirectDraw 嵌入到你的文件浏览器类型的应用程序或者 email client 里。在这种有复杂 UI 交互的应用里使用 DirectDraw 是自寻死路。DirectDraw 是给全屏程序和一次播放一个电影的播放器用的(即使有问题,用户体验的是电影而不是你的 UI,他们没空动鼠标,只会在上厕所之前按空格)。

结论

今天,只有模态对话框和鼠标拖拽还需要使用第二消息循环。长时间的操作应该使用 timer 和多线程避免 UI 锁死。为了降低复杂度,即使使用 timer 和多线程避免 UI 锁死,也应该尽可能在此期间限制用户操作(比如用模态对话框显示进度条,禁止『取消当前操作』之外的一切 UI 操作。如果不喜欢模态对话框,可以在主窗口显示操作状态并在用户发出其它 UI 操作的时候自动取消后台任务(大多数文件浏览器都在后台遍历当前文件夹,用户转向其它文件夹的时候自动取消操作)。如果后台任务和用户的 UI 操作必须同时进行,那么,祝你好运。

脚注

  1. 消息队列的消息产生于何处对开发者来说并不可见。一般只要理解成用户的各种操作会自然的产生队列中的各种事件就好。良好的设计原则是对消息在队列中是否重复以及顺序如何尽量不做假设。
  2. 这里说的应用代码不包括 Cocoa 这样的 framework,Cocoa 和 QT 自己都会创建一些线程。
  3. 同理不包括 framework 本身。
  4. 这是因为大多数事件驱动的 framework 都把事件处理放到单一的主线程,即使有多个窗口也是如此。曾经有一个特例 —— BeOS,每个窗口拥有自己的独立线程。BeOS 的开发复杂度也是它消失的原因之一。Win32 可以有多个线程拥有独立的消息队列和消息循环。不过实际中很少用到。
  5. Mac OS X 里面早就提供 buffered window,Windows 终于从 Vista 开始提供,从此窗口的重绘不再单单依靠开发者编写的重绘代码(在内容没有变化的时候)。所以重绘消息现在通常被排除在第二消息循环需要处理的消息之外。
  6. 事实上,处理鼠标拖拽的循环的目的就在于过滤信息,而并非改变应用的响应行为和代码运行顺序。但是这种做法沿袭了 Win16 时代为了提高响应敏捷度的方法的副作用。
  7. Timer 这种方法用 Win16 的系统就足以实现。但是 timer 要求用户编写的第一消息循环在程序比较空闲的时候能够调用 timer 处理函数。在 Win16 时代,第一消息循环主要还是手工编写,所以 timer 这种方式和手工编写第二消息循环相比并不省事。今天第一消息循环都被封装在各种 framework 里,而且基本都内建对 timer 的支持。

大部头与网路短篇

2010/02/20

新年前后开始打算学习 OpenGL。按照惯常的思路:像 API 出了小毛病这样小问题靠上网查查资料;像 OpenGL 这样大的主题要找本厚厚的大部头从头到尾慢慢研究。于是我从家里的书架上取出一本搁置已久的《Interactive Computer Graphics: A Top-Down Approach Using OpenGL》 —— 这是我的另外一个习惯:看到感兴趣的先买下来,不管有没有时间学,也不管当时兴趣能持续多久,于是书架上会有不少这样的『储备』(可谓『高束焉,度藏焉』)。不过很多『储备』也并未一直束之高阁下去,日后一旦兴趣恢复也会阅读。所以坚持想买就买的原则,不怕买错也不怕买了不读。

当初买这本《Interactive Computer Graphics: A Top-Down Approach Using OpenGL》之后发现 Windows 对 OpenGL 的支持日薄西山,一下子没了兴趣。不料多年之后转到了Mac OS X —— 对 OpenGL 优化最好的平台之一。也算验证了书不怕搁置的理论。

可惜真正开始读之后发现内容并不理想。这本书是国外大学的教材,有教材的通病 —— 前言和泛泛的介绍罗里罗嗦,而且大部分是以前就了解的知识。好不容易读完了泛泛介绍,后面的内容也不流畅。3D 是一个要求对基础数学和对 API 的了解都很深入的主题。可惜这本书对二者的结合并不理想。讲 API 的时候对需要了解的数学基础一笔带过或者根本不提。也许是因为 3D 实在不是一个很容易在一本书里用一套结构讲述清晰的知识体系。这个主题似乎更需要这样的阅读:分别精读两本书,先完整的学习 3D 的数学工具,比如变换矩阵,homogenous 坐标系,等对数学工具了解透彻再学习一本讲具体 API 的书。

也就是说,当学习的主题越来越脱离具体的产品,学习者就会越来越发现大部头的问题所在。每个大部头都在试图讲解自己的一套体系,但是这套体系会和其它的体系发生很多平行的联系。从根本上说,这是我们需要学校的原因:学校帮助我们组织各个大部头之间的平行联系。另一方面,这也是非计算机专业的人可以通过自学学习计算机科学达到比较高的工作水平的原因,因为计算机科学的各个分支和其它分支的平行联系都比较弱。学习一门计算机科学的分支往往不需要了解其它分支或者需要很少的时间准备一些独立的知识点即可。而像通信、自动化、电力这样的学科分支之间就有比较强的平行联系。

但是无论如何,计算机科学里还是有像 3D 这样与另一门或者几门分支具有较强的复杂平行联系的分支。对于离开学校很久的人来说,学习这样的知识要比学习运用一个数据库或者服务器产品困难很多。

不过我不想再体验学校的学习方式,转而尝试一下应用更多的网络资源 —— 这算是我离开通信行业进入软件业之后养成的一个新习惯。一方面,网络上像 Google 和 Wikipedia 这样的工具在三年前离开通信之后有了很大发展,另一方面,软件行业面临的跨系统的希奇古怪的问题比通信业多出十倍。在通信行业能用官方的文档解决大多数问题。而在软件业里遇到的种种问题,没有 Google 这样的工具是不可能解决的。

结果相当顺利。第一个例子是 OpenGL 的投射矩阵(projection matrix)。《Interactive Computer Graphics: A Top-Down Approach Using OpenGL》对这个概念完全没有任何解释。只是对 gluPerspective() 的效果做了一个简单的介绍。gluPerspective() 只是投射矩阵的一种特例(从它的 glu 前缀就能看出来)。单单介绍这个 API 的作用根本不能理解投射矩阵的意义,而用 Google 搜索『OpenGL transformation』能找到这篇文章,介绍了投射矩阵在整个 pipeline 中的位置;顺藤摸瓜即可找到对投射矩阵的详细解释 —— 而不了解投射矩阵的一个重要相关概念 homogenous 坐标也没有关系,因为这篇详细解释的链接里就指向了这篇。这种超链接的组织是 HTML 的本意,不过整个网站的 wiki 风格说明在 Wikipedia 成功之后,这种知识组织方式才越来越被更多人运用的更为娴熟而超过了书籍。

第二个例子是 OpenGL 的选择模式(Selection Mode)。这次书里很遗憾地有很多基本错误。比如对 gluPickMatrix() 何时调用以及调用前后需要哪些其它 API 的辅助的代码例子都有错误;对 glPushName()glLoadName() 的区别也没有任何解释就直接在代码例子里使用了后者。其实 glLoadName()glPopName()glPushName() 共同使用的一个快捷方式。这种对基本操作不加解释就直接利用快捷方式是很让读者迷惑的。Google 查找『gluPickMatrix』的结果指向这篇,对选择模式的解释很令人满意。

其实,在第一个例子中网上资料比书的解释更详细的时候,我就犹豫是继续按照书的结构学习还是完全采用网上的资料。后来还是决定暂且按照书的组织结构。岂料不过一天就又卡在选择模式这个问题上。经过第二个例子,我倒是发现学习资料的组织结构不像以前认为的那么重要。以前我选择书籍最看重的是组织结构里注重知识的循序渐进,减少或者消除前向引用 —— 就是前面的章节引用后面的章节介绍的概念(哪怕是说明了必要性也很令人不快)。而今天参考网上的资料,不用理会原本的结构如何,遇到不太懂的概念只要在网上顺藤摸瓜即可。

回顾这几年的学习经历,Google 和 Wikipedia 已经彻底的改变了我的学习方式。相比网络上的资料,大部头有更强的知识集中、系统性和结构性。可是这种结构性是出于作者对读者背景和理解力的主观预测。结果往往是详略和节奏都和读者期望的不同。特别是像 3D 这样把数学和具体 API 结合起来的,在不同分支之间有平行联系主题,如果仅靠阅读大部头就需要从头到尾阅读多部,然后主要凭借记忆自己建立起平行联系。

网络上的知识一般被认为是零乱而且缺乏组织的。不过这些年已经不知不觉发生了巨变。首先是个人 blog 的发展让很多作者把网络资料的水准从 BBS 的帖子提高到了出版物的水平。再有,Wikipedia 这样的网站在本身收集知识的同时,也把组织知识的先进方式公开给了所有知识传播者。让 HTML 在传播知识方面有了高层次的范例。最后,Google 让所有这些知识片段按读者的自我需求动态组织成为一个个较大的主题。这三者缺乏任何一种都不可能让网络知识的水平达到传统书籍的水平。而现在的情形是它们的协作已经至少在工程技术领域让网络化组织的知识已经基本可以取代长篇的出版物。

调试专用代码的最小集

2010/01/01

不知道别人如何,有段时间我常常想在 code base 里加点儿调试专用的代码(就是那种通常被括在『#if _DEBUG_ ... #endif』之类的宏里面的代码),也许是处于对潜在问题的忧虑,或者纯粹就是让程序看起来高级点儿。不过最后总是发现还是删掉(或者压根不写)它们更整洁。如果善于利用 debugger ,需要额外的调试代码的情况还是不多见的。即使是必需的调试代码,问题解决之后也很少需要用再用到。所以我还是愿意必要时候随手写几个 printf ,然后删掉它们。

把调试专用代码长久留在 code base 里要多留意很多:编码规范、对其它部分的功能和调试的影响,等等。随便写几行日后就删掉的调试用代码则简单的多,不必顾及这些,只要能解决眼前的问题。更重要一点,留在 code base 中的调试代码必须对解决一再被发现的潜在问题真正有价值。只有这类问题,你知道它在未来很可能会被发现,但是现在却属未知。而它被发现的时候,用工具和几行 printf 又无法解决,才值得把调试专用代码留在 code base 中。

迄今为之,考虑到把代码留在 code base 里耗费的精力和调试工具的强大能力,我认为只有一种问题值得事先写一些调试代码:某种内存增长的问题。但是这种内存增长的范围很小,绝大多数内存增长问题都不属这种问题。比如,内存泄漏是内存增长的一个常见原因,但是大多数内存泄漏不需要调试代码。良好的 code review 能去除大多数内存泄漏,统一的内存管理策略(比如普遍的利用局部拷贝和引用计数)也能消除大多数内存泄漏。现代的内存监测工具甚至能把大多数引起内存泄露的代码位置明确标识出来。对大多数内存泄漏来说,良好的设计和强大的工具比调试代码更有用。

真正需要专用调试代码的内存增长问题是进行长时间的操作时内存缓慢但持续地增长,短时间之内幅度不明显,积累起来(比如几十分钟或者几小时之后)不容忽视。这种增长也许不是内存泄露(比如,有的情况里存在问题的长时间操作结束或者被用户中途取消,内存会回复),所以用检测内存泄漏的工具通常无法查明原因。而且,因为是长时间操作,内存检测工具运行的开销经常无法容忍,比如一个运行 20 分钟的 use case ,如果把程序连上内存检测工具,运行时间也许会超过一个小时。只要功能慢慢丰富,code base 复杂度不断增加,一个应用程序迟早会遇到这类问题(一般在 3.0 版本左右吧)。但是当最初写下隐含这类问题的代码的时候,你或者你的前任还在忙于功能测试。就算从一开始就重视性能测试,也很难做到边写大段代码边测试每次耗时 20 分钟的 use case。这种问题用外部工具如同隔靴挠痒,又很难在早期发现,当问题显露的时候,code base 已经相当复杂。因此一开始就得在 code base 里做些未雨绸缪的准备。

准备起来很简单,定义一个 dictionary 类型的数据结构(比如一个 std::map),Key 值是程序里生成的实例比较多的类(或者 struct,如果是 C 语言)名,value 是类的实例个数。维护这个 dictionary 的方法就是在对象的 constructor 和 destructor 里对相应的 value 值加一和减一(C 语言要写一组能接受 struct 名称的 debug 版本的 malloc/free)。然后设计一个操作,让你能在长时间的操作中随时打印这个 dictionary 的内容,比如一个随时可见的按钮或者一个菜单项。而且别忘了把所有这些对用户需求来说多余的代码都用条件编译括起来,别影响 release 版本的性能和外观。

这些调试代码纪录的东西不多,但足以解决问题。如果没有这些信息,解决内存增长就如同大海捞针。一旦有了这些信息,大部分情况下通过显示的实例个数的变化都能找到是哪个对象造成的内存增长,找到这个就如同有了金钥匙,接下来顺藤摸瓜用些简单的断点调试和代码查找就能找到病根。某些工具 —— 特别是在 Java 之类的动态语言领域,可以自动收集这些信息。不过『土办法』有独特的优点。比如,程序里会有很多类或者对象的创建的销毁(或者说『生命周期』)完全被其它类控制,这种类或者对象的实例个数不必被纪录,只需纪录控制它们生命周期的类的实例个数。但是外部工具往往不能区分这点。『土方法』需要你自己动手写点代码,所以很容易区分哪些实例个数是真正需要纪录的而哪些又可以忽略。土方法另一个不可替代的好处是消耗的资源非常少,所以能研究一些本身就消耗资源很多的长时间操作。而那些本身就消耗很多资源的工具遇到这些问题往往没一会儿就让系统僵死了。最后,这个办法对程序设计有一点点(也许是有益的)要求:用 C 或者 C++ 的时候不要直接把 char* 或者 byte* 类型的大块内存到处直接分配和引用,给这些 buffer 封装一个 struct,这样才能在调试中得到更有意义和启发性的名字。

所以,我暂时不打算在下一个项目中写『#if _DEBUG_ ... #endif』,除了上面说的纪录实例个数的代码。

Snow Leopard 的修正准则

2009/12/22

只要应用程序够复杂,每次操作系统升级都会弄坏几个本来工作得好好的功能,有些是应用程序本身代码的问题,只是在系统升级时才突然现身。这些 bug 在旧的操作系统上深藏不露,系统一升级才兴风作浪,大多因为操作系统的 API 行为变得更『正确』了。

升级到 Mac OS X Snow Leopard 之后我们的应用程序就出现了两个这样的 bug。第一个是应用程序 fork 之后立刻在子进程里调用 File Manager 的函数。跑了几年都没问题的代码,一到 Snow Leopard 上就僵死。第二个是 MPEG 的播放代码,同样是用户使用了很多年都没有问题,一到 Snow Leopard 上播放的视频就不停的闪烁。原因都是应用程序调用 API 的时机不对:File Manager 必须在 exec*() 函数调用之后才能使用;用 Core Video 配合 Quick Time 播放视频,必须在取出后一帧之后才能销毁前一帧。这些限制在 Leopard 版的文档上就有说明,幸运的不幸的是,系统『恰好』缓存了某些数据让不恰当的 API 调用『碰巧』能工作,掩饰了旧代码中的错误。显然,我们可以猜到,程序在 Snow Leopard 上才初次显露问题是因为新的操作系统去掉了那些缓存。

这样看来,OS X 岂不是自寻烦恼?旧的 API 行为在原来的应用程序里工作得好好的,何苦修改呢?大致猜想有这些原因:一是缓存让 API 的实现复杂化了。加上一个缓存,就得再加上一套代码保证缓存和真实数据的同步(或者在无法同步时清空缓存)。二来,因为缓存不能完全替代获取或者计算真实数据的代码,所以缓存、维护缓存同步的代码、计算真实数据的代码要同时占用内存。应用程序的活跃部分的体积会增大,原来能适合 CPU cache 的部分可能必须在主存和 cache 之间换进换出,本来为了提高效率的缓存却适得其反。最后一点,缓存让 API 行为变得不可预测。一段『碰巧』能工作的代码意味着当它碰巧不工作的时候更难被监测到和修正。

这些 bug,或者更广泛的说,所有系统升级中暴露的遗留代码的问题都说明了软件(特别是 API )设计的一个陷阱 —— 性能和 API 的确定性行为哪个更重要。结论是,确定性行为不仅更重要,而且往往带来更简洁的设计和更好的性能。

这是『修正准则』(Rule of Repair)的变形实例。Leopard 里的 API 不完美的修正了应用程序的错误输入(这里的输入是调用时机),引入了更复杂的实现和不确定的行为。Snow Leopard 去掉了缓存,消除了 silent success,但是 failure 不够 noisy。结果是功能被搞坏,新出现的 bug 诊断起来也很困难。所以,不要只在文档里说明你的 API 需要什么样的 precondition。如果在你的 API 文档里出现这样的文字 —— 在某某条件不满足的情况下调用某个 API 的行为是不确定的,那你就得三思。应用程序的作者不可能有时间细细品味文档,他们只会在出问题的时候(或者更窄的范围,程序崩溃或者挂起的时候;甚至没有人会注意你的 API 会有内存泄露,即使瞥一眼 Activity Monitor 就能看到疯狂的增长)才匆匆翻阅一下。所以,你的那些告诫会被人忽视数年。直到有一天你决定清理一下你的 API 实现,让它变得更简洁。这个时候那些告诫才会出来咬人。那么,亡羊补牢的方法是用一个 crash 来表达你的告诫(那样会在 debugger 里清晰的显露出出错点),而不是听任程序出现任何可能的行为(比如僵死和闪烁)。

不论如何,Snow Leopard 毕竟简化了实现,尽管做得不完美,起码比打着向后兼容的口号,为了让几个 killer-app 的 buggy code 正常工作而保留丑陋的实现然后把文档变成让人无法理解的技术诉讼书(或者辩护书)更好。