Archive for the ‘C++’ Category

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++失去的东西更多。

C++恶性录——之一

2009/06/01

C语言是一种简单优美的语言,但是它也有很多问题。C++试图解决C语言的某些问题,并为此付出了努力。这种努力值得钦佩,但是其结果不如人意。由于对C++的大量投资,人们往往对C++在表面上解决了C问题的假想夸大其辞,而对于C++继承C的问题过分忽视。

C++对C的兼容性是一个错误。这不是因为C++继承了C的错误,而是C++的继承导致了C++特有的错误。C的很多策略在其简单的环境下是可控的。当C++拥有比C高一个数量级的复杂度的时候,这些策略就成为了致命的问题。所以,C++对C的继承和兼容并不意味着C++“至少比C好”。C++在(表面上)解决一个问题的时候引入了(实质上)更多的问题

分离编译(separate compilation)是C语言编译的基本策略。在此基础上C借助make等工具实现了增量构建(incremental build)。分离编译中,不同编译单元(unit of translation)中的信息共享通过头文件来完成;最后的信息汇总通过连接器完成。分离编译——特别是在借助于头文件这种低级语言处理方式,并且最终必须通过静态连接生成可执行文件的情况下——有它自身的局限性,但是在一个相对简洁的语言中,还是可以维护的。

在C的分离编译中,一个编译单元可以引用另一个编译单元中的实现。后者对其包含的实现(比如函数,全局变量)输出symbol。前者引用symbol。二者间symbol的一致性通过头文件保证。连接器通过symbol的对应来进行连接。如果两个不同的编译单元输出了同样的symbol,连接器会报错。如果一个编译单元引用的symbol没有被找到,编译器或者连接器会报错。这样的结果是,放在头文件中的实现往往会导致连接时的重复symbol定义错误。所以,部分的是头文件的设计初衷,部分的是这种简单的连接策略的结果,头文件在C语言中半强制性的地位是包含类型声明,而不能包含类型的实现。C语言的简单机制保证了绝大多数编译单元间不一致造成的错误能在编译和连接阶段被发现,同时提倡了头文件功能的统一。

在C语言没有提供保护的广大领域中,容器的类型安全是最为C++的鼓吹者津津乐道的。C++解决容器类型安全的方法不是为语言处理加入容器类型安全的特性,而是发明了模板这个meta-feature。通过模板,C++的设计者感到满足——因为面对问题,C++没有头疼医头、脚疼医脚,不是简单的ad-hoc解决,而是引入了优美的新的基本概念,从逻辑基础上一举解决这个问题,还得到了编译器图灵完备的语言。

但是有些东西超出了C++设计者的预料。由于模板在编译时才能具体展开,所以模板的实现必须放在头文件中。使用模板的每个编译单元都必须包含实现这些模板的头文件。模板的问题在于,引用模板就是实现模板。所以,当单元A和单元B同时引用std::vector<a_class>的时候,它们不是在引用同一个类型,而是各自实现了一个类型。这样的结果是C++的连接器不能像C连接器那样,简单地对不同的编译单元实现同一个symbol的情况报错,C++的连接器必须决定保留某个编译单元的实现而抛弃其它单元的实现(严格的说并非完全抛弃,如果不同的编译单元使用了某个模板类不同的成员函数,这些不同的成员函数的实现会被合并)。

当不同的编译单元引用了同名的模板附带同名的类型参数,但是这些模板或者类型参数在不同的单元中的定义不同(比如不同的编译单元使用了不同的编译宏、或者引用了含有同样名字模板的不同头文件),结果是灾难性的。任何一个单元的编译都是成功的。连接呢?当你遇到一个具体的编译器之前,你绝对不知道;当在一个莫名其妙的错误上花费无数时间之后,你也许会猜到几分。在Visual C++上对不同的编译单元打开或者关闭_HAS_ITERATOR_DEBUGGING,你会发现,连接默默的获得了成功,但是(幸运的话)程序会马上崩溃。

是的,C也会有类似的问题。但是C的问题仅仅局限在struct定义,而不会延伸到函数实现。大多数由于包含不同struct实现而造成的分离编译问题实际上都能在编译时发现。因为struct本身具有接口的性质,而接口的错误往往会造成同一个编译单元内部信息的不一致,从而在编译时被发现。但是模板不一致造成的问题往往属于实现问题,在编译时被隐藏,在连接时又被考虑不周的连接器忽视。C++在C的基础上引入了新概念,但是新概念并不是基于更智能的编译策略,而是一种简单的即时(on-the-fly)类型生成。这种代入风格[1]的扩展把C语言中无关痛痒的瑕疵发展成了致命的缺陷。C++的初衷是增强C的静态类型系统,把更多的错误扼杀在运行时之前。但是由于失去控制的复杂度,最终导致一些在C语言中本已解决的问题退化为运行时的问题。

注:[1]关于代入风格的讨论会随后整理。代入风格即处理输入信息的时候局部分离处理。和委托风格对应(即处理输入信息时全局统一处理)。