Archive for the ‘C++’ Category

Lua vs. Python

2013/05/13

在《 Programming in Lua 》系列里谈了 Lua 的 stackless 实现。说到 stackless 设计,难免和 Python 的 stackful 实现比较一下。

以前总有一个疑惑。为什么 Python 既要采用 native thread,又要用 great-lock 将其变成实质上的协作式 thread。像 Lua 这样的 coroutine 不好么?现在知道了,非不为,不能也。既要尽量保证虚拟机的可移植性,又采用了非常依赖 CRT stack 的 stackful 设计,语言本身没有 synchronous primitive,不能应付真正的 preemptive 多线程。这种情况下,多线程加 big-lock 是唯一的折衷了。由此也知道了 Python 的 generator 为什么只允许在第一层函数中 yield,因为 stackful 设计不允许保存 call stack (说老实话,只允许在第一层函数中 yield 的 coroutine 不过是两个函数调来调去,在 C 里实现起来也不难)。Python 3.3 开始支持更宽松的 yields,不过实现的方式和 Lua 的 yields-in-C 差不多,作为基于虚拟机的语言是比较原始的手段。

拿 Lua 和 Python 做比较令人恍惚感觉正在比较 Objective-C 和 C++。Lua/Python 和 Objective-C/C++ 都是在共同基础上发展出来:后者扩展 C 语言;前者用 C 语言实现基于 byte-code 的虚拟机。它们都有理想的「标杆」:Objective-C/C++ 的标杆是 Smalltalk/Simula 等面向对象语言先驱;Lua/Python 是 Lisp 这样的高级动态语言先驱。努力的方向都是降低「标杆」过大的性能开销和简化「标杆」过于复杂 (或者过于精简) 的概念。Python 和 C++ 相对较早的尝试,都采用了比较低级的机制:C++ 用函数指针模拟成员函数;Python 依赖 CRT stack 直接实现 byte-code stack。这些「第一次」都没能「do things right」。后来的第二次尝试才作出了更妥当的取舍。

在《 The Art of UNIX Programming 》里指出了系统设计的「第二系统综合症 (second-system effect)」。乔布斯也提到过「第二个产品」的问题。在一个成功的系统上衍生的第二个系统有时会因为没有理解第一个系统成功的真正原因而失败。但是,如果还有机会的话,由此衍生的「第三系统」往往会做得更好。对于上面所说的语言发展来说,它们的基础 (C 语言) 和「标杆」是「第一系统」,第一次改进的尝试毁誉参半,而后来的「第三系统」更加出色。

Objective-C 到底给了程序员什么

2011/11/27

不少程序员和我一样,对 Objective-C 经历了从反感到喜爱的转变。反感的是方括号语法和没有虚拟机 (virtual machine) 的动态语言实现。转变因为两个原因。一是 Objective-C 相对简单的语言构造。需要面向对象编程又惧怕 OOC 的程序员们终于摆脱了 C++ 的绑架,没有了跨函数的异常处理,没有了声东击西的操作符重载 (operator overloading) ,获得了统一的内存管理从而摆脱了 C++ 的 value copy 和 block-scope 语义(什么时候 copy constructor 会调用?什么时候 destructor 会调用?)。第二,尽管基于虚拟机的动态语言更强大,但完全脱离 C 还是不现实的,构建实际的软件需要 hybrid 编程,而没有虚拟机的 Objective-C 是一种「单一」语言,还是单一语言好用。

首先谈谈第一点,Objective-C 比 C++ 简单是否仅仅限于砍掉了一堆 C++ 语言的 feature 而已?那么又增加了方括号语法是不是在「砍掉 feature」大基调下一个不和谐的杂音?

范式分割

C++ 被其设计者 Bjarne Stroustrup 定义为「多范式 (multi-paradigm) 的通用编程语言」[1]。Stroustup 的基本出发点看似没有什么问题,解决不同的问题,尤其是不同层次的问题,需要不同范式的编程语言。但是,能不能把不同范式揉合到一种语言当中呢?至少 C++ 的具体方式很糟糕。我认为正确的方式是让程序员在(面向不同问题,或者分析不同代码的)不同时候用不同范式思考,提供在范式之间切换的手段。尽量让程序员在每个时刻只需用单一范式思考。同时混用多个范式是巨大的智力负担。C++ 正好反其道行之:对所有范式都选取相似的语法符号,更糟糕的是为了加大相似度特地提供了操作符重载。Stroustrup 在自己的书里多次提到他的出发点是让所有范式的所有元素都成为 first-class citizen。这是个似是而非的肤浅观点。平等的成为 first-class citizen 并不的意味着被相似的符号表示。C++ 不仅鼓励没有节制的混用范式,即使某段代码实际上只采用单一范式,维护它的程序员也无法放心的排除其它范式。结果是对任何看似简单的代码都必须同时考虑 C++ 的所有范式。

应该说这个问题并非 C++ 独有。高级动态语言同样可能在没有明显代码风格差异的情况下改变范式。但我在之前的 blog 里讨论过 [2],丰富表现力的子语言解决复杂单一的问题的可以采用这种方式,却并非适合团队协作的通用编程语言应该采用。

Objective-C 和 C++ 相比无论语言元素是增是减,始终为了一个目的,向程序员清晰的表达目前所处的范式。首先它的目标更简单,没有难以融合的 generic 范式,集中精力实现 OO 范式。它提供了方括号语法明确表示范式的切换。排除了操作符重载,避免混淆范式。没有跨函数的异常处理,避免破坏 C 的过程范式的重要部分,也是起系统胶合剂作用的 ABI 重要部分 —— call stack 规范。所以,当程序员看到一段 Objective-C 代码时,他可以根据方括号的出现与否轻松的决定是否让头脑卸下思考 OO 动态行为的负担,集中注意力到 C 的静态部分;还是让头脑卸下思考内存管理等底层问题的负担,用 OO 的高级抽象思考问题。

Objective-C 的设计者 Tom Love 解释过方括号语法确实是为了让 C 部分和 OO 扩展部分成为有鲜明区别的「两个」语言 [3]。虽然 hybrid 编程会带来的互操作和调试的负担,但是程序员都喜欢用不同范式解决不同层次的问题来减轻头脑的负担。适合问题的范式和范式之间明确的界限是 hybrid 编程最大的优势。

工具融合

如果 Objective-C 是两种语言的 hybrid 编程。那么一开始提到,和其它高级动态语言与 C 语言的 hybrid 编程相比,Objective-C 是一种「单一」语言又该怎么理解?这取决于工具支持。Objective-C 在语言设计方面保持两个独立的语言定义,又用工具把实际使用中可以融合的部分尽量融合到一起。

首先,hybrid 编程依靠不同的语法来区分范式。大多数 hybrid 编程把针对不同语法的编译器也区分开来。比如,Lua 的编译器是一个 library,和 C 编译器没有任何联系。参数通过 API 传递。Objective-C 的做法是提供可以同时处理两种语言的单一编译器。虽然揉合了两种语言的处理,但是可以设想,除去前端 parser 的语法树 (AST) 产生可能复杂一些,这个编译器的后端处理不会比单独写两个编译器更复杂。和 C++ 融合多种范式的编译器复杂度不可相提并论。对程序员来说,Objective-C 的源文件作为一个整体是极大的便利。

不论范式如何不同(摧残头脑的 functional 编程除外),程序的运行都是线性的,所以每个线程中包括所有范式语言的单一 call-stack 是存在的。尽管逻辑上可行,许多 hybrid 编程环境里并不提供能展示单一 call-stack 的调试工具。如果需要,程序员只能自己用头脑分析和想像。Objective-C 极大的改进了这点。除统一的编译器之外,让 Objective-C 成为「单一」语言的另一个要素是能集成分析 C 和 OO 扩展部分运行状态的调试器。

从工具集合来看,Objective-C 比大多数开源语言都丰富。无论开源还是商业领域,直接支持 hybrid 编程的单一编译器和展示 hybrid 运行时的单一调试器都是 Objective-C 独特的优势。

共生系统

Objective-C 体现了独特但合理的设计编程环境的思路:使用同一工具的并不非得是一个语言,多个语言也不见得非要使用多个分立的工具。集成不同语言的可以是方便的工具而不是脆弱的 API。对两种定义清晰的语言提供高度集成的同一工具是 Objective-C 比很多晚出现十几年的语言更显先进的原因。太多语言设计者抱着这样的错误成见:我的语言必须是一个整体;我的语言可以令程序员从开始 coding 一刻起就避免和解决很多其它语言需要用工具解决的问题,所以不需要太多工具;我的语言这么好所以会有无数的人为它写工具,我只要专心设计语言最小核心的编译解释工具。我曾经写过 Lua 最大限度避免了集成污染 [4],因为 Lua 利用了 C 编译器对 ANSI C 的良好支持。遗憾的是,在这个利用工具的良好开端之后,和其它语言一样,Lua 的开发团队始终是限于语言本身的改进而没有任何附带的增强工具。

编程环境是语言和工具的共生体,但是人们经常忽视这个事实,或者只是关心共生体的一小部分,徒劳的希望别人来改进其它部分。Mac OS X 的编程环境,得益于 Apple 这家强于 end-to-end control [5] 的公司选择了独特的 Objective-C。

脚注:

  1. Design and Evolution of C++》。
  2. 高级动态语言和软件业》,《高级动态语言和软件业 —— 交流与内省》。
  3. 《Masterminds of Programming》。
  4. 集成污染
  5. Objective-C 说明了 end-to-end control 并不等同封闭。它的实现 Clang 和 LLVM/LLDB,乃至之前的 GCC 都是 open source 实现。Control 只是推动了它们的整体发展和最终集成。Apple 选择了工具支持最好的 Objective-C,同时在业界对其缺乏兴趣的环境下一力发展 Objective-C 的语言和工具是有勇气的。

高级动态语言与软件业 —— 交流与内省

2011/06/01

一直以来,我都认为编程语言是程序员之间的语言,是人和人交流的工具。主流编程语言最重要的特性是清晰、准确,开发者个人在使用中是否感受到的灵活、简洁、优雅则是次要的。这是把编程语言和同为交流工具的自然语言比较,同时又因为两者构建文化认同的程度有所不同,所得出的结论。

接触高级动态语言也已经有近四年,一直觉得虽然它们并不适合作为程序员交流的工具,但绝对是程序员的工具箱里不可缺少的一样东西。几个月前写了一篇《高级动态语言与软件业》,主要观点是,语言概念相对简单,用法相对固定的静态语言更适合构建商业级别的产品,而高级动态语言只限用于符号推导系统。之后是对 Lua 的学习和《集成污染》。高级动态语言并非我所能认同的交流工具,但又具有不可否认的必要性,我经常重新考虑『编程语言/自然语言』这个类比应该如何涵盖高级动态语言。

今天和别人讨论 Objective-C  vs. C++ ,突然回忆起到半年前看的《 What Technology Wants 》里对自然语言的描述:

The creation of language was the first singularity (注1) for humans. It changed everything. Life after language was unimaginable to those on the far side before it.  …  But the chief advantage of language is not communication but autogeneration. Language is a trick that allows the mind to question itself; a magic mirror that reveals to the mind what the mind thinks; a handle that turns a mind into a tool.

这时我想到自己从未把自然语言的『自省』功用和编程语言联系到一起。也就是上文引用里的所谓『 question itself 』,『 reveals to the mind what the mind thinks 』。如果把『自省』这个元素考虑在内,『编程语言/自然语言』这个类比就更完整。复杂的产品中用户定制和核心算法的符号推导部分更加接近『自省』而非『交流』。而且,很多有经验的程序员都说过,你不一定要用它来开发产品,但是学习一门高级动态语言能让你更了解编程的本质。这和自然语言的『自省』功用更接近。最后,自然语言的『自省』功用大于『交流』,编程语言的『自省』功用也很重要,但应该退居『交流』功用之后。因为毕竟程序员不是机器,他们的思考大部分还是通过自然语言完成的。软件产品也不是机器人,很多功能进化依然是通过用户反馈、迭代开发加更新发布而非简单的定制完成的。

脚注:

  1. 顺便说一下,在读到这段文字之前,尽管我知道『技术奇异点』之后和之前就像人和动物的区别,但是我从来没直接意识到自然语言就是史前的奇异点。

集成污染

2011/05/27

写过《高级动态语言与软件业》之后,Alex 回复说 Lua 语言应该符合我的期望。其实我应该早就意识到 Lua 的存在,因为 Lightroom 就是用它作为 policy 部分的编程语言。只是 Lua 的库不如 Python 等语言强大,作为编写应用程序的主力语言还显单薄。但正是因此使用轻量级的 Lua 的开发者很少受到诱惑用它来开发复杂算法和 policy 之外的东西(比如 UI ),那正是我在《高级动态语言与软件业》里所反对的。

另一方面,作为描述算法和 policy 的动态语言的另一个重要条件在《高级动态语言与软件业》里讨论不多,却正是 Lua 的优势 —— Lua 是我所知的造成『集成污染』最少的库。

什么叫做『集成污染』?简单地说,如果系统的构建( build )在加入某个模块之前一直好好的,加入这个摸块之后就开始出现问题,这就叫集成污染。这种症状在编译、链接和运行阶段都可能发生。例如,编译阶段的集成污染的可以来自 C 的 macro ,typedef ,条件编译,C++ 的 template 。运行时的集成污染可能来自不同的多线程模型混用。

一个模块能否被顺利地集成到更大的系统中,取决于它希望别的模块如何对待它。一个模块对其它模块的要求越多,越不明确,集成的困难就越大。当这些要求被错误地理解或者无法满足的时候,就变成了集成污染。如果一个模块只是对数据的类型有所要求,那么任何一个可以定义抽象数据结构的静态语言都能很容易的描述这种要求,而且也很容易得到满足。但是,即使是如此简单的需求,也会遇到命名冲突之类的问题。通常这就是 macro 和 typedef 造成集成污染的原因。

接下来,有些模块的要求在『理论上』能够被满足,但是这种要求超出了现有技术的表达能力。C++ template 和多线程就是这样的例子。一个使用了 template 的模块很难用 C++ 语言本身的结构来说明自己的需求。对多线程来说情况就更严重,因为不仅编程语言不能描述线程模型,连数学语言和自然语言也很难清晰描述一个模型对线程使用的期望。所以,基本上集成涉及多线程的模块是一种盲目试错的过程。

更进一步,有些模块的要求是否能被清晰地描述已经不重要,因为这些要求常常是难以满足的。这些模块要求独占 system-wide 稀缺资源。比如,Cocoa 框架把整个应用的很多初始化工作和主消息循环封装到一个黑箱函数 NSApplicationMain() 中,古老的 C++ Framework 如 MFC 也会隐藏 main() 函数。这些库用自己的方式任意使用消息循环和主函数。如此霸道的模块,在系统中是一山不容二虎。《高级动态语言与软件业》提到很多高级动态语言和静态语言的互操作方式仅限于单向发起 —— 能调用静态语言编写的动态链接库,但是不能作为库被其它(静态)语言调用,这就等同于对主函数(有时连同消息循环)的独占。

解决集成污染的方法是各种边界分离。像 macro 、typedef 之类的命名污染可以通过文件分离来解决。终极方法是进程分离加 IPC 。不论如何难缠的集成污染也会在进程边界止步。另一种解决之道是参与集成的模块低调、收敛、谦虚一些。这就是 Lua 的方式。采用 ANSI C 中最成熟的跨平台部分,避免了编译时的错误。放弃对线程和 I/O 的支持,避免了运行时污染。Lua 能做这样的取舍,原因在于有合理的目标,把自己定位为描述算法的符号操作语言,和 hosting 应用的交互限于严格而且有序的符号集合。这让 Lua 成为扩展 C 语言和的方案中最简洁和最可靠的形式。

高级动态语言与软件业

2011/03/02

顺着 Java 、Perl 、Python 一路看去会发现一个有趣的规律。至少那些 Lisp 高手会发现。后一个比前一个更 Lisp 一点。

If you look at these languages in order, Java, Perl, Python, you notice an interesting pattern. At least, you notice this pattern if you are a Lisp hacker. Each one is progressively more like Lisp.

—— 《 Revenge of the Nerds

从 Lisp 说起

好好学一门高级动态语言一直是日程上一项安排,但限于精力未着手施行。最近看了以《 Revenge of the Nerds 》为首篇的一系列讨论 Lisp 的文章,很受启发。联系我现在的工作领域,对高级动态语言的应用有些看法和期待。虽然在入门之前妄谈可以被称为『成见』,不过鉴于深入学习并非很小的投入,预先思考一下也有益处。

算法与系统

上面提到的这些文章当然都在正面评价 Lisp ,我也多少赞同。但是个人经历形成的感觉仍然是,Lisp 这样的高级动态语言(或者说以 Lisp 为终极目标不断进化的动态语言)并不会在短时间内 —— 估计十年内,成为解决软件业主要问题的手段。我不想老生长谈『性能』问题。性能的确是个问题,但我认为除此之外还有更重要的问题,在更长时间之内不会被基本解决的问题:动态语言适合编写复杂的『算法』,但不适合编写复杂的『系统』。很难给『算法』和『系统』做形式上的定义或者区分。《 Re: Revenge of the Nerds 》中引用了 Trevor Blackwell 的大致说明:

我认为,在 Web 崛起之前,绝少有程序包含比排序更复杂的算法。你认为 FreeBSD 里有复杂的算法吗?Nortel (我靠,老东家!)交换机的五千万行代码里可能连排序都没有,全是硬件资源管理和错误处理。Cisco 路由器的上百万行代码也没有几个复杂的算法。

Before the rise of the Web, I think only a very small minority of software contained complex algorithms: sorting is about as complex as it got. Can you think of a complex algorithm in FreeBSD? The 50 million lines of code in a Nortel switch probably didn’t even contain a sort. It was all about managing hardware and resources and handling failures gracefully. Cisco routers also have millions of lines of software, but not many complex algorithms.

高级动态语言适合执行简洁符号的复杂变换和推导。这类问题具有令纯脑力工作者愉悦的挑战性,属于『优雅』的问题。因此,哪怕能让这些问题的表达和解决更优雅一点点,动态语言的设计者和拥护者也会真诚地付出巨大努力。另一方面,复杂系统没有令人愉悦挑战性,包含无数细节,属于脏活累活。这是高级动态语言不擅长的或者说不屑于的。

优雅的,在智力上具有挑战性的,纯粹的符号问题可以由一个人或者一个很小的团队独立完成。为解决这类问题设计的语言更注重问题本身的表达,是高度简洁和脱离具体平台的,但是其互操作性也受到限制。

复杂的充满细节的工作则需要整个业界协作。需要芯片级别、系统集成级别、操作系统、库、和应用不同级别的互操作。这种复杂的互操作不可能通过简洁的符号来完成,取而代之的是 C 级别的 application programming interface 和 application binary interface 。曾经认为以摩尔定律带来的性能提升和业界的标准化努力能逐渐消除这类细节化的工作,但是实际趋势说明这种预计是错误的。首先是对应用的需求总是超过摩尔定律的发展,今天的 mobile 应用和 3D 应用是以前难以想像的。二是人们和计算机的交互总是不断脱离已经定义的符号范围,几十年前是命令行,几年前是菜单和按钮,今天是各种 drag and drop ,scrolling ,gesture , multi-touch ,未来将是 Kinect 和我们现在还想像不到的东西。

高级动态语言能承担的不过是整个系统中可以被简单符号定义的那部分,正如很多文档中把高级动态语言编写的算法部分叫做 kernel(不要和操作系统 kernel 混淆),只是一个小小的硬核。核内代表优美的数学推导。而外围的复杂系统才是把符号对应到真实世界的建模过程,充满了微妙而且要反复变换的权衡、妥协、近似。

通用和专用

上面所说的高级动态语言的强项限于纯符号问题只是它不能主导软件开发的一方面原因,另一方面,在纯符号问题领域高级动态语言也不是高枕无忧。低级静态语言不适合表达复杂的算法。但是这不表示它们不能。《 Revenge of the Nerds 》也承认,只要是图灵完备的语言都能等价地描述任何算法。当然该文也嘲笑了一番这种做法,它说,一般的方案是:

  1. 使用一种高级动态语言,
  2. 用(当前适用的静态语言)写一个高级动态语言的 ad-hoc 解释器(为了应付当前的问题凑合实现的不完整功能),
  3. 程序员自己充当高级动态语言的人肉解释器。

这种说法似乎成立,当程序员由于种种原因不能使用高级动态语言但是又需要实现复杂算法时,为了使设计清晰不得不自行搭建一层抽象(程序的或者人肉的)。于是,这似乎证明高级动态语言是必需的,只要这种情况是一直成立的。

但是情况会发生变化,当复杂算法足够通用以致为众多领域所需时,软件业对这个算法的投资会达到无需中间抽象而直接用静态语言表示的程度。正如快速傅立叶变换和 H.264 解码有成熟的硬件实现方式,而且不仅仅是几个具体的产品,而是一种脱离具体产品的业界普遍掌握的定式,不需要任何程序或者人肉的抽象。这些通用算法的复杂度虽然很高,却已经不需要『算法 » 高级表示 » 低级等价表示 » 硬件表示』这样的推导过程,而是完全跳过中间步骤。就像小学生每天笔算多位数乘法而不会从十进制定义的角度思考竖式计算原理。定式像生物为了快速行动而做出的不经过逻辑推导的反射行为,也许逻辑是更优雅的智慧结晶,但是反射也是进化的杰作。

我们经常说硬件的发展会减少低级语言的应用领域,但是忽视高级动态语言的应用领域也有类似的时效性。后者的阵地同样不断受到挤压,最终只剩下和各个行业的专业知识紧密相关的专业算法,比如原文中提到的航空管制系统。这些领域原本被认为是 domain-specific language 的适用范围。而动态语言进入这个原本认为应属 DSL 的领域并不奇怪,因为 Lisp 这样的语言里充满了可以把自身调教成另一种形式的 feature ,比如 macro 。按照编程的命名文化,这些创造 feature 的 feature 可以称为 meta-feature

虽然动态语言有临时构建类 DSL 语言的能力,但是这样临时搭建的 DSL 必然是千差万别没有标准的,所以用动态语言写成的代码只适合小范围的解决专业问题。。而对于需要大量互操作的问题,对问题本身的优雅和简洁的表示就会退位于对适应不同文化的交流的需求。这就必然退位于概念更为简单,更为固定的静态语言。这方面的反例是试图在通用领域引入 meta-feature 的 C++ 。把过于灵活的语言应用到整个系统,就像现代人和古人用『几何』、『骚人』这样的词语直接对话。

不完美的子语言

我并非用高级动态语言不能成为软件业的主要工具来否定它的前景。相反,我认为一切未成为大众消费级需求的符号变换操作都应该用动态语言编写。需要使用动态语言的领域会越来越多。另一方面,用高级动态语言承担超出这个范围的应用,尤其是在 I/O 和 UI 方面,虽然在产品初期规模较小的原型阶段可能很顺手,但是随着维护期的增长、代码量的扩大和团队的扩大,最后的净收益很可能为负。

我期待的高级动态语言的模式是类似 SQL 的 sub-language 模式,简化到完全省略 I/O ,嵌入到其它语言或者应用中,只关注符号操作。正如 Emacs 和 AutoCAD 提供的 Lisp 接口。可惜这种做法并没有盛行,没有什么高级动态语言真正的努力支持过 SQLite 那种以库的形式在静态语言中 in-process 解释的能力。采用高级动态语言作为算法模块的系统通常要借助 IPC 或者 Web server 来完成互操作。这也让高级动态语言不得不提供 I/O 甚至 UI 支持,成为扬短避长的累赘。而 IPC 在性能和复杂度上的开销也让很多应用用静态语言来凑合描述本该用动态语言描述的算法( ad-hoc / 人肉解释器)。

我并非 monolithic 的鼓吹者,但是我倾向于把 process separation 作为设计原则而非结构强制。比如,micro-kernel 的强制 user-space device driver 并不成功。而 monolithic kernel 允许设计者自行决定 driver 的结构成就了 FUSE 这样的 user-space driver 。高级动态语言作为一种不能独当一面的语言,最好还是把这种决定权交给应用开发者。高级动态语言并非风格一致的拒绝 in-process 形式,它们通常可以调用静态语言编写的动态链接库,但这种 in-process 交互是单向的,如上所述,动态语言的解释器很少能作为库被其它应用调用。这种单向交互是因为高级动态语言的拥护者很少把自己看作不能脱离静态语言生存的核心,相反,他们把静态语言看作是 legacy ,而且认为高级动态语言才应该是系统的 master 。

这里我们转头看看那个最初来自《人月神话》的著名论断:不论用何种语言,程序员的生产力以『行代码/天』计算的话不会有很大变化,『 bug 数/行』也不会有很大变化。是的,这个论断和我迄今为之的经历没有任何矛盾之处。在我接触过的系统里,程序员用更简洁的语言编写模块会更快,这些模块的 bug 更少,即使有,修改起来也更快。但是,如果系统出现跨模块的问题,若是涉及高级动态语言编写的模块,解决起来会比不涉及这种模块的情况难度高上几个数量级。问题不在于引入高级动态语言模块本身,而在于高级动态语言目前的实现的拙劣的互操作机制 —— 笨重的 IPC ,繁复的参数和数据结构转换。如果这一切有所改观,那么高级动态语应该可以释放更强大的生产力。

开发者的厌恶 —— Objective-C/Cocoa

2010/11/23

前几天看 Buzz 有感匆就了一篇超短的《开发者的厌恶》,涉及了一些很有趣的问题:一个平台,应该如何在开发者和用户之间达到平衡。作为程序员,我不会不知道一个受开发者喜爱的平台才能具有生命力,《 The Art of UNIX Programming 》里大书特书的 programming barrier 、 hobbyist programming 和 fun to hack 等概念是我经常引用的。但另一方面,以纯用户身份转向 Mac 进而成为其开发者的经历 [1],以及 Mac 成功的事实,也说明它们之间没有绝对的优先级,必须在两个相反方向上达到平衡。

达到平衡需要顾及的问题很多。先谈谈 Objective-C 和 Cocoa 这个方面。我对 Objective-C 和 Cocoa 的看法是:远非完美,但是还没有更好的替代。

如果谈及 Objective-C/Cocoa 的不可替代性,就必须谈及 C++ 。业界普及 C++ 已经有几十年,有了 MFC 、QT 等等 framework 。如果说我们认为这些东西是够用的,那么大谈 Objective-C/Cocoa 如何不可替代就是无稽之谈。在这个 blog 里我专门用一个类别来讨论 C++ 的问题。简而言之,我认为业界必须逐渐脱离 C++ 的绑架(如果有人说 GoF 绑架了什么,那么程度不及 C++ 百分之一)。如果你还认为 C++ 足以堪用,那么可以略过这篇 blog。

第二,如果谈及 C++ 的缺点,就必须找替代物。否则一个远非完美但不可替代的 C++ 和一个远非完美但不可替代的某某语言没有比较的必要。对系统开发来说替代物显而易见,Linux kernel 和 git 的成功都说明我们需要的代码组织能力即使是原始的 C 也可以提供。问题在于用户界面的开发,即便轻视面向的人也大都认为界面开发必须使用面向对象。由于其复杂性,面向对象和设计模式是编程语言在这个领域的自然而且必要的延展。所以,必须找到脱离 C++ 的面向对象方案。

这个方案必须具备统一的内存管理。当我说内存管理,我的底线是『线程安全的引用计数』。其实 OS X 的 Objective-C 2.0 已经提供了(可选的)基于引用跟踪的垃圾回收(tracing GC) 。但是我仍然要强调引用计数是一个稳健而明智的方案。关于这一点我在《 C++ 与垃圾回收》中讨论过,针对实际工程中的内存问题,泄漏其实是个无伤大雅的问题,而且 tracing GC 解决内存泄漏也远非完美,内存管理更多的是要解决生命周期,尤其是生命周期过早结束的问题,这点来说引用技术很成功;C++ boost 的 shared_ptr 堪称完美,可惜作为库而非语言组成其强制力大打折扣;Cocoa 的引用计数方案相对来说具备了语言级别的强制力,虽然不是基于栈和作用域的自动方案,但是借助方法的命名规范和 Xcode 的代码静态分析,基本上能达到相同的效果。

当我说最需要统一的内存管理解决的问题是生命周期问题,我指的是普遍的在基于消息循环和派发的环境中的生命周期问题而非狭义的对象生命周期。所以,面向对象需要内存管理方案,而要对整个 code base 和整个开发团队施行统一的内存管理方案,也需要一种面向对象语言,它们在界面开发中是互相依赖的。

一个疑问是这个方案是否必须基于非本地码(也就是 byte code 或 P-code 等非 native code)的虚拟机。我倾向于否定。首先是 OS X 出现的时机,在 2005 年以前,我不认为开发者有信心在当时的硬件条件上使用虚拟机方案。当然,今天是 2010 年。不过,我一向认为好的产品是 do more with more,而不是 do acceptable with less。好的程序员会把 native code 带来的性能变成对产品有利的东西。在那些提供所谓简化模型的虚拟机平台上提供和 native code 具有同样竞争力的界面功能经常需要降低代码的可读性。不可否认,在更原始的平台上开发一个功能需要自己编写更多的底层函数。但是有两个补偿,第一是你有很多 open source 库,不用等待或者自己把它们移植到 Java 或者 ActionScript 上;第二,如果你的代码要维护很多年,你可以写更直观的代码(由于 native code 带来的性能收益),而且多维护几个函数并不会影响代码的整体和局部可读性。另一个原因是,虚拟机方案还能带来什么?一个错误的程序在虚拟机上抛出一个无法恢复的 except,同样错误的程序在 native code app 中造成崩溃并且生成详细的 callstack,甚至生成详细的 core dump。很大的差别吗?

说到差别,我们对所谓低级语言和高级语言的在软件开发中对生产力影响的差别很大程度上夸大了事实。对自动 tracing GC 和虚拟机语言的鼓吹,其实是一贯忽视代码静态检查和 crash callstack report 一类工具的结果。

如果抛开对虚拟机的痴迷,支持 Java 语言对 Mac 平台来说就少了许多吸引力。接下来,Java 在各个层面都有不能容忍的问题。首先,Oracle 对 Google 的诉讼再一次告诉我们,Apple 不可能使用一个由另一家公司完全控制的语言。其次,让语言和 OS 分属不同公司控制也是开发者完成工作的噩梦,同属虚拟机方案的 WPF 用户体验尚不算寒酸。而 Java 无论在哪个不受 Sun 控制的平台上的用户体验都有目共睹(其实我用加那个定语吗)。

接下来,Objective-C 的语法。如果你真的看过我上面的描述,如果 Objective-C 在上面这些方面做得比其它所有语言都稍稍好一些,那么它的语法就算比今天还糟糕又如何?Not a big deal !难道,Java 里用匿名类安装回调函数的语法非常优美?难道 C++ template 和 operator overload 的词句十分押韵?别,连美国人都开始说 long time no see 了。

Apple 应该在 2000 年的时候创建另一种语法完美的语言吗?(或者说 NeXT 在更早些时候?)我不知道。也许如果那发生了,今天我们就会皆大欢喜。可惜的是没有,就像今天的汉字不都是形声字能让我们认半边。我不想讨论 Apple(或者 NeXT )在当时花精力构建一种新语言是在构建完美未来还是在自身难保、资源捉襟见肘的形势下自寻死路,只想说从构建更好的软件出发,如果今天完全剔除 Objective-C/Cocoa 所有引起开发者厌恶的东西是得不偿失的无的放矢。

注释:

  1. 我从 07 年开始在 Mac 上写 code 。不过完全是工作所需。这里的作为用户转向 Mac 和成为其开发者指的是我在自由时间的选择。

链表迷魂阵

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 失效。什么玩艺,这样的话我还用链表干什么?

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 这类语言对更多平台支持的增加(同时,随着那些拒不对垃圾回收提供更好支持和推广的平台的消亡)而逐渐消失。希望软件的内存管理全面的转向垃圾回收。垃圾回收不再是某种语言的特性,而是像函数、文件这样的概念,成为软件开发一个基本的不可缺少的概念。

C++恶性录——之二

2009/06/10

C++中有很多东西是发现的,而不是设计者故意设计的。STL,Template Metaprogramming都不是设计者预先料想到的。所以Bjarne给他的书起名为“C++的设计与演化 (The Design and Evolution of C++)”,我认为这是它迷人的地方,它能够进化。你不能把这些称为workaround或show off

——樊志岩对《C++恶性录——之一》的评论

It’s risky to design minilanguages that are only accidentally Turing-complete. If you do this the odds are good that, sometime in the future, some clever fellow is going to think he needs to press your language into doing loops and conditionals for him. Because these are only available in an obfuscated way, he’ll produce obfuscated code. The results may be serviceable in the short term, but are likely to be a nightmare for those who come after him.

——《The Art of UNIX Programming》


认为C++“迷人”的同学中,有一部分是因为从C++里面发现了那么多意想不到的东西。意想不到的东西给人悲伤、震惊、或者乐趣。但是带来惊喜和乐趣的意想不到并不一定代表好的工程设计。

从C++里能“发现”这么多东西,是不是说明了C++的设计是经过深思熟虑、抑或就是其设计者灵感迸发、(相比那些后来从中“发现”的东西不那么多的语言的设计者更)才华横溢的体现呢?看来有一部分人是这么认为的。但是崇拜经常来自于知识的缺乏。如果你了解了“图灵完备”的概念,然后把template考虑成一种compile-time minilanguage,再把两个概念放到一起,就会把“迷人”的外衣剥落。只要你让一种语言达到compile-time图灵完备,不从中“发现”那么多东西倒是奇怪的了。达到图灵完备是任何一个语言设计者都轻而易举能掌握的技巧。事实上,正如《The Art of UNIX Programming》所告诫的,了解图灵完备概念的意义在于避免无意中触发它而不是不必要的实现它。

那么,抛开“发现”这么多东西的原因,“发现”的这些东西是对工程有实际作用的吗?“[S]erviceable in the short term, … nightmare for those who come after …” C++里面发现的东西,可读性都很差,如果出了编译错误更是没法收拾。例如,C++曾经用template实现了一个LL(k) parser generator,很奇妙么?但是这个东西产生的parser对unpredictable syntax的处理是silent failure。所以这个东西只能是孤芳自赏的玩具!

C++ template是为了静态类型安全设计的。但是当一个在高级语言里可以用smoke test很快发现的错误在C++里变成一个晦涩的编译错误的时候,只能说C++的设计者失去了更广的视角。静态安全在一定限度内是有益的,不知道在什么地方停步是C++设计者的问题。不舍得付出任何代价,用template specialization这种补丁来达到效率提升从而意外的引入“图灵完备”,最终让C++失去的东西更多。