“C++”目录存档

链表迷魂阵

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

C++恶性录——之一

2009年06月1日,星期一

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]关于代入风格的讨论会随后整理。代入风格即处理输入信息的时候局部分离处理。和委托风格对应(即处理输入信息时全局统一处理)。