立足现实 与时俱进:C++ 1991-2006 partIV

ARM 82浏览

1、   语言特征:1991-1998

1991年,用于C++98的最重要的语言特征已经被接纳:在ARM中详细描述的模板和异常已经正式成为语言的一部分。然而,对它们的详细规范持续了好几年。除此之外,委员会还在许多新的特征上进行工作,比如:

1992              协变返回类型(参见标准10.3.5ARM中描述的特征的第一个扩展

1993      运行时类型标识(RTTIdynamic_casttypeidtype_info§5.1.2

1993      在条件中的声明 §5.1.3

1993      基于枚举的重载

1993      名字空间 §5.1.1

1993       mutable

1993       新的转换(static_castreinterpret_castconst_cast

1993       布尔类型(bool §5.1.4

1993       显式模板实例

1993       函数模板调用中的显式模板参数指定

1994       成员模板(嵌套模板)

1994       类模板作为模板参数

1996       类内成员初始化

1996       模板的分离编译(export§ 5.2

1996       模板偏特化

1996       重载函数模板的偏序

在这儿我不会涉及到细节;关于这些特征的历史可以从D&E[121]找到,TC++PL3[126]中描述了它们的使用。显然,这些特征中的大多数是在被提议和讨论很久之后,才被投票允许收入到标准中。

委员会有用于接纳一个新的特征的全面的标准吗?不完全有,引进类、类层次、模板和异常(组合)代表了一种审慎的尝试,去改变人们关于编程和编写代码的思考方法。这样的主要的改变是我对C++的目标之一。然而,直到委员会可以被认为是在思考时,它还是没有跟它做的一致。个人提出提议,然后一些人获得委员会的通过,只有限定范围内的提议能够进行投票。委员会的成员都很忙,而主要的做实际工作的人们对于抽象的目标和具体细节没有多少耐心,而那些是可以通过详细的检查得以改正。

我的观点是增加的设施的总和对C++支持的编程风格一个更加完整和效率的支持,因此我们可以说这些提议的全面目标是“提供更好的对面向过程、面向对象和泛型编程的支持和对数据抽象的支持”。这是真的,但它不是用于从长长的列表中挑选出提议的具体的标准。某种程度上,该进程成功地选择出一些新的“次要特征”,它是基于提议-提议的个人决定的结果。那不是我的理想,但结果不是太糟。ISO C++C++98)比早期的C++版本更接近我的理想,C++98是比“ARM C++”(§3)更灵活(强大)的编程语言。主要原因是修正的积累的结果,比如成员模板。

但是,在我看来不是所有被接受的特征都是一种改进。比如“在类内通过常量表达式初始化静态的具有整型类型的const成员”(由John MaxSkaller提议,代表奥大利亚和新西兰的)和void f(T)void f( const T)表示相同的函数的规则(由Tom Plum提议的,出于兼容C的理由)都有共同的可疑的差异,尽管我极力反对,但最后都通过了C++投票。

5.1 一些“次要特征”

当委员会在这些“次要特征”上工作时并不觉得这些次要特征次要,并且程序员在使用它们时也不会觉得这些特征次要。比如,我在D&E[121]中称名字空间和RTTI是“次要”的,然而,它们没有显著地改变我们考虑编写程序的方法,所以我将简要讨论几个这样的特征,许多被认为不是“次要”的。

5.1.1 名字空间

C提供一个单一的全局名字空间用于所有的名字,没有便利的地方存放一个单一函数,单一struct或单一编译单元。这会引起名字冲突的问题。我第一次与这个问题进行搏斗是在C++的早期设计中,对编译单元来说,默认所有名字都是局部的,需要显式使用extern声明才能让它们被其它的编译单元看到。这种想法既不能充分解决问题也不能被充分接受,因此它失败了。

当我设计出类型安全链接机制[121]时,我重新考虑这个问题,我观察到:

extern "C"{ /*...*/}

语法的很小的改变,因此实现技术将允许我们有

extern XXX{ /*...*/}

意味着在XXX中声明的名字是在独立的XXX范围内的,并且其它范围只能通过XXX::才能访问到这些名字,这跟从类外访问静态成员的方法一致。

由于各种的理由,大部分是由于没有时间,这个想法处于休眠状态直到1991年才浮现在ANSI/ISO委员会的讨论中。首先,来自MicrosoftKeith Rowe提出一个提议,建议如下符号:

bundle XXX{ /*...*/};

作为一种定义有名字范围的机制,一个操作符用于把所有bundle中的名字引进到其它域。这导致一场不是非常精力充沛的在扩展组中几个成员间的讨论,包括Steve DovichDag BruckMartin O'Riordan和我。最后,由来自西门子的Volker BaucheRoland HartingerErwin Unruh提炼了这种想法并形成一份提议,不需要使用新的关键字:

::XXX::{/*...*/};

这导致扩展组中的一场严肃的讨论,特别地,Martin O'Riordan论证这个::符号会与用于类成员和全局名字的::发生歧义。

1993年初,我已经在标准会议的讨论中和许多email的帮助下—综合成一个一致的提议。我回想对名字空间有技术贡献的人有Dag BrückJohn BrunsSteve DovichBill GibbonsPhilippe GautronTony HansenPeter JuhlAndrew KoenigEric KrohnDoug McIlroyRichard Minner Martin O’RiordanJohn “Max” SkallerJerry SchwarzMark TerribileMike Vilot和我。除此之外,Mike Vilot主张马上把这种想法通过明确的提议体现出来,这样这个设施将解决在ISO C++库中不可避免的名字问题。除此之外还有各种各样的普通的CC++技术用于限制名字碰撞的破坏,Modula-2Ada提供的设施也被讨论过。19937月,名字空间在Munich会议中投票通过。因此我们可以写如下代码:

namespace XXX{

       int f(int);

}

int f(int);

int x = f(1); // 调用全局的f

int y = XXX::f(1);// 调用 XXX中的f

199311月的San Jose会议中,决定采用名字空间去控制标准CC++库中的名字。

原始的名字空间设计包括一些其它设施,比如名字空间别名,允许对长的名字空间的缩写,using声明将把单独名字引进名字空间。三年后,增加了参数依赖的查找(ADL或“Koenig查找”),使得参数类型的名字空间隐式可见。

结局是一个设施是有用的并且被使用,但就是不被喜欢。名字空间完成了人们期望它们完成的工作,有时优雅,有时笨拙,有时它们做了太多人们希望它们做的事(特别是在模板实例化时的参数依赖的名字查找)。事实是C++标准库只使用单一的名字空间用于它的主要的设施,这是确立名字空间作为C++程序员的一种工具的一种失败的提示。在标准库中使用子名字空间意味着库实现的部分是标准的(也即是说,哪些设施是在哪些名字空间里面,哪些部分依赖于其它部分)。一些库提供商强烈反对限制他们作为传统的编译器实现者的自由传统的CC++库的内部组织本质上是不能被约束的。参数依赖的查找从名字空间中得到帮助,但它是在标准化的后期才引进的。还有,ADL受到模板的干扰,某些情况使得它宁愿意选择出人意料的的模板而不是明显的非模板。这里,“出人意料”和“明显”是由用户客气的评论。

这导致一个对C++0x的提议,加强名字空间,限制它们的使用,来自EDGDavid Vandevoorde的提议是最有意思的,让某些名字空间进入到模块中[146]—那就是说,提供分离的编译的名字空间,象模块一样被加载。显然,这个设施看起来有点象JavaC#中类似的特征。

5.1.2 运行时类型信息

当设计C++时,我已经不考虑用于确定一个对象类型的设施(SimulaQUAINSPECT类似于SmalltalkisKindOfisA)。原因是我观察到频繁和严重的滥用严重地损害了程序的组织:人们使用这些设施去实现(慢且丑陋)switch版本的语句。

增加在运行时用于确定的对象类型的设施的原始动力来自于HP公司的Dmitry LenkovDmitry的经验来自于创建主要的C++库,比如Interview[81]NIH[50]ET++[152]。这些库所提供的RTTI机制相互间是不兼容的,所以它们是使用多个库的壁垒。还有,所有这些都要求基类设计者要有相当的远见。因此,需要一个由语言支持的机制。

我参与了这些机制的详细设计,与Dmitry一起,向委员会提出了初始的提议,然后作为委员会中的主要负责人对提议进行提炼[119]。该提议第一次呈给委员会是在19917月的London会议,然后在19933月在波兰的Oregon会议中被接受。

运行时类型信息机制包含三个部分:

·操作符dynamic_cast,用于从指向派生对象的基类指针中获取一个指向派生类对象的指针,操作符dynamic_cast转换指针只有当它指向的对象类型真的是指定的派生类时,否则它返回0

·操作符typeid,用于确定基类指针所指的对象的实际类型

·一个结构type_info,作用相当于勾子,用以获得与类型相关的运行时的信息

假定一个库提供dialog_box类,它的接口用dialog_box表达。我使用dialog_box和我自己的Sbox

class dialog_box : public window {

// library class known to ‘‘the system’’

public:

virtual int ask();

// ...

};

class Sbox : public dialog_box {

// can be used to communicate a string

public:

int ask();

virtual char* get_string();

// ...

};

因此,当系统/库给我一个指向dialog_box的指针时,我怎么能知道它是否指向我的Sbox呢?注意到我不能修改库,让它知道我的Sbox类,甚至假定我可以,我也不会这么干,因为在那之后我将不得不修改每一个新版本的库。所以,当系统传一个对象给我时,我有时需要问它是不是我“自己类”中的一个。这个问题能够直接使用dynamic_cast得到解决:

void my_fct(dialog_box* bp)

{

if (Sbox* sbp = dynamic_cast<Sbox*>(bp)) {

// use sbp

} else {

// treat *pb as a ‘‘plain’’ dialog box

}

}

dynamic_cast<T*>(p)转换p到目标类型T*,如果*p真的是一个T或者是T的派生类;否则,dynamic_cast<T*>(p)的返回值是0dynamic_cast的使用对于GUI回调的一个十分重要的操作。因此,C++RTTI可以看成是用于支持GUI的最小设施。

如果你不想显式测试,那你可以通过使用引用而不是指针:

void my_fct(dialog_box& br)

{

Sbox& sbr = dynamic_cast<Sbox&>(br);

// use sbr

}

现在,如果dialog_box不是期待的类型,那么异常将会抛出,能够在其它地方进行错误处理(§5.3)。

显然,运行时类型信息是最小的,这导致了要求最大限度的设施:一个完全元数据(meta-data)设施(反射)。到目前为止,对于编程语言来说,这被认为是不适宜的,其它东西将离开它的应用,带着最小的记忆轨迹

5.1.3 在条件中声明

留意我们在“box例子”中用到的转换、声明和测试方法:

if( Sbox *sbp = dynamic_cast<Sbox*>(bp)){

  // use sbp

}

这制造了一个简洁的逻辑实体,最小化了忘记测试的可能性,并把变量的范围限制到了最小。比如,dbp的域就是在if语句中。

这个设施被称之为“条件中的声明”并反映在“在for语句初始化中声明”,和“象语句一样声明”。允许在任何位置声明的思想是由Algol68的把语句作为表达式的定义所激发[154]。因此当Charles Lindsey向我解释说Algol68由于技术原因还不支持在条件中的声明时我感到非常吃惊,那是在HOPL-II会议上。

5.1.4 布尔类型

有些扩展真的是非常次要,但在C++社团中关于它们的讨论则不是。考虑最普通的一个枚举:

enum bool{ false, true};

每一个主要程序都会有一个或跟或者下面的其中一个:

#define bool char

#define Bool int

typedef unsigned int BOOL;

typedef enum { F, T } Boolean;

const true = 1;

#define TRUE 1

#define False (!True)

这种变化显然是没有尽头的,更坏的是,大多变形意味着语义上的微小的变异。并且大多数与其它的在一起使用时会发生冲突。

自然,这个问题已经存在多年,Dag Brück(代表瑞典和爱立信)和Andrew KoenigAT&T)决定对该问题采取某些行动:“C++中关于布尔数据类型的想法属于宗教问题。一些人,特别是那些来自PascalAlgol,认为C没有这样的类型,因此C++也不应该有的想法是不合理的;同样的,其它人,特别是那些来自C的,认为增加这样的类型到C++中是不合理的。

自然地,第一个想法是定义一个枚举类型。然而,Dag BrückSean CorfieldUK)测试了许多C++代码发现,大多数使用的布尔类型要求隐式boolint之间的转换。C++没有提供隐式的int到枚举的转换,所以定义一个枚举作为标准的bool将破坏许多已经存在的代码。所以为什么还要一个布尔类型?

·布尔数据类型是生活的一部分,不管它是否为C++标准的一部分

·许多冲突的定义使得难以方便和安全地使用布尔类型

·许多人想要基于布尔类型的重载。

对我来说,稍微有点意外,委员会接受了这些理由,所以bool现在是C++中独特的整数类型,带有文字常量truefalse,非0值可以隐式转换成true,而true能够隐式转换到10能隐式转换成false,而false能隐式转换到0。这确保高度的兼容性。

许多年后,bool被证明是大家喜爱的。意料之中,我发现它在用于教那些没有编程经验的人们时很管用。boolC++中取得成功之后,C标准委员会决定把它也加到C里面,不幸的是,他们通过不同的和不兼容的方法实现,所以在C99[64]中,bool是在头文件<stdbool.h>中用关键字_Bool定义的一个宏,在头文件中还有truefalse的宏。

5.2 export论战

从最早的设计,模板原来是想允许在一个编译单元中通过声明就能够使用模板[11735]。比如:

template<class In, class T>

In find(In, In, const T&); // no function body

vector<int>::iterator p = find(vi.begin(), vi.end(),42);

然后就是编译器和链接器的工作,去寻找和使用find模板的定义(D&E §15.10.4)。那是其它语言构建的工作的方法,比如函数,但对于模板,说起来容易,但实现起来却极端困难。

第一个实现模板的编译器是Cfront3.0199110月),在编译期和链接期的代价耗费巨大。然而,当TaumetricBorland实现模板时,他们引进了“包含所有东西”的模型:把所有模板的定义放到头文件中,然后当你在独立的编译单元中多次包含同一个文件时,编译器和链接器将会消除多份定义。第一个带有基本的模板支持的Borland编译器在19911120日发布,然后马上推出3.1版本并在199311月推出更加鲁棒的4.0版本[27]。微软、Sun和其它厂商也采用(相互之间不兼容)“包含所有东西”的方法上。明显地,这种方法违反了实现(使用定义)通常要与接口(只展现声明)分离的规则,并使得定义对于宏、无意的重载解析等都是变得脆弱。考虑如下例子:

// printer.h:

template<class Destination>

class Printer {

locale loc;

Destination des;

public:

template<class T> void out(const T& x)

{ print(des,x,loc); }

// ...

};

我们可以在两个编译单元中这样使用Printer

//user1.c:

typedef int locale; // represent locale by int

#define print(a,b,c) a(c)<<x

#include "printer.h"

// ...

//user2.c:

#include<locale> // use standard locale

using namespace std;

#include "printer.h"

// ...

这显然是非法的,因为在user1.cuser2.c中的环境不一样,会导致Printer定义的不一致性。然而,编译器很难发现这样的错误。不幸的是,这种错误的可能性在C++中要比C要高一些,甚至在C++中,使用模板比不使用模板出错误的可能性也要高一些。原因是当我们使用模板时,在头文件中有许多关于typedef、重载和宏会干扰到模板。这导致防御式的编程实践(比如模板中定义的局部名字的命名以加密的方式以避免冲突,比如_L2)和在分离编译时尽量减少头文件中的代码量。

1996年,在委员会中爆发了一场精力充沛的争论,关于我们是否应该接受“包含所有东西”的模型用于模板定义,实际上,“包含所有东西”的模型偏离了原来的在独立的编译单元中分离模板定义和声明的模型。双方的论据如下:

·分离编译模板太难(不是不可能),这样的负担不应该加到实现者身上

·分离模板编译是必要的,用于适当的代码组织(根据数据隐藏原则)

双方都有许多辅助的论据支持,Mike BallSun)和John SpicerEDB)领导“禁止分离模析编译”团队,Dag Bruck(爱立信和瑞典)领导“保留分离模板编译”团队。我坚持分离模板编译。甚至在混乱的讨论中,双方在他们的主要论点上都是正确的。在最后,来自SGI的人们特别是John Wikinson—建议一个新的模型,作为一种妥协被接受了。这个妥协是关键字export被引入用于提示模板可以被分离编译。

分离模板编译在现在变得更坏:export特征仍然被禁上甚至在一些支持分离编译的编译器上,因为支持它将破坏ABI。直到2003年,Herb Sutter(代表微软)和Tom PlumPlum Hall)提议对标准进行修改,通过这样,没有实现模板分离编译的编译器仍是遵守标准的,那就是把export变成一种可选的语言特征。给出的理由仍然是编译器实现的复杂度和基于一个客观事实,那就是在标准通过的五年后,仍然只有一个编译器能做到模板分离编译。这个提议被80%的人所反对,部分是因为已经有一个支持export的编译器存在;抛开技术上的争论,许多委员会成员认为对一些已经花了大量时间和工作去实现分离模板编译的编译器制造商来说,把export作为一个可选特征是不公平的。

这个伤感传奇的真正主角是EDG编译器的实现者:Steve AdamczykJohn SpicerDavid Vandevoorde。他们强烈反对分离模板编译,最后为达到最佳妥协投赞成票,然后花了超过一年的时候完成了他们所反对的东西。这就是专业!编译器的实现正如他的竞争对手所预测的那样困难。但它成功了并带来了一些好处,正如它的支持者所承诺的那样。不幸的是,由于一些对分离模板编译的必要的约束,因此承诺以因没能提供它们预期的利益并且让编译器变得更加复杂而告终。一如往常,在技术问题上的政客般的承诺导致了“warts”。

我怀疑分离模板编译的一个更好的解决方法的主要组成部分是concepts§8.3.3),另一个就是David Vandevoorde的模块[146]

5.3 异常安全

在详细说明STL的工作过程中,我们遇到一个奇怪的现象:我们不能很好地谈论模板与异常之间的相互作用。很少有人因为这方面的模板问题而被责怪。其它人开始认为由于没有自动废料收集,异常基本上是缺陷的(比如[20])或至少是有缺陷的。然而,当库的实现者(Nathan MyersGreg ColvinDave Abrahams)观察这个问题时,他们发现基本上我们有一个语言特征异常那是我们还不知道怎么很好地使用它。问题在于资源和异常间的相互作用(或更通常的,invariant(使对象的状态良好定义的属性称之为invariant。)与异常之间的相互作用)。如果抛出一个异常,那会导致资源不可访问,没有优雅地恢复的希望。当我设计异常处理机制时我当然考虑过这一点并提出从构造函数中抛出异常的规则(正确地处理部分构建的组合对象)和“在初始化时获取资源”技术(§5.3.1)。然而,那只是一个好的开始和一个十分重要的基础。我们需要的是概念性的框架一个更系统的方法去思考资源管理。和许多人一起,Matt Austern开发了这样的一个框架。

关键是Dave Abrahams1997年早期所阐述的三个保证:

·基本保证:保持成员的invariant不变,没有资源泄露

·强保证:操作要么完全成功,要么抛出异常,让程序维持该操作之前的状态

·no-throw保证:操作不会抛出异常

注意到强保证基本上是数据库的“commit”或“rollback”规则。使用这些基本的概念,库工作组阐述标准库和制造出高效和鲁棒的标准库。标准库对所有操作提供了基本保证,我们不应该通过在构造函数中抛出异常来退出。除此之外,库对关键操作提供强保证和no-throw保证。我发现这个结果是很重要的,应该加到TC++PL的附录中[124],但由于其它原因没能加进来,后面在[126]添加进来。对于标准库异常保证的细节和使用异常的编程技术,参考TC++PL的附录E

Matt AusternSGI STLBoris FomitchevSTLPort[42]是第一个使用这些概念去获得异常安全的STL,在1997年春天出现的标准容器和算法得到Dave Abraham的异常安全的支持。

我认为这儿的关键教训是:只是知道一个语言特征是怎么表现是不够的。为了写出好的软件,对需要用到特征的问题我们必须有一个清晰的表达设计策略。

5.3.1 资源管理

异常典型地并且是正确的被看成是一种控制结构:throw将控制权丢给了其它的catch语句。然而,操作的顺序只是该场景中的一部分:使用异常的错误处理大多都是跟资源和invariant有关。对于C++类来说异常就是类的一部分,异常原语提供一种必要的用于保证和标准库设计的基础。

当抛出一个异常时,每一个在从throwcatch路径上的已经构造的对象都会被销毁,而部分构造的对象的销毁则不会被调用。如果没有这两条规则,异常处理将会变得难以管理(在没有其它支持的情况下)。我(笨拙地)命名这个基本的技术为“在初始化时获取资源通常缩写为“RAII”。经典的例子[118]

// naive and unsafe code:

void use_file(const char* fn)

{

FILE* f = fopen(fn,"w"); // open file fn

// use f

fclose(f); // close file fn

}

这看起来很合理,然而,如果在调用fopen之后,fclose之前出错,异常会使得use_file在没有调用 fclose的时候退出。请注意同样的问题同样发生在不支持异常处理的语言上。比如,调用标准C库函数longjmp也会有同样的后果。甚至一个错误引入的return,都会使得程序泄露一个文件句柄。如果我们想要支持容错系统,我们必须解决这个问题。

通常的解决方法是把资源(这里的文件句柄)作为某些类的对象,类的构造函数请求资源,然后类的析构函数释放它。比如,我们可以定义一个File_ptr类,其作用跟FILE*一样,如下

class File_ptr {

FILE* p;

public:

File_ptr(const char* n, const char* a)

{

p = fopen(n,a);

if (p==0) throw Failed_to_open(n);

}

~File_ptr() { fclose(p); }

// copy, etc.

operator FILE*() { return p; }

};

我们可以通过fopen返回的参数去购造一个File_ptr对象。File_ptr对象将在它的域的最后被析构,析构函数则关闭文件。我们的程序现在缩短成这样:

void use_file(const char* fn)

{

File_ptr f(fn,"r"); // open file fn

// use f

} // file fn implicitly closed

析构函数被调用与函数是由于正常退出或由于抛出一个异常而退出没有关系。问题的一般形式看起来是这样的:

void use()

{

// acquire resource 1

// ...

// acquire resource n

// use resources

// release resource n

// ...

// release resource 1

}

这可以应用任何“资源”,任何你获取然后必须释放给系统,以便使其正确工作的东西。例子有文件、锁、内存、iostream状态和网络连接。请注意到自动废料收集不是它的替代:及时释放资源是是很重要的,并且对性能有益的。这是一种对资源管理的系统的方法,修改后的代码比原来有错误的和原始的方法的代码更短和更简单。程序员不用记住去释放资源。这跟以前曾经流行的finally方法相比也不一样,程序员提供一个try块去释放资源。C++处理这种问题的解决方法象下面这样:

void use_file(const char* fn)

{

FILE* f = fopen(fn,"r"); // open file fn

try {

// use f

}

catch (...) { // catch all

fclose(f); // close file fn

throw; // re-throw

}

fclose(f); // close file fn

}

这种解决方法的问题是它很冗赘的、乏味和可能代价巨大。通过在语言中提供 finally语句,它能变得不那么冗长和不那么乏味,正如JavaC#以及早期的语言所做的那样[92]。然而,缩短这样的代码没有涉及到基本的问题,即程序员必须记住去写释放每个已经获得的资源的代码,而不是只获取资源,象RAII方法所做的那样。当我发现“在初始化时获得资源”是对finally方法的一种系统的和更小错误倾向的替换方法后,半年之后把异常引入到ARM并在会议论文中提到异常[79]

注意到RAII技术象其它强大的C++习惯用语,比如智能指针依赖于析构函数的使用。析构函数是1980年第一批被引入到带类C中的特征(§2.1)中的一个。它们的引入是由于对资源管理的关注所激发。构造函数申请资源并初始化;析构函数则执行相反的动作:它们释放资源和做清理工作。因为释放和清除必须被保证,对于分配在栈上(静态分配的变量也是)的对象,析构函数被隐式调用。因此,现代C++资源管理是在带类C的第一个版本的基础上发展起来的,而这又根源于我早期的在操作系统上的工作[121]

5.4 自动废料收集

1995年的某个时候,我逐渐理解到委员会中的大多数人认为把废料收集器加入到C++编程是不符合标准的,因为收集器会不可避免地执行某些动作,而那将违反标准规则。更坏的是,他们在自动废料收集器将打破规则这一点上是正确的。比如:

void f()

{

int* p = new int[100];

// fill *p with valuable data

file << p; // write the pointer to a file

p = 0; // remove the pointer to the ints

// work on something else for a week

file >> p;

if (p[37] == 5) { // now use the ints

// ...

}

}

我对上面代码的看法不管口头上的还是书面上的都是相当粗暴的:“这样的程序应被扔掉”、“使用保守的废料收集器对C++来说非常好”。然而,那不是草稿标准说的。废料收集器在我们读从文件中获取数据的指针和再次使用整数数组之前无疑会回收内存。然而,标准C和标准C++,绝对不允许内存在没有程序员的显式的动作下被回收!

为解决这个问题,我做了个提议,允许“可选的自动废料收集器”[123]。这会把C++带回到我曾经考虑过的C++该是什么样子的境地并让已经在被使用的废料收集器遵守标准[1147]。在标准中明确的提出这个将会鼓励GC的恰当的使用。不幸地,我严重低估了委员会对废料收集器的厌恶,并且错误地提交了提议。

我的GC提议的致命失误是混淆关于“可选择的”的含意。“可选择”真的意味着编译器不是必须提供一个废料收集器?它意味着程序员能够决定是否打开或关闭废料收集器?是否要在编译时和运行时做出选择?如果我要求激活废料收集器去工作但编译器不提供时发生什么事?我能否在运行时请求进行废料收集?我怎么确定废料收集器没有在关键操作的时候运行?那时对于这样的问题的混乱的讨论爆发了,不同的人们发现许多的冲突的答案,提议被否决。

事实上,就算当时没有被搞糊涂,废料收集器也不会在1995年获得通过。部分委员会成员:

·出于性能的原因对GC强烈地不信任

·厌恶GC因为它看起来与C不兼容

·没有感觉到他们理解接受GC所带来的含义(我们也没有)

·不想创建一个废料收集器

·不想为废料收集器付出代价(金钱、空间和时间等)

·GC风格的另一种替代

·不想花费委员会宝贵的时间在GC

基本上,在标准进程中引进某些主要的东西是太迟了。为了让与废料收集器有关的东西能被接受,我应该提前一年开始。

我的关于废料收集器的提议反映了后来在C++中主要使用的废料收集器即保守的收集器,没有关于哪些内存位置包含指针和决不在内存中移动对象的假设[11]。变通办法包括创建一个类型安全的C++子集,这样就能知道每个指针指向哪里,使用智能指针[34]和提供一个单独的操作符(gcnewnew(gc))用于分配在废料收集堆的对象,这三种方法都是可行的,各带不同的好处,并且都有各自的支持者,进一步让C++中标准化废料收集器的工作变得复杂。

多年的一个共同的问题是:为什么你没有增加GCC++?通常,这意味着(后续评论)C++委员会怎么无视这本该被做而尚未开始做的工作。首先,我观察到在我以前的思考中有这样的看法,当C++第一次设计时如果它依赖于废料收集器则C++将胎死腹中。废料收集器的时间开销,在可供使用的硬件上,在靠近硬件和性能关键的领域都排除废料收集器的使用,而那是正是C++的面包和黄油。带有废料收集的语言,比如LispSmalltalk,人们对那些适合他们的应用程序有理由感到高兴。我的目标不是在它们已经确立的领域上让C++去取代它们。C++的目标是让面向对象和数据抽象机制在那些技术已被认为不现实的领域变得可以使用。C++的主要使用领域涉及任务,比如器件驱动,高性能计算和硬实时任务,而废料收集器在这些领域要么不可行要么不常被使用。

一旦C++被制定为没有废料收集并带有一组使得废料收集变得困难(指针、转换、联合等)的语言特征,那么将很难在不造成较大损害的情况下去重新改造。而且,C++提供的许多特征使得废料收集在许多领域没有必要(域对象、析构函数、定义容器和智能指针的设施等)。这些都让废料收集器变得不紧迫。

所以,为什么我想看到C++支持废料收集器呢?实际的理由是许多人写代码时随意地使用自由存储,在一个数十万行代码的程序中,满世界都是newdelete,我看不到避免内存泄露和限制非法指针访问的希望。我对那些开始一个工程的人们的建议是简单的:“不要那么做”。通过使用容器(STL和其它§4.1)、资源句柄(§5.3.1和智能指针(§6.2))和智能指针(如果需要的话)就能避免那些问题,写出正确且高效的C++代码的方法是相当容易的。然而,我们中的许多人不得不处理老的代码,它们对内存的处理方法是随意的,对于这样的代码,增加废料收集器通常是最佳选择。我希望C++0x要求每个C++编译器都要有一个废料收集器,它能够被激活或关闭。

我怀疑废料收集器最终会变成必需品的另一个理由是,我还没有看到怎么获得完美的类型安全而不需要至少不需要普遍深入的测试指针合法性或有害的兼容性(比如,通过使用两个字的非局部指针)。并且改进类型安全(比如,“消除每一个隐式类型违例”[121])总是C++的一个长期的基本的目标。显然,这与通常的“当你有一个废料收集器,编程将变得简单因为你不需要考虑回收”理由很不同。与之对比,我的观点总结为“C++是一个可以应用废料收集的语言,因为它创建许多小的废料需要被收集。”许多关于C++的思考已经关注到一般的资源(比如锁、文件句柄、线程句柄、和自由存储内存),正如§5.3所备注的,这个关注从语言本身转移到标准库,再到编程技术。对于大的系统,对于嵌入式系统,和对于安全关键的系统,一种系统的对待资源的方法对我来说比关注废料收集器更有前途。

5.5 没有完成的工作

选择做什么比怎么完成那些工作更重要。如果有人在本文是指C++标准委员会委员决定在一个错误的问题上进行工作,工作完成的质量大多是不相关的,给定有限的时间和资源,委员会只能处理少数主题并且只能在少数的主题上努力工作而不是更多的主题。大体上,我认为委员会的选择是正确的,并且C++98已经被证明是一个明显的比ARM C++好的语言。自然,我们可以做得更好,但是回顾起来,仍是难以知道怎样才能做得更好。那时候所做的决定,都带有许多我们可以获得的信息(关于问题、资源、可能的解决方法、商业现实等),那今天的问题又有哪些呢?

尽管有些问题很显然:

·为什么我们没有增加对并发的支持?

·为什么我们没有提供更多有用的库?

·为什么我们不提供一个GUI

这三个问题都被认真考虑过,并且前两个是通过投票决定的。投票的结果基本上是一致通过的。考虑到我们已经决定不再追求并发或提供一个更大的库,那么关于GUI库的问题就没有讨论的意义了。

我们中的许多人也许是大多数的委员会成员想要某种并发的支持。并发是基本的和重要的。在其它建议中,我们有一个漂亮的坚固的提议,关于以micro-C++的形式支持并发,提议来自University of Toronto[18]。我们中的某些人,比如Dag Bruck依赖于来自爱立信的数据,察看该事件并向委员会提出不处理并发,理由如下:

·我们发现许多替代的方法用于支持并发

·不同的应用领域显然有不同的需求和完全不同的传统

·Ada就是直接支持并发的语言,它的经历让人失望

·许多(而不是全部)能够由库来完成

·委员会没有足够的经验去完成一个坚固的设计

·我们不想有一组对并发方法的支持超过了其它方法的原语

·我们估计工作如果完全可行将耗费多年,考虑到我们短缺的资源

·我们没有能力去决定应该抛弃用于改进并发工作的其它想法

我估计那时“并发跟其它扩展一样是一个大的主题,我们考虑把它们放到一起”。我不记得我听说过我认为是“辩论终结者”的东西:任何充分的并发支持都会牵涉到操作系统;因为C++是一个系统编程语言,我们需要映射C++的并发设施到系统提供的原语上。但对于每个平台的所有者都坚持C++程序员能够使用每一个他们提供的设施。除此之外,作为多语言环境的基本部分,C++不能依赖于并发模型,因为这与其它语言支持的明显不同。设计问题受到太多约束以致没有解决方法。

缺少并发支持并没有伤害到C++社团,因为人们处理并发是相当现实的,够通过库的支持来实现,并且有少数的语言扩展(非标准的)。不同的线程库(§8.6)和MPI[94][49]提供了许多例子。

今天,折衷方案变得不同了:硬件门数的持续增长,但硬件时钟速度没有增长,这两个是利用低层次的并发的一个强烈的诱因。除此之外,多处理器的增长和集群要求其它风格的并发支持,广域网的增长和web从本质上说也是另一种风格的并发系统。支持并发的挑战比以往都要紧急,C++世界中的主要玩家们看起来对适应改变的要求很开放。C++0x上的工作就能反映出这一点(§8.2)。

对“为什么我们没有提供更多有用的库”的回答是简单的:我们没有资源(时间和人力)去做比我们所能做到的更多的工作。甚至为了获取与其它部分的一致性,STL因此延迟1年,比如iostream,就让委员会劳累很久。明显的捷径采用商用的基础库也被考虑过。在1992年,TI提供他们的非常漂亮的库用于考虑,在一个小时内,有5个主要公司的代表清楚地表达了他们的观点:如果这个库被认真考虑的话,那么他们将推荐他们自己公司的基础库。这不是委员会所能接受的。对一致性要求不是很高的委员会也许会通过从商用库中选择一个来达到目的,但对于C++委员会来说,这是不可能的。

1994年同样也是必须被记住的,许多人已经认为C++标准库太大了。Java还没有改变程序员的观点,即他们可以从一门语言中免费得到某些东西。反而,C++社团中的许多人使用微小的C标准库作为库大小的比较单位。一些国家代表(荷兰和法国)反复表达他们对C++标准库的膨胀的担忧。象委员会中的大多数人一样,我也希望标准会对C++工业库有所帮助而不是取代它们。

考虑到那些对库的一般的关注,对“为什么我们没有提供一个GUI库”的答案也就很显然了:委员会不能完成它。甚至在处理GUI特定的设计之前,委员会必须处理并发和决定容器设计。除此之外,许多成员并没有准备。GUI被看成是一个又大又复杂的库使用dynamic_cast§5.1.2就能由自己来完成(特别地,那正是我的观点)。事实上他们也确实这样做了。今天的问题不是没有C++GUI库,而是有太多库,大约有25种类似的库在被使用(比如,Gtkmm[161]FLTK[156]SmartWin++[159]MFC[158]WTL[160]wxWidgets(前身是wxWindows[162]Qt[10])。委员会可以在GUI库上工作的,在那些被GUI库作为基础的库设施上工作,或者在用于公共GUI功能的标准库接口上工作。后两种方法也许会产生重要的结果,但那都没有被采纳,因此我不认为委员会有在那个方向上取得成功的必需的才能。相反,委员会在更精细的流IO的接口上花了大量工夫。那也许是一个死胡同,因为用于多字符集和locale的施设在传统数据流的环境中不是很有用。