4.3 基本对象
C++的第一步正是这样,函数可以放在struct内部,作为“成员函数”。CStash的C版本翻译成C++的Stash后是:
首先,注意到这里没有typedef,而是要求程序员创建一个typedef。C++编译器把结构名转变为这个程序的新类型名(就像int、char、float和double是类型名一样)。
所有的数据成员与以前完全相同,但现在这些函数在struct的内部了。另外,注意到,对应于这个库中的C版本中第一个参数已经去掉了。在C++中,不是硬性传递这个结构的地址作为在这个结构上运算的所有函数的第一个参数,而是编译器秘密地做这件事。现在,这些函数的仅有的参数与它们所做的事情有关,而不与这些函数的运算机制有关。
认识到这些函数代码与在C库中的那些同样有效,是很重要的。参数的个数是相同的(虽然看不到这个结构地址被传进来,实际上它在这里),每个函数只有一个函数体。正因为如此,书写
并不意味着每个变量得到不同的add()函数。
那样产生的代码几乎和为C库写的一样。更有趣的是,这包括了“名字修饰”,在C中也许应当像Stash_initialize()、Stash_cleanup()等这样修饰。当函数在struct内时,编译器有效地做了同样的事情。因此,在Stash内部的initialize()将不会与任何其他结构中的initialize()相冲突,即便是与全局函数名initialize(),也不会冲突。大部分时间都不必为函数名字修饰而担心—而是使用未修饰的函数名。但有时还必须能够指出这个initialize()属于这个struct Stash而不属于任何其他的struct。特别是,当正在定义这个函数时,需要完全指定它是哪一个。为了完成这个指定任务,C++有一个新的运算符(:),即作用域解析运算符(这样命名是因为名字现在能在不同的范围内:在全局范围内或在一个struct的范围内)。例如,如果希望指定initialize()属于Stash,就写Stash:initialize(int size)。可以看到,在下面函数定义中是如何使用作用域运算符的:
在C和C++之间有以下不同:首先,头文件中的声明是由编译器要求的。在C++中,不能调用未事先声明的函数,否则编译器将报告一个出错信息。这是确保这些函数调用在被调用点和被定义点之间一致的重要方法。通过强迫在调用函数之前必须声明它,C++编译器可以保证我们用包含这个头文件的方式完成这个声明。如果在这个函数被定义的地方还包含有同样的头文件,则编译器将作一些检查以保证在这个头文件中的声明和这个定义匹配。这意味着,这个头文件变成了函数声明的有效的仓库,并且保证这些函数在项目中的所有处理单元中使用一致。
当然,全局函数仍然可以在定义和使用它的每个地方用手工方式声明(这是很乏味的,以致变得不太可能)。然而,必须在定义和使用之前声明结构,而最习惯放置结构定义的位置是在头文件中,除非有意把它藏在代码文件中。
可以看到,除了作用域和来自这个库的C版本的第一个参数不再是显式的这一事实以外,所有这些成员函数实际上都与C版本中的一样。当然,这个参数仍然存在,因为这个函数必须工作在一个特定的struct变量上。但是,在成员函数内部,成员照常使用。这样,不写s->size=sz,而写size=sz。这就消除了多余的s->,它对我们所做的任何事情不能添加任何含义。当然,C++编译器必须为我们做这些事情。实际上,它取“秘密”的第一个参数(也就是先前用手工传递的这个结构的地址),并且当提到struct的数据成员的任何时候,应用成员选择器。这意味着,当在另一个struct的成员函数中时,通过简单地给出成员的名字,就可以使用任何成员(包括其他成员函数)。编译器在找出这个名字的全局版本之前先在局部结构的名字中搜索。这个性能意味着不仅代码更容易写,而且更容易阅读。
但是,如果因为某种原因,我们希望能够处理这个结构的地址,情况会怎么样呢?在这个库的C版本中,这是很容易的,因为每个函数的第一个参数是叫做s的一个CStash*。在C++中,事情是更一致的。这里有一个特殊的关键字,称为this,它产生这个struct的地址。它等价于这个库的C版本的‘s’。所以可以用下面语句恢复成C风格。
对这种书写形式进行编译所产生的代码是完全一样的,因此不需要像这样的方式使用this。有时,我们会看到有人在代码的各处都明显地使用this->,但是,这不能对代码增加任何意义。通常,不经常用this,而只是需要时才使用(稍后,本书中将有一些使用this的例子)。
最后需要提到,在C中,可以赋void*给任何指针,例如:
而且编译器能够通过。但在C++中,这个语句是不允许的。为什么呢?因为C对类型信息不挑剔,所以它允许未明确类型的指针赋给一个明确类型的指针。而C++则不同。类型在C++中是严格的,当类型信息有任何违例时,编译器就不允许。这一点一直是很重要的,而对于C++尤其重要,因为在struct中有成员函数。如果能够在C++中向struct传递指针而不被阻止,那么就能最终调用对于struct逻辑上并不存在的成员函数。这是防止灾难的一个实际的办法。因此,C++允许将任何类型的指针赋给void(这是void的最初的意图,它需要足够大,以存放任何类型的指针),但不允许将void指针赋给任何其他类型的指针。一个类型转换总是需要告诉读者和编译器,我们实际上要把它作为目标类型处理。
这就带来了一个有趣的问题,C++的最重要的目的之一是能编译尽可能多的已存在的C代码,以便能容易地向这个新语言过渡。然而,这并不意味着C允许的任何代码都能自动地被C++接受。有一些C编译器允许的东西是危险的和易出错的(本书中还会看到它们)。C++编译器对于这些情况产生警告和出错信息,其优点远大于缺点。实际上,在C中有许多我们知道有错误只是不能找出它的情况,但是一旦用C++重编译这个程序,编译器就能指出这些问题。在C中,我们常常发现能使程序通过编译,然后我们必须再花力气使它工作。在C++中,常常是,程序编译正确了,它也就能工作了。这是因为该语言对类型要求更严格的缘故。
在下面的测试程序中,可以看到Stash的C++版本所使用的另一些东西。
我们可以注意到,变量是实时(on the fly)定义的(第3章介绍过)。也就是说,它们能在作用域内的任何点上定义,而不是像C语言限制的那样,只能在作用域的开头部分。
这段代码和CLibTest.cpp相似,但在调用成员函数时,在函数名字之前使用成员选择运算符“.”。这是一个传统的文法,它模仿了结构数据成员的使用。它们的不同在于这里是函数成员,有一个参数表。
当然,该编译器实际产生的调用,看上去更像原来的C库函数。如果考虑名字修饰和this传递,C++函数调用intStash.initialize(sizeof(int),100)就和Stash_initialize(&intStash, sizeof(int),100)一样了。如果想知道在内部所进行的工作,可以回忆最早的C++编译器cfront,它由AT&T开发,它输出的是C代码,然后再由C编译器编译。这个方法意味着cfront能使C++很快地移植到有C编译器的机器上,有助于快速地传播C++编译器技术。正因为这个C++编译器必须产生C,所以我们知道必然有方法用C语言描述C++文法(某些编译器仍然允许产生C代码)。
ClibTest. cpp的另一个改变是引入require.h头文件,这是为这本书创造的头文件,用来完成比assert()更复杂的错误检查任务。它包含了几个函数,其中一个就是在这里为了检查文件而使用的assure()。它检查这个文件是否已经成功地打开了,如果没有,它就报告一个标准错误,告诉这个文件不能打开并且退出程序(这样,它就需要文件名作为第二个参数)。require.h函数在全书中都会用到,特别是为了保证命令行参数的个数正确和保证文件确实打开了。require.h取代了不断重复的和分散进行的检查出错代码,并且提供非常有用的出错信息。这些函数将在本书的后面解释。