本篇之前的内容都是基础中的基础,理论上只需前面所说的内容即可编写出几乎任何只操作内存的程序,也就是本篇以后说明的内容都可以使用之前的内容自己实现,只不过相对要麻烦和复杂许多罢了。
本篇开始要比较深入地讨论C++提出的很有意义的功能,它们大多数和前面的switch语句一样,是一种技术的实现,但更为重要的是提供了语义的概念。所以,本篇开始将主要从它们提供的语义这方面来说明各自的用途,而不像之前通过实现原理来说明(不过还是会说明一下实现原理的)。为了能清楚说明这些功能,要求读者现在至少能使用VC来编译并生成一段程序,因为后续的许多例子都最好是能实际编译并观察执行结果以加深理解(尤其是声明和类型这两个概念)。为此,如果你现在还不会使用VC或其他编译器来进行编译代码,请先参看其他资料以了解如何使用VC进行编译。为了后续例子的说明,下面先说明一些预备知识。
预备知识
写出了C++代码,要如何让编译器编译?在文本文件中书写C++代码,然后将文本文件的文件名作为编译器的输入参数传递给编译器,即叫编译器编译给定文件名所对应的文件。在VC中,这些由VC这个编程环境(也就是一个软件,提供诸多方便软件开发的功能)帮我们做了,其通过项目(Project)来统一管理书写有C/C++代码的源文件。为了让VC能了解到哪些文件是源文件(因为还可能有资源文件等其他类型文件),在用文本编辑器书写了C++代码后,将其保存为扩展名为。c或。cpp(C Plus Plus)的文本文件,前者表示是C代码,而后者表示C++代码,则缺省情况下,VC就能根据不同的源文件而使用不同的编译语法来编译源文件。
前篇说过,C++中的每条语句都是从上朝下执行,每条语句都对应着一个地址,那么在源文件中的第一条语句对应的地址就是0吗?当然不是,和在栈上分配内存一样,只能得到相对偏移值,实际的物理地址由于不同的操作系统将会有各自不同的处理,如在Windows下,代码甚至可以没有物理地址,且代码对应的物理地址还能随时变化。
当要编写一个稍微正常点的程序时,就会发现一个源文件一般是不够的,需要使用多个源文件来写代码。而各源文件之间要如何连接起来?对此C++规定,凡是生成代码的语句都要放在函数中,而不能直接写在文本文件中。关于函数后面马上说明,现在只需知道函数相当于一个外壳,它通过一对“{}”将代码括起来,进而就将代码分成了一段一段,且每一段代码都由函数名这个项目内唯一的标识符来标识,因此要连接各段代码,只用通过函数名即可,后面说明。前面说的“生成代码”指的是表达式语句和指令语句,虽然定义语句也可能生成代码,但由于其代码生成的特殊性,是可以直接写在源文件内(在《C++从零开始(十)》中说明),即不用被一对“{}”括起来。
程序一开始要从哪里执行?C++强行规定,应该在源文件中定义一个名为main的函数,而代码就从这个函数处开始运行。应该注意由于C++是由编译器实现的,而它的这个规定非常的牵强,因此纵多的编译器都又自行提供了另外的程序入口点定义语法(程序入口点即最开始执行的函数),如VC,为了编写DLL文件,就不应有main函数;为了编写基于Win32的程序,就应该使用WinMain而不是main;而VC实际提供了更加灵活的手段,实际可以让程序从任何一个函数开始执行,而不一定非得是前面的WinMain、main等,这在《C++从零开始(十九)》中说明。
对于后面的说明,应知道程序从main函数开始运行,如下:
long a; void main(){ short b; b++; } long c;
上面实际先执行的是long a;和long c;,不过不用在意,实际有意义的语句是从short b;开始的。
函数(Function)
机器手焊接轿车车架上的焊接点,给出焊接点的三维坐标,机器手就通过控制各关节的马达来使焊枪移到准确的位置。这里控制焊枪移动的程序一旦编好,以后要求机器手焊接车架上的200个点,就可以简单地给出200个点的坐标,然后调用前面已经编好的移动程序200次就行了,而不用再对每次移动重复编写代码。上面的移动程序就可以用一个函数来表示。
函数是一个映射元素。其和变量一样,将一个标识符(即函数名)和一个地址关联起来,且也有一类型和其关联,称作函数的返回类型。函数和变量不同的就是函数关联的地址一定是代码的地址,就好像前面说明的标号一样,但和标号不同的就是,C++将函数定义为一种类型,而标号则只是纯粹的二进制数,即函数名对应的地址可以被类型修饰符修饰以使得编译器能生成正确的代码来帮助程序员书实现上面的功能。
由于定义函数时编译器并不会分配内存,因此引用修饰符“&”不再其作用,同样,由数组修饰符“[]”的定义也能知道其不能作用于函数上面,只有留下的指针修饰符“*”可以,因为函数名对应的是某种函数类型的地址类型的数字。
前面移动程序之所以能被不同地调用200次,是因为其写得很灵活,能根据不同的情况(不同位置的点)来改变自己的运行效果。为了向移动程序传递用于说明情况的信息(即点的坐标),必须有东西来完成这件事,在C++中,这使用参数来实现,并对于此,C++专门提供了一种类型修饰符——函数修饰符“()”。在说明函数修饰符之前,让我们先来了解何谓抽象声明符(Abstract Declarator)。
声明一个变量long a;(这看起来和定义变量一样,后面将说明它们的区别),其中的long是类型,用于修饰此变量名a所对应的地址。将声明变量时(即前面的写法)的变量名去掉后剩下的东西称作抽象声明符。比如:long *a, &b = *a, c[10], ( *d )[10];,则变量a、b、c、d所对应的声明修饰符分别是long*、long&、long[10]、long(*)[10].函数修饰符接在函数名的后面,括号内接零个或多个抽象声明符以表示参数的类型,中间用“,”隔开。而参数就是一些内存(分别由参数名映射),用于传递一些必要的信息给函数名对应的地址处的代码以实现相应的功能。声明一个函数如下:
long *ABC( long*, long&, long[10], long(*)[10] );
上面就声明了一个函数ABC,其类型为long*( long*, long&, long[10], long(*)[10] ),表示欲执行此函数对应地址处开始的代码,需要顺序提供4个参数,类型如上,返回值类型为long*.上面ABC的类型其实就是一个抽象声明符,因此也可如下:
long AB( long*( long*, long&, long[10], long(*)[10] ), short, long& );
对于前面的移动程序,就可类似如下声明它:
void Move( float x, float y, float z );
上面在书写声明修饰符时又加上了参数名,以表示对应参数的映射。不过由于这里是函数的声明,上述参数名实际不产生任何映射,因为这是函数的声明,不是定义(关于声明,后面将说明)。而这里写上参数名是一种语义的体现,表示第一、二、三个参数分别代表X、Y、Z坐标值。
上面的返回类型为void,前面提过,void是C++提供的一种特殊数字类型,其仅仅只是为了保障语法的严密性而已,即任何函数执行后都要返回一个数字(后面将说明),而对于不用返回数字的函数,则可以定义返回类型为void,这样就可以保证语法的严密性。应当注意,任何类型的数字都可以转换成void类型,即可以( void )( 234 );或void( a );。
注意上面函数修饰符中可以一个抽象修饰符都没有,即void ABC();。它等效于void ABC( void );,表示ABC这个函数没有参数且不返回值。则它们的抽象声明符为void()或void(void),进而可以如下:
long* ABC( long*(), long(), long[10] );
由函数修饰符的意义即可看出其和引用修饰符一样,不能重复修饰类型,即不能void A()(long);,这是无意义的。同样,由于类型修饰符从左朝右的修饰顺序,也就很正常地有:void(*pA)()。假设这里是一个变量定义语句(也可以看成是一声明语句,后面说明),则表示要求编译器在栈上分配一块4字节的空间,将此地址和pA映射起来,其类型为没有参数,返回值类型为void的函数的指针。有什么用?以后将说明。
函数定义
下面先看下函数定义,对于前面的机器手控制程序,可如下书写:
void Move( float x, float y, float z ) { float temp; // 根据x、y、z的值来移动焊枪 } int main() { float x[200], y[200], z[200]; // 将200个点的坐标放到数组x、y和z中 for( unsigned i = 0; i < 200; i++ ) Move( x[ i ], y[ i ], z[ i ] ); return 0; } |
上面定义了一个函数Move,其对应的地址为定义语句float temp;所在的地址,但实际由于编译器要帮我们生成一些附加代码(称作函数前缀——Prolog,在《C++从零开始(十五)》中说明)以获得参数的值或其他工作(如异常的处理等),因此Move将对应在较float temp;之前的某个地址。Move后接的类型修饰符较之前有点变化,只是把变量名加上以使其不是抽象声明符而已,其作用就是让编译器生成一映射,将加上的变量名和传递相应信息的内存的地址绑定起来,也就形成了所谓的参数。也由于此原因,就能如此书写:void Move( float x, float, float z ) { }.由于没有给第二个参数绑定变量名,因此将无法使用第二个参数,以后将举例说明这样的意义。
函数的定义就和前面的函数的声明一样,只不过必须紧接其后书写一个复合语句(必须是复合语句,即用“{}”括起来的语句),此复合语句的地址将和此函数名绑定,但由于前面提到的函数前缀,函数名实际对应的地址在复合语句的地址的前面。
为了调用给定函数,C++提供了函数操作符“()”,其前面接函数类型的数字,而中间根据相应函数的参数类型和个数,放相应类型的数字和个数,因此上面的Move( x[ i ], y[ i ], z[ i ] );就是使用了函数操作符,用x[ i ]、y[ i ]、z[ i ]的值作为参数,并记录下当前所在位置的地址,跳转到Move所对应的地址继续执行,当从Move返回时,根据之前记录的位置跳转到函数调用处的地方,继续后继代码的执行。
函数操作符由于是操作符,因此也要返回数字,也就是函数的返回值,即可以如下:
float AB( float x ) { return x * x; } int main() { float c = AB( 10 ); return 0; }
先定义了函数AB,其返回float类型的数字,其中的return语句就是用于指明函数的返回值,其后接的数字就必须是对应函数的返回值类型,而当返回类型为void时,可直接书写return;。因此上面的c的值为100,函数操作符返回的值为AB函数中的表达式x * x返回的数字,而AB( 10 )将10作为AB函数的参数x的值,故x * x返回100.
由于之前也说明了函数可以有指针,将函数和变量对比,则直接书写函数名,如:AB;。上面将返回AB对应的地址类型的数字,然后计算此地址类型数字,应该是以函数类型解释相应地址对应的内存的内容,考虑函数的意义,将发现这是毫无意义的,因此其不做任何事,直接返回此地址类型的数字对应的二进制数,也就相当于前面说的指针类型。因此也就可以如下:
int main() { float (*pAB)( float ) = AB; float c = ( *pAB )( 10 ); return 0; }
上面就定义了一个指针pAB,其类型为float(*)( float ),一开始将AB对应的地址赋值给它。为什么没有写成pAB = &AB;而是pAB = AB;?因为前面已经说了,函数类型的地址类型的数字,将不做任何事,其效果和指针类型的数字一样,因此pAB = AB;没有问题,而pAB = &AB;就更没有问题了。可以认为函数类型的地址类型的数字编译器会隐式转换成指针类型的数字,因此既可以( *pAB )( 10 );,也能( *AB )( 10 );,因为后者编译器进行了隐式类型转换。
由于函数操作符中接的是数字,因此也可以float c = AB( AB( 10 ) );,即c为10000.还应注意函数操作符让编译器生成一些代码来传递参数的值和跳转到相应的地址去继续执行代码,因此如下是可以的:
long AB( long x ) { if( x > 1 ) return x * AB( x - 1 ); else return 1; }
上面表示当参数x的值大于1时,将x - 1返回的数字作为参数,然后跳转到AB对应的地址处,也就是if( x > 1 )所对应的地址,重复运行。因此如果long c = AB( 5 );,则c为5的阶乘。上面如果不能理解,将在后面说明异常的时候详细说明函数是如何实现的,以及所谓的堆栈溢出问题。
现在应该了解main函数的意义了,其只是建立一个映射,好让连接器制定程序的入口地址,即main函数对应的地址。上面函数Move在函数main之前定义,如果将Move的定义移到main的下面,上面将发生错误,说函数Move没定义过,为什么?因为编译器只从上朝下进行编译,且只编译一次。那上面的问题怎么办?后面说明。