第七章 杂项

第七章 杂项

 

进行高效的C++程序设计有很多准则,其中有一些很难归类。本章就是专门为这些准则而安排的。不要因此而小看了它们的重要性。要想写出高效的软件,就必须知道:编译器在背后为你(给你?)做了些什么,怎样保证非局部的静态对象在被使用前已经被初始化,能从标准库得到些什么,从何处着手深入理解语言底层的设计思想。本书最后的这个章节,我将详细说明这些问题,甚至更多其它问题。

条款45: 弄清C++在幕后为你所写、所调用的函数

 

一个空类什么时候不是空类? ---- 当C++编译器通过它的时候。如果你没有声明下列函数,体贴的编译器会声明它自己的版本。这些函数是:一个拷贝构造函数,一个赋值运算符,一个析构函数,一对取址运算符。另外,如果你没有声明任何构造函数,它也将为你声明一个缺省构造函数。所有这些函数都是公有的。换句话说,如果你这么写:

class Empty{};

和你这么写是一样的:

class Empty {
public:
  Empty();                        // 缺省构造函数
  Empty(const Empty& rhs);        // 拷贝构造函数

  ~Empty();                       // 析构函数 ---- 是否
                                  // 为虚函数看下文说明
  Empty&
  operator=(const Empty& rhs);    // 赋值运算符

  Empty* operator&();             // 取址运算符
  const Empty* operator&() const;
};

现在,如果需要,这些函数就会被生成,但你会很容易就需要它们。下面的代码将使得每个函数被生成:

const Empty e1;                     // 缺省构造函数
                                    // 析构函数

Empty e2(e1);                       // 拷贝构造函数

e2 = e1;                            //  赋值运算符

Empty *pe2 = &e2;                   // 取址运算符
                                    // (非const)

const Empty *pe1 = &e1;             //  取址运算符
                                    // (const)

假设编译器为你写了函数,这些函数又做些什么呢?是这样的,缺省构造函数和析构函数实际上什么也不做,它们只是让你能够创建和销毁类的对象(对编译器来说,将一些 "幕后" 行为的代码放在此处也很方便 ---- 参见条款33和M24。)。注意,生成的析构函数一般是非虚拟的(参见条款14),除非它所在的类是从一个声明了虚析构函数的基类继承而来。缺省取址运算符只是返回对象的地址。这些函数实际上就如同下面所定义的那样:

inline Empty::Empty() {}

inline Empty::~Empty() {}

inline Empty * Empty::operator&() { return this; }

inline const Empty * Empty::operator&() const
{ return this; }

至于拷贝构造函数和赋值运算符,官方的规则是:缺省拷贝构造函数(赋值运算符)对类的非静态数据成员进行 "以成员为单位的" 逐一拷贝构造(赋值)。即,如果m是类C中类型为T的非静态数据成员,并且C没有声明拷贝构造函数(赋值运算符),m将会通过类型T的拷贝构造函数(赋值运算符)被拷贝构造(赋值)---- 如果T有拷贝构造函数(赋值运算符)的话。如果没有,规则递归应用到m的数据成员,直至找到一个拷贝构造函数(赋值运算符)或固定类型(例如,int,double,指针,等)为止。默认情况下,固定类型的对象拷贝构造(赋值)时是从源对象到目标对象的 "逐位" 拷贝。对于从别的类继承而来的类来说,这条规则适用于继承层次结构中的每一层,所以,用户自定义的构造函数和赋值运算符无论在哪一层被声明,都会被调用。

我希望这已经说得很清楚了。

但怕万一没说清楚,还是给个例子。看这样一个NamedObject模板的定义,它的实例是可以将名字和对象联系起来的类:

template<class T>
class NamedObject {
public:
  NamedObject(const char *name, const T& value);
  NamedObject(const string& name, const T& value);

  ...

private:
  string nameValue;
  T objectValue;
};

因为NamedObject类声明了至少一个构造函数,编译器将不会生成缺省构造函数;但因为没有声明拷贝构造函数和赋值运算符,编译器将生成这些函数(如果需要的话)。

看下面对拷贝构造函数的调用:

NamedObject<int> no1("Smallest Prime Number", 2);

NamedObject<int> no2(no1);      // 调用拷贝构造函数

编译器生成的拷贝构造函数必须分别用no1.nameValue和no1.objectValue来初始化no2.nameValue和no2.objectValue。nameValue的类型是string,string有一个拷贝构造函数(你可以在标准库中查看string来证实 ---- 参见条款49),所以no2.nameValue初始化时将调用string的拷贝构造函数,参数为no1.nameValue。另一方面,NamedObject<int>::objectValue的类型是int(因为这个模板实例中,T是int),int没有定义拷贝构造函数,所以no2.objectValue是通过从no1.objectValue拷贝每一个比特(bit)而被初始化的。

编译器为NamedObject<int>生成的赋值运算符也以同样的方式工作,但通常,编译器生成的赋值运算符要想如上面所描述的那样工作,与此相关的所有代码必须合法且行为上要合理。如果这两个条件中有一个不成立,编译器将拒绝为你的类生成operator=,你就会在编译时收到一些诊断信息。

例如,假设NamedObject象这样定义,nameValue是一个string的引用,objectValue是一个const T:

template<class T>
class NamedObject {
public:
  // 这个构造函数不再有一个const名字参数,因为nameValue
  // 现在是一个非const string的引用。char*构造函数
  // 也不见了,因为引用要指向的是string
  NamedObject(string& name, const T& value);

  ...                          // 同上,假设没有
                               // 声明operator=
private:
  string& nameValue;           // 现在是一个引用
  const T objectValue;         // 现在为const
};

现在看看下面将会发生什么:

string newDog("Persephone");
string oldDog("Satch");

NamedObject<int> p(newDog, 2);      // 正在我写本书时,我们的
                                    // 爱犬Persephone即将过
                                    // 她的第二个生日

NamedObject<int> s(oldDog, 29);     // 家犬Satch如果还活着,
                                    // 会有29岁了(从我童年时算起)

p = s;                              // p中的数据成员将会发生
                                    // 些什么呢?

赋值之前,p.nameValue指向某个string对象,s.nameValue也指向一个string,但并非同一个。赋值会给p.nameValue带来怎样的影响呢?赋值之后,p.nameValue应该指向 "被s.nameValue所指向的string" 吗,即,引用本身应该被修改吗?如果是这样,那太阳从西边出来了,因为C++没有办法让一个引用指向另一个不同的对象(参见条款M1)。或者,p.nameValue所指的string对象应该被修改吗? 这样的话,含有 "指向那个string的指针或引用" 的其它对象也会受影响,也就是说,和赋值没有直接关系的其它对象也会受影响。这是编译器生成的赋值运算符应该做的吗?

面对这样的难题,C++拒绝编译这段代码。如果想让一个包含引用成员的类支持赋值,你就得自己定义赋值运算符。对于包含const成员的类(例如上面被修改的类中的objectValue)来说,编译器的处理也相似;因为修改const成员是不合法的,所以编译器在隐式生成赋值函数时也会不知道怎么办。还有,如果派生类的基类将标准赋值运算符声明为private,  编译器也将拒绝为这个派生类生成赋值运算符。因为,编译器为派生类生成的赋值运算符也应该处理基类部分(见条款16和M33),但这样做的话,就得调用对派生类来说无权访问的基类成员函数,这当然是不可能的。

以上关于编译器生成函数的讨论引发了这样的问题:如果想禁止使用这些函数,那该怎么办呢?也就是说,假如你永远不想让类的对象进行赋值,所以有意不声明operator=,那该怎么做呢?这个小难题的解决方案正是条款27讨论的主题。指针成员和编译器生成的拷贝构造函数及赋值运算符之间的相互影响经常被人忽视,关于这个话题的讨论请查看条款11。

<script type="text/javascript">var style='null';var url='http://www.leftworld.net/stat';</script> <script src="http://www.leftworld.net/stat/stat.js" type="text/javascript"></script> <script language="javascript" src="http://www.leftworld.net/stat/stat.php?user=&style=null&referer=http%3A//www.leftworld.net/online/effectivec/file/ch11c.htm" type="text/javascript"></script>  

条款46: 宁可编译和链接时出错,也不要运行时出错

 

除了极少数情况下会使C++抛出异常(例如,内存耗尽 ---- 见条款7)外,运行时错误的概念和C++没什么关系,就象在C中一样。没有下溢,上溢,除零检查;没有数组越界检查,等等。一旦程序通过了编译和链接,你就得靠自己了 ---- 一切后果自负。这很象跳伞运动,一些人从中找到了刺激,另一些人则吓得摔成了残废。这一思想背后的动机当然在于效率:没有运行时检查,程序会更小更快。

处理这类事情有另一个不同的方法。一些语言如Smalltalk和LISP通常在编译链接期间只是检查极少一些错误,但却提供了强大的运行时系统来处理执行期间的错误。不象C++,这些语言几乎都是解释型的,在提供额外灵活性的同时,它们也带来了性能上的损失。

不要忘了你是在用C++编程。即使发现Smalltalk/LISP的方法很吸引人,也要忘掉它们。常说要坚持党的路线,现在的情况下,它的含义就是要避免运行时错误。只要有可能,就要让出错检查从运行时退回到链接时,或者,最理想的是,编译时。

这种方法带来的好处不仅仅在于程序的大小和速度,还有可靠性。如果程序通过了编译和链接而没有产生错误信息,你就可以确信程序中没有编译器和链接器能检查得到的任何错误,仅此而已。(当然,另一个可能性是,编译器或链接器有问题,但不要拿这种可能性来困扰我们。)

对于运行时错误来说,情况大不一样。在某次运行期间程序没有产生任何运行时错误,你就能确信另一次不同的运行期内不会产生错误吗?比如:在另一次运行中,你以不同的顺序做事,或者采用不同的数据,或者运行更长或更短时间,等等。你可以不停地测试自己的程序直到面色发紫,但你还是不能覆盖所有的可能性。因而,运行时发现错误比在编译链接期间检查错误更不能让人放心。

通常,对设计做一点小小的改动,就可以在编译期间消除可能产生的运行时错误。这常常涉及到在程序中增加新的数据类型(参见条款M33)。例如,假设想写一个类来表示时间中的日期,最初的做法可能象这样:

class Date {
public:
  Date(int day, int month, int year);

  ...

};

准备实现这个构造函数,面临的一个问题是对day和month值的合法性检查。让我们来看看,对于传给month的值来说,怎么做可以免于对它进行合法性检查呢?

一个明显的办法是采用枚举类型而不用整数:

enum Month { Jan = 1, Feb = 2, ... , Nov = 11, Dec = 12 };

class Date {
public:
  Date(int day, Month month, int year);

  ...

};

遗憾的是,这不会换来多少好处,因为枚举类型不需要初始化:

Month m;
Date d(22, m, 1857);      // m是不确定的

所以,Date构造函数还是得验证month参数的值。

既想免除运行时检查,又要保证足够的安全性,你就得用一个类来表示month,你就得保证只有合法的month才被创建:

class Month {
public:
  static const Month Jan() { return 1; }
  static const Month Feb() { return 2; }
  ...
  static const Month Dec() { return 12; }

  int asInt() const           // 为了方便,使Month
  { return monthNumber; }     // 可以被转换为int

private:
  Month(int number): monthNumber(number) {}

  const int monthNumber;
};

class Date {
public:
  Date(int day, const Month& month, int year);
  ...
};

这个设计在几个方面的特点综合确定了它的工作方式。首先,Month构造函数是私有的。这防止了用户去创建新的month。可供使用的只能是Month的静态成员函数返回的对象,再加上它们的拷贝。第二,每个Month对象为const,所以它们不能被改变(否则,很多地方会忍不住将一月转换成六月,特别是在北半球)。最后一点,得到Month对象的唯一办法是调用函数或拷贝现有的Month(通过隐式Month拷贝构造函数 ---- 见条款45)。这样,就可以在任何时间任何地方使用Month对象;不必担心无意中使用了没有被初始化的对象。(否则就可能有问题。条款47进行了说明)

有了这些类,用户几乎不可能指定一个非法的month,甚至完全不可能 ---- 如果不出现下面这种可恶的情况的话:

Month *pm;                 // 定义未被初始化的指针

Date d(1, *pm, 1997);      // 使用未被初始化的指针!

但这种情况所涉及的是另一个问题,即通过未被初始化的指针取值,其结果是不可确定的。(参见条款3,看看我对 "不确定行为" 的感受)遗憾的是,我没有办法来防止或检查这种异端行为。但是,如果假设这种情况永远不会发生,或者如果我们不考虑这种情况下软件的行为,Date构造函数对它的Month参数就可以免于合法性检查。另一方面,构造函数还是必须检查day参数的合法性 ---- 九月,四月,六月和十一月各有多少天呢?

Date的例子将运行时检查用编译时检查来取代。你可能想知道什么时候可以使用链接时检查。实际上,不是经常这么做。C++用链接器来保证所需要的函数只被定义一次(参见条款45,"需要" 一个函数会带来什么)。它还使用链接器来保证静态对象(参见条款47)只被定义一次。你可以用同样的方法使用链接器。例如,条款27说明,对于一个显式声明的函数,如果想有意禁止对它进行定义,链接器检查就很有用。

但不要过于强求。想消除所有的运行检查是不切实际的。例如,任何允许交互式输入的程序都要进行输入验证。同样地,某个类中如果包含需要执行上下限检查的数组,每次访问数组时就要对数组下标进行检查。尽管如此,将检查从运行时转移到编译或链接时一直是值得努力的目标,只要实际可行,就要追求这一目标。这样做的奖赏是,程序会更小,更快,更可靠。

条款47: 确保非局部静态对象在使用前被初始化

 

大家都是成年人了,所以用不着我来告诉你们:使用未被初始化的对象无异于蛮干。事实上,关于这个问题的整个想法会让你觉得可笑;构造函数可以确保对象在创建时被初始化,难道不是这样吗?

唔,是,也不是。在某个特定的被编译单元(即,源文件)中,可能一切都不成问题;但如果在某个被编译单元中,一个对象的初始化要依赖于另一个被编译单元中的另一个对象的值,并且这第二个对象本身也需要初始化,事情就会变得更复杂。

例如,假设你已经写了这样一个程序库,它提供一个文件系统的抽象,其中可能包括一个功能,使得互联网上的文件看起来就象在本地一样。既然程序库使得整个世界看起来象一个单独的文件系统,你就可以在程序库的名字空间(见条款28)中创建一个专门的对象,theFileSystem,这样,用户任何时候需要和程序库所提供的文件系统交互,都可以使用它:

class FileSystem { ... };            // 在个类在你
                                     // 的程序库中

FileSystem theFileSystem;            // 程序库用户
                                     // 和这个对象交互

因为theFileSystem表示的是很复杂的东西,所以它的构造重要而且必需;在theFileSystem还没构造之前就使用它会造成不可确定的行为。(然而,参考条款M17,象theFileSystem这样的对象,其初始化可以被有效、安全地延迟。)

现在假设某个程序库的用户创建了一个类,表示文件系统中的目录。很自然地,这个类使用了theFileSystem:

class Directory {                    // 由程序库的用户创建
public:
  Directory();
  ...
};

Directory::Directory()
{
  通过调用theFileSystem的成员函数
  创建一个Directory对象;
}

进一步假设用户想为临时文件专门创建一个全局Directory对象:

Directory tempDir;                  // 临时文件目录

现在,初始化顺序的问题变得很明显了:除非theFileSystem在tempDir之前被初始化,否则,tempDir的构造函数将会去使用还没被初始化的theFileSystem。但theFileSystem和tempDir是由不同的人在不同的时间、不同的文件中创建的。怎么可以确认theFileSystem在tempDir之前被创建呢?

任何时候,如果在不同的被编译单元中定义了 "非局部静态对象" ,并且这些对象的正确行为依赖于它们被初始化的某一特定顺序,这类问题就会产生。非局部静态对象指的是这样的对象:

· 定义在全局或名字空间范围内(例如:theFileSystem和tempDir),
· 在一个类中被声明为static,或,
· 在一个文件范围被定义为static。

很抱歉,"非局部静态对象" 这个术语没有简称,所以你要让自己习惯这种有点咬口的句子。

对于不同被编译单元中的非局部静态对象,你一定不希望自己的程序行为依赖于它们的初始化顺序,因为你无法控制这种顺序。让我再重复一遍:你绝对无法控制不同被编译单元中非局部静态对象的初始化顺序。

很自然地想知道,为什么无法控制?

这是因为,确定非局部静态对象初始化的 " 正确" 顺序很困难,非常困难,极其困难。即使在它最普通的形式下 ---- 多个被编译单元,多个通过隐式模板实例化所生成的非局部静态对象(隐式模板实例化时,它们本身可能都会产生这样的问题) ---- 不仅不可能确定正确的初始化顺序,往往连找一个可以确定正确顺序的特殊情况都不值得。

在 "混沌理论" 领域,有一个原理称为 "蝴蝶效应" 。这条原理声称,世界某个角落的一只蝴蝶拍动翅膀,会对大气产生微小的影响,从而导致某个遥远的地方天气模式的深刻变化。稍微准确一点来说也就是:对于某种系统,输入的微小干扰会导致输出彻底的变化。

软件系统的开发也表现了自身的 "蝴蝶效应"。一些系统对需求的细节高度敏感,需求发生细小的变化,实现系统的难易程度就会发生巨大的变化。例如,条款29说明,将一个隐式转换的要求从 "String到char*" 改为 "String到const char*",就可以将一个运行慢、容易出错的函数用一个运行快并且安全的函数来代替。

确保非局部静态对象在使用前被初始化的问题也和上面一样,它对你的实现细节十分敏感。但是,如果你不强求一定要访问 "非局部静态对象",而愿意访问具有和非局部静态对象 "相似行为" 的对象(不存在初始化问题),难题就消失了。取而代之的是一个很容易解决的问题,甚至称不上是一个问题。

这种技术 ---- 有时称为 "单一模式"(译注:即Singleton pattern,参见 "Design Patterns" 一书)---- 本身很简单。首先,把每个非局部静态对象转移到函数中,声明它为static。其次,让函数返回这个对象的引用。这样,用户将通过函数调用来指明对象。换句话说,用函数内部的static对象取代了非局部静态对象。(参见条款M26)

这个方法基于这样的事实:虽然关于 "非局部" 静态对象什么时候被初始化,C++几乎没有做过说明;但对于函数中的静态对象(即,"局部" 静态对象)什么时候被初始化,C++却明确指出:它们在函数调用过程中初次碰到对象的定义时被初始化。所以,如果你不对非局部静态对象直接访问,而用返回局部静态对象引用的函数调用来代替,就能保证从函数得到的引用指向的是被初始化了的对象。这样做的另一个好处是,如果这个模拟非局部静态对象的函数从没有被调用,也就永远不会带来对象构造和销毁的开销;而对于非局部静态对象来说就没有这样的好事。

下面的代码对theFileSystem和tempDir都采用了这一技术:

class FileSystem { ... };            // 同前
FileSystem& theFileSystem()          // 这个函数代替了
{                                    // theFileSystem对象

  static FileSystem tfs;             // 定义和初始化
                                     // 局部静态对象
                                     // (tfs = "the file system")

  return tfs;                        // 返回它的引用
}

class Directory { ... };             // 同前

Directory::Directory()
{
  同前,除了theFileSystem被
  theFileSystem()代替;
}

Directory& tempDir()                 // 这个函数代替了
{                                    // tempDir对象

  static Directory td;               // 定义和初始化
                                     // 局部静态对象

  return td;                         // 返回它的引用
}

系统被修改后,用户还是完全和以前一样编程,只是现在他们用的是theFileSystem()和tempDir(),而不是theFileSystem和tempDir。即,他们所用的是返回对象引用的函数,而不是对象本身。

这种返回引用的函数虽然采用了上面所讨论的技术,但函数本身总是很简单:第一行定义并初始化一个局部静态对象,第二行返回它,仅此而已。因为太简单,你可能很想把它声明为inline。条款33指出,对于C++语言规范的最新修订版本来说,这是一个非常有效的实现策略;但它同时指出,在使用之前,一定要确认你的编译器和标准中的相关要求要一致。如果编译器不符合最新标准,你又象上面那样使用内联,就可能造成函数以及函数内部静态对象有多份拷贝。这足以让一个成年的程序员哭泣。

至此已没有什么神秘之处了。为了使这一技术有效,一定要给对象一个合理的初始化顺序。如果你让对象A必须在对象B之前初始化,同时又让A的初始化依赖于B已经被初始化,你就会惹上麻烦,坦白说,是罪有应得。如果能避开这种不合理的情况,本条款所介绍的方案将会很好地为你提供帮助。

条款48: 重视编译器警告

 

很多程序员日常总是不理睬编译器警告。毕竟,如果问题很严重,就会是个错误,不是吗?这种想法在其它语言中相对来说没什么害处,但在C++中,可以肯定的一点是,编译器的设计者肯定比你更清楚到底发生了什么。例如,大家可能都犯过这个错误:

class B {
public:
  virtual void f() const;
};

class D: public B {
public:
  virtual void f();
};

本来是想用D::f重新定义虚函数B::f,但有个错误:在B中,f是一个const成员函数,但在D中没有被声明为const。据我所知,有个编译器会这么说:

warning: D::f() hides virtual B::f()

对于这条警告,很多缺乏经验的程序员会这样自言自语,"D::f当然会隐藏B::f ---- 本来就应该是这样!" 错了。编译器想告诉你的是:声明在B中的f没有在D中重新声明,它被完全隐藏了(参见条款50:为什么这样)。忽视这条编译器警告几乎肯定会导致错误的程序行为。你会不停地调试去找原因,而这个错误实际上早就被编译器发现了。

当然,在对某个编译器的警告信息积累了经验之后,你会真正理解不同的信息所表示的含义(唉,往往和它们表面看上去的意思不同)。一旦有了这些经验,你会对很多警告不予理睬。这没问题,但重要的是,在忽略一个警告之前,你一定要准确理解它想告诉你的含义。

只要谈到警告,就要想到警告是和编译器紧密相关的,所以在编程时不要马马虎虎,寄希望于编译器为你找出每一条错误。例如上面隐藏了函数的那段代码,当它通过不同的(但使用很广泛的)编译器时可能不会产生警告。编译器是用来将C++转换成可执行格式的,并不是你的私人保镖。你想得到那样的安全?去用Ada吧。

条款49: 熟悉标准库

 

C++标准库很大。非常大。难以置信的大。怎么个大法?这么说吧:在C++标准中,关于标准库的规格说明占了密密麻麻300多页,这还不包括标准C库,后者只是 "作为参考"(老实说,原文就是用的这个词)包含在C++库中。

当然,并非总是越大越好,但在现在的情况下,确实越大越好,因为大的库会包含大量的功能。标准库中的功能越多,开发自己的应用程序时能借助的功能就越多。C++库并非提供了一切(很明显的是,没有提供并发和图形用户接口的支持),但确实提供了很多。几乎任何事你都可以求助于它。

在归纳标准库中有些什么之前,需要介绍一下它是如何组织的。因为标准库中东西如此之多,你(或象你一样的其他什么人)所选择的类名或函数名就很有可能和标准库中的某个名字相同。为了避免这种情况所造成的名字冲突,实际上标准库中的一切都被放在名字空间std中(参见条款28)。但这带来了一个新问题。无数现有的C++代码都依赖于使用了多年的伪标准库中的功能,例如,声明在<iostream.h>,<complex.h>,<limits.h>等头文件中的功能。现有软件没有针对使用名字空间而进行设计,如果用std来包装标准库导致现有代码不能用,将是一种可耻行为。(这种釜底抽薪的做法会让现有代码的程序员说出比 "可耻" 更难听的话)

慑于被激怒的程序员会产生的破坏力,标准委员会决定为包装了std的那部分标准库构件创建新的头文件名。生成新头文件的方法仅仅是将现有C++头文件名中的 .h 去掉,方法本身不重要,正如最后产生的结果不一致也并不重要一样。所以<iostream.h>变成了<iostream>,<complex.h>变成了<complex>,等等。对于C头文件,采用同样的方法,但在每个名字前还要添加一个c。所以C的<string.h>变成了<cstring>,<stdio.h>变成了<cstdio>,等等。最后一点是,旧的C++头文件是官方所反对使用的(即,明确列出不再支持),但旧的C头文件则没有(以保持对C的兼容性)。实际上,编译器制造商不会停止对客户现有软件提供支持,所以可以预计,旧的C++头文件在未来几年内还是会被支持。

所以,实际来说,下面是C++头文件的现状:

· 旧的C++头文件名如<iostream.h>将会继续被支持,尽管它们不在官方标准中。这些头文件的内容不在名字空间std中。

· 新的C++头文件如<iostream>包含的基本功能和对应的旧头文件相同,但头文件的内容在名字空间std中。(在标准化的过程中,库中有些部分的细节被修改了,所以旧头文件和新头文件中的实体不一定完全对应。)

· 标准C头文件如<stdio.h>继续被支持。头文件的内容不在std中。

· 具有C库功能的新C++头文件具有如<cstdio>这样的名字。它们提供的内容和相应的旧C头文件相同,只是内容在std中。

所有这些初看有点怪,但不难习惯它。最大的挑战是把字符串头文件理清楚:<string.h>是旧的C头文件,对应的是基于char*的字符串处理函数;<string>是包装了std的C++头文件,对应的是新的string类(看下文);<cstring>是对应于旧C头文件的std版本。如果能掌握这些(我相信你能),其余的也就容易了。

关于标准库,需要知道的第二点是,库中的一切几乎都是模板。看看你的老朋友iostream。(如果你和iostream不是朋友,转到条款2,看看你为什么要和它发展关系)iostream帮助你操作字符流,但什么是字符?是char吗?是wchar_t?是Unicode字符?一些其它的多字节字符?没有明显正确的答案,所以标准库让你去选。所有的流类(stream class)实际上是类模板,在实例化流类的时候指定字符类型。例如,标准库将cout类型定义为ostream,但ostream实际上是一个basic_ostream<char>类型定义(typedef )。

类似的考虑适用于标准库中其它大部分类。string不是类,它是类模板:类型参数限定了每个string类中的字符类型。complex不是类,它是类模板:类型参数限定了每个complex类中实数部分和虚数部分的类型。vector不是类,它是类模板。如此不停地进行下去。

在标准库中你无法避开模板,但如果只是习惯于和char类型的流和字符串打交道,通常可以忽略它们。这是因为,对这些组件的char实例,标准库都为它们定义了typedef,这样你就可以在编程时继续使用cin,cout,cerr等对象,以及istream,ostream,string等类型,不必担心cin的真实类型是basic_istream<char>以及string的真实类型是basic_string<char>。

标准库中很多组件的模板化和上面所建议的大不相同。再看看那个概念上似乎很直观的string。当然,可以基于 "它所包含的字符类型" 确定它的参数,但不同的字符集在细节上有不同,例如,特殊的文件结束字符,拷贝它们的数组的最有效方式,等等。这些特征在标准中被称为traits,它们在string实例中通过另外一个模板参数指定。此外,string对象要执行动态内存分配和释放,但完成这一任务有很多不同的方法(参见条款10)。哪一个最好?你得选择:string模板有一个Allocator参数,Allocator类型的对象被用来分配和释放string对象所使用的内存。

这里有一个basic_string模板的完整声明,以及建立在它之上的string类型定义(typedef);你可以在<string>头文件中找到它(或与之相当的什么东西):

namespace std {

  template<class charT,
           class traits = char_traits<charT>,
           class Allocator = allocator<charT> >
     class basic_string;

  typedef basic_string<char> string;

}

注意,basic_string的traits和Allocator参数有缺省值。这在标准库中是很典型的做法。它为使用者提供了灵活性, 但对于这种灵活性所带来的复杂性,那些只想做 "正常" 操作的"典型" 用户却又可以避开。换句话说,如果只想使用象C字符串那样的字符串对象,就可以使用string对象,而不用在意实际上是在用basic_string<char, char_traits<char>, allocator<char> >类型的对象。

是的,通常可以这么做,但有时还是得稍稍看看底层。例如,条款34指出,声明一个类而不提供定义具有优点;它还指出,下面是一种声明string类型的错误方法:

class string;                   // 会通过编译,但
                                // 你不会这么做

先不要考虑名字空间,这里真正的问题在于:string不是一个类,而是一个typedef。如果可以通过下面的方法解决问题就太好了:

typedef basic_string<char> string;

但这又不能通过编译。"你所说的basic_string是什么东西?" 编译器会奇怪 ---- 当然,它可能会用不同的语句来问你。所以,为了声明string,首先得声明它所依赖的所有模板。如果可以这么做的话,就会象下面这样:

template<class charT> struct char_traits;

template<class T> class allocator;

  template<class charT,
           class traits = char_traits<charT>,
           class Allocator = allocator<charT> >
     class basic_string;

typedef basic_string<char> string;

然而,你不能声明string。至少不应该。这是因为,标准库的实现者声明的stirng(或std名字空间中任何其它东西)可以和标准中所指定的有所不同,只要最终提供的行为符合标准就行。例如,basic_string的实现可以增加第四个模板参数,但这个参数的缺省值所产生的代码的行为要和标准中所说的原始的basic_string一致。

那到底该怎么办?不要手工声明string(或标准库中其它任何部分)。相反,只用包含一个适当的头文件,如<string>。

有了头文件和模板的这些知识,现在可以看看标准C++库中有哪些主要组件:

· 标准C库。它还在,你还可以用它。虽然有些地方有点小的修修补补,但无论怎么说,还是那个用了多年的C库。

· Iostream。和 "传统" Iostream的实现相比,它已经被模板化了,继承层次结构也做了修改,增强了抛出异常的能力,可以支持string(通过stringstream类)和国际化(通过locales ---- 见下文)。当然,你期望Iostream库所具有的东西几乎全都继续存在。也就是说,它还是支持流缓冲区,格式化标识符,操作子和文件,还有cin,cout,cerr和clog对象。这意味着可以把string和文件当做流,还可以对流的行为进行更广泛的控制,包括缓冲和格式化。

· String。string对象在大多数应用中被用来消除对char*指针的使用。它们支持你所期望的那些操作(例如,字符串连接,通过operator[]对单个字符进行常量时间级的访问,等等),它们可以转换成char*,以保持和现有代码的兼容性,它们还自动处理内存管理。一些string的实现采用了引用计数(参见条款M29),这会带来比基于char*的字符串更佳的性能(时间和空间上)。

· 容器。不要再写你自己的基本容器类!标准库提供了下列高效的实现:vector(就象动态可扩充的数组),list(双链表),queue, stack,deque,map,set和bitset。唉,竟然没有hash table(虽然很多制造商作为扩充提供),但多少可以作为补偿的一点是, string是容器。这很重要,因为它意味着对容器所做的任何操作(见下文)对string也适用。

什么?你不明白我为什么说标准库的实现很高效?很简单:标准库规定了每个类的接口,而且每条接口规范中的一部分是一套性能保证。所以,举例来说,无论vector是如何实现的,仅仅提供对它的元素的访问是不够的,还必须提供 "常量时间" 内的访问。如果不这样,就不是一个有效的vector实现。

很多C++程序中,动态分配字符串和数组导致大量使用new和delete,new/delete错误 ---- 尤其是没有delete掉new出来的内存而导致的泄漏 ---- 时常发生。如果使用string和vector对象(二者都执行自身的内存管理)而不使用char*和动态分配的数组的指针,很多new和delete就可以免于使用,使用它们所带来的问题也会随之消失(例如,条款6和11)。

· 算法。标准容器当然好,如果存在易于使用它们的方法就更好。标准库就提供了大量简易的方法(即,预定义函数,官方称为算法(algorithm) ---- 实际上是函数模板),其中的大多数适用于库中所有的容器 ---- 以及内建数组(built-in arrays)!

算法将容器的内容当作序列(sequence),每个算法可以应用于一个容器中所有值所对应的序列,或者一个子序列(subsequence)。标准算法有for_each(为序列中的每个元素调用某个函数),find(在序列中查找包含某个值的第一个位置 ---- 条款M35展示了它的实现),count_if(计算序列中使得某个判定为真的所有元素的数量),equal(确定两个序列包含的元素的值是否完全相同),search(在一个序列中找出某个子序列的起始位置),copy(拷贝一个序列到另一个),unique(在序列中删除重复值),rotate(旋转序列中的值),sort(对序列中的值排序)。注意这里只是抽取了所有算法中的几个;标准库中还包括其它很多算法。

和容器操作一样,算法也有性能保证。例如,stable_sort算法执行时要求不超过0比较级(N log N) 。(如果不理解上面句子中符号 "0" 的意思,不要紧张。概括的说,它的意思实际上是,stable_sort提供的性能必须和最高效的通用排序算法在同一个级别。)

· 对国际化的支持。不同的文化以不同的方式行事。和C库一样,C++库提供了很多特性有助于开发出国际化的软件。但虽然从概念上来说和C类似,其实C++的方法还是有所不同。例如,C++为支持国际化广泛使用了模板,还利用了继承和虚函数,这些一定不会让你感到奇怪。

支持国际化最主要的构件是facets和locales。facets描述的是对一种文化要处理哪些特性,包括排序规则(即,某地区字符集中的字符应该如何排序),日期和时间应该如何表示,数字和货币值应该如何表示,怎样将信息标识符映射成(自然的)明确的语言信息,等等。locales将多组facets捆绑在一起。例如,一个关于美国的locale将包括很多facets,描述如何对美国英语字符串排序,如何以适合美国人的方式读写日期和时间,读写货币和数字值,等等。而对于一个关于法国的locales来说,它描述的是怎么以法国人所习惯的方式完成这些任务。C++允许单个程序中同时存在多个locales,所以一个应用中的不同部分可能采用的是不同的规范。

· 对数字处理的支持。FORTRAN的末日可能就快到了。C++库为复数类(实数和虚数部分的精度可以是float,double或long double)和专门针对数值编程而设计的特殊数组提供了模板。例如,valarray类型的对象可用来保存可以任意混叠(aliasing)的元素。这使得编译器可以更充分地进行优化,尤其是对矢量计算机来说。标准库还对两种不同类型的数组片提供了支持,并提供了算法计算内积(inner product),部分和(partial sum),临差(adjacent difference)等。

· 诊断支持。标准库支持三种报错方式:C的断言(参见条款7),错误号,例外。为了有助于为例外类型提供某种结构,标准库定义了下面的例外类(exception class)层次结构:

                                                   |---domain_error
                    |----- logic_error<---- |---invalid_argument
                    |                              |---length_error
                    |                              |---out_of_range
exception<--|
                    |                               |--- range_error
                    |-----runtime_error<--|---underflow_error
                                                    |---overflow_error

logic_error(或它的子类)类型的例外表示的是软件中的逻辑错误。理论上来说,这样的错误可以通过更仔细的程序设计来防止。runtime_error(或它的子类)类型的例外表示的是只有在运行时才能发现的错误。

可以就这样使用它们,可以通过继承它们来创建自己的例外类,或者可以不去管它。没有人强迫你使用它。

上面列出的内容并没有涵盖标准库中的一切。记住,规范有300多页。但它还是为你初步展现了标准库的基本概貌。

标准库中容器和算法这部分一般称为标准模板库(STL---- 参见条款M35)。STL中实际上还有第三个构件 ---- 迭代子(Iterator) ---- 前面没有介绍过。迭代子是指针似的对象,它让STL算法和容器共同工作。不过现在不需要弄清楚迭代子,因为我这里所介绍的是标准库的高层描述。如果你对它感兴趣,可以在条款39和M35中找到使用它的例子。

STL是标准库中最具创新的部分,这并不是因为它提供了容器和算法(虽然它们非常有用),而是因为它的体系结构。简单来说,它的体系结构具有扩展性:你可以对STL进行添加。当然,标准库中的组件本身是固定的,但如果遵循STL构建的规范,你可以写出自己的容器,算法和迭代子,使它们可以和标准STL组件一起工作,就象标准组件自身之间相互工作一样。你还可以利用别人所写的符合STL规范的容器,算法和迭代子,就象别人利用你的一样。使得STL具有创新意义的原因在于它实际上不是软件,而是一套规范(convention)。标准库中的STL组件只是具体体现了遵循这种规范所能带来的好处。

通过使用标准库中的组件,通常可以让你避免从头到尾来设计自己的IO流,string,容器,国际化,数值数据结构以及诊断等机制。这就给了你更多的时间和精力去关注软件开发中真正重要的部分:实现那些有别于你的竞争对手的软件功能。

条款50: 提高对C++的认识

 

C++中有很多 "东西":C,重载,面向对象,模板,例外,名字空间。这么多东西,有时让人感到不知所措。怎么弄懂所有这些东西呢?

C++之所以发展到现在这个样子,在于它有自己的设计目标。理解了这些设计目标,就不难弄懂所有这些东西了。C++最首要的目标在于:

· 和C的兼容性。很多很多C还存在,很多很多C程序员还存在。C++利用了这一基础,并建立在 ---- 我是指 "平衡在" ---- 这一基础之上。

· 效率。作为C++的设计者和第一个实现者,Bjarne Stroustrup从一开始就清楚地知道,要想把C程序员争取过来,就要避免转换语言会带来性能上的损失,否则他们不会对C++再看第二眼。结果,他确信C++在效率上可以和C匹敌 ---- 二者相差大约在5%之内。

· 和传统开发工具及环境的兼容性。各色不同的开发环境到处都是,编译器、链接器和编辑器则无处不在。从小型到大型的所有开发环境,C++都要轻松应对,所以带的包袱越轻越好。想移植C++?你实际上移植的只是一种语言,并利用了目标平台上现有的工具。(然而,往往也可能带来更好的实现,例如,如果链接器能被修改,使得它可以处理内联和模板在某些方面更高的要求)

· 解决真实问题的可应用性。C++没有被设计为一种完美的,纯粹的语言,不适于用它来教学生如何编程。它是设计为专业程序员的强大工具,用它来解决各种领域中的真实问题。真实世界都有些磕磕碰碰,因此,程序员们所依赖的工具如果偶尔出点问题,也不值得大惊小怪。

以上目标阐明了C++语言中大量的实现细节,如果没有它们作指导,就会有摩擦和困惑。为什么隐式生成的拷贝构造函数和赋值运算符要象现在这样工作呢,尤其是指针(参见条款11和45)?因为这是C对struct进行拷贝和赋值的方式,和C兼容很重要。为什么析构函数不自动被声明为virtual(参见条款14),为什么实现细节必须出现在类的定义中(参见条款34)呢?因为不这样做就会带来性能上的损失,效率很重要。为什么C++不能检测非局部静态对象之间的初始化依赖关系(参见条款47)呢?因为C++支持单独编译(即,分开编译源模块,然后将多个目标文件链接起来,形成可执行程序),依赖现有的链接器,不和程序数据库打交道。所以,C++编译器几乎不可能知道整个程序的一切情况。最后一点,为什么C++不让程序员从一些繁杂事务如内存管理(参见条款5-10)和低级指针操作中解脱出来呢?因为一些程序员需要这些处理能力,一个真正的程序员的需要至关重要。

关于C++身后的设计目标如何影响语言行为的形成,以上介绍远远不够。要想覆盖所有的内容,将需要一整本书;方便的是,Stroustrup写了一本。这本书是 "The Design and Evolution of C++"  (Addison-Wesley, 1994),有时简称为 "D&E"。读了它,你会了解到有哪些特性被增加到C++中,以什么顺序,以及为什么。你还会知道哪些特性被放弃了,以及为什么。你甚至可以了解到一些幕后故事,如dynamic_cast(参见条款39和M2)如何被考虑,被放弃,又被考虑,最后被接受 ---- 以及为什么。如果你理解C++有困难,D&E将为你驱散心头的疑云。

对于C++如何成为现在的样子,"The Design and Evolution of C++" 提供了丰富的资料和见解,但它绝对不是正式的语言规格说明。对此你得求助于C++国际标准,一本令人印象深刻的长达700多页的正式文本。在那儿你可以读到象下面这样刻板的句子:

一个虚函数调用所使用的缺省参数是表示对象的指针或引用的静态类型所决定的虚函数所声明的缺省参数。派生类中的重载函数不获取它重载的函数中的缺省值。

这段话是条款38("决不要重新定义继承而来的缺省参数值")的基础,但我期望我对这个论题的论述比上面的原文多少更让人容易理解一些。

C++标准不是临睡前的休闲读物,而是你最好的依靠 ---- 你的 "标准" 依靠 ---- 如果你和其他人(比如,编译器供货商,或采用其它工具编程的开发人员)对什么东西是或不是C++有分歧的话。标准的全部目的在于,为解决这类争议提供权威信息。

C++标准的官方名称很咬口,但如果你需要知道,就得知道。这就是:International Standard for Information Systems----Programming Language C++。它由International Organization for Standardization (ISO)第21工作组颁布。(如果你爱钻牛角尖,它实际上是由ISO/IEC JTC1/SC22/WG21颁布的----我没有添油加醋)你可以从你的国家标准机构(在美国,是ANSI,即American National Standards Institute)定购正式C++标准的副本,但C++标准的最新草稿副本 ---- 和最终文件十分相近(虽然不完全一样)---- 在互联网上是免费提供的。可以找到它的一个好地方是 "the Cygnus Solutions Draft Standard C++ Page" (http://www.cygnus.com/misc/wp/),互联网上变化速度很快,如果你发现这个网站不能连接也不要奇怪。如果是这样,搜索引擎一定会帮你找到一个正确的URL。

我说过,"The Design and Evolution of C++" 对于了解C++语言的设计思想很有好处,C++标准则明确了语言的具体细节;如果在 "D&E千里之外的视野" 和 "C++标准的微观世界" 之间存在承上启下的桥梁那就太好了。教程应当适合于这个角色,但它们的视角往往偏向于标准,更侧重于说明什么是语言,而没有解释为什么。

进入ARM吧。ARM是另一本书,"The Annotated C++ Reference Manual" (Addison-Wesley, 1990),作者是Margaret Ellis和Bjarne Stroustrup。这本书一出版就成为了C++的权威,国际标准就是基于ARM(和已有的C标准)开始制定的。这几年间,C++标准和ARM中的说明在某些方面有分歧,所以ARM不再象过去那样具有权威性了。但它还是很具参考价值,因为它所说的大多数还是正确的;所以,在C++领域中,有些厂家还是坚持采用ARM规范,这并不少见,毕竟,标准只是最近才定下来。

然而,使得ARM真正有用的不是它的RM部分(the Reference Manual),而是A部分(the annotations):注释。针对C++的很多特性 "为什么" 要象现在这样工作,ARM提供了全面的解释。这些解释D&E中也有一些,但大多数没有,你确实需要了解它们。例如,第一次碰到下面这段代码,大部分人会为它发疯:

class Base {
public:
  virtual void f(int x);
};

class Derived: public Base {
public:
  virtual void f(double *pd);
};

Derived *pd = new Derived;
pd->f(10);                            // 错误!

问题在于Derived::f隐藏了Base::f,即使它们取的是不同的参数类型;所以编译器要求对f的调用取一个double*,而10这个数字当然不行。

这不很合理,但ARM对这种行为提供了解释。假设调用f时,你真的是想调用Derived中的版本,但不小心用错了参数类型。进一步假设Derived是在继承层次结构的下层,你不知道Derived间接继承了某个基类BaseClass,而且BaseClass中声明了一个带int参数的虚函数f。这种情况下,你就会无意中调用了BaseClass::f,一个你甚至不知道它存在的函数!在使用大型类层次结构的情况下,这种错误会时常发生;所以,为了防患于未然,Stroustrup决定让派生类成员按名字隐藏掉基类成员。

顺便指出,如果想让Derived的用户可以访问Base::f,可以很容易地通过一个using声明来完成:

class Derived: public Base {
public:
  using Base::f;                   // 将Base::f引入到
                                   // Derived的空间范围
  virtual void f(double *pd);
};

Derived *pd = new Derived;
pd->f(10);                         // 正确,调用Base::f

对于尚不支持using声明的编译器,另一个选择是采用内联函数:

class Derived: public Base {
public:
  virtual void f(int x) { Base::f(x); }
  virtual void f(double *pd);
};

Derived *pd = new Derived;
pd->f(10);                 // 正确,调用Derived::f(int),
                           // 间接调用了Base::f(int)

借助于D&E和ARM,你会对C++的设计和实现获得透彻理解,从而可能参悟到:有时候,看似巴洛克风格的建筑外观之后,是合理严肃的结构设计。(译注:巴洛克风格的建筑极尽富丽堂皇、粉装玉琢,因而结构复杂,甚至有点怪异)将这些理解和C++标准的具体细节结合起来,你就矗立于软件开发的坚实基础之上,从而走向真正有效的C++程序设计之路。

关于本电子书

制作本书的目的是为了方便大家的阅读。

我们要吐血感谢lostmouse为我们翻译了这么好的C++名著,希望大家多多支持他,并希望他能继续给我们带来更多更好的文章。

为了加强本书的完整性,特将侯老师翻译的前言和导读也加了近来。其余均为lostmouse翻译。

 

 

 

 

制作: lians
2001/08/21
修订:
save
2001/08/31

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值