C++从零开始(七)
——何谓函数
本篇之前的内容都是基础中的基础,理论上只需前面所说的内容即可编写出几乎任何只操作内存的程序,也就是本篇以后说明的内容都可以使用之前的内容自己实现,只不过相对要麻烦和复杂许多罢了。
本篇开始要比较深入地讨论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没定义过,为什么?因为编译器只从上朝下进行编译,且只编译一次。那上面的问题怎么办?后面说明。
重载函数
前面的移动函数,如果只想移动X和Y坐标,为了不移动Z坐标,就必须如下再编写一个函数:
void Move2( float x, float y );
它为了不和前面的Move函数的名字冲突而改成Move2,但Move2也表示移动,却非要变一个名字,这严重地影响语义。为了更好的从源代码上表现出语义,即这段代码的意义,C++提出了重载函数的概念。
重载函数表示函数名字一样,但参数类型及个数不同的多个函数。如下:
void Move( float x, float y, float z ) { }和void Move( float x, float y ) { }
上面就定义了两个重载函数,虽然函数名相同,但实际为两个函数,函数名相同表示它们具有同样的语义——移动焊枪的程序,只是移动方式不同,前者在三维空间中移动,后者在一水平面上移动。当Move( 12, 43 );时就调用后者,而Move( 23, 5, 12 );时就调用前者。不过必须是参数的不同,不能是返回值的不同,即如下将会报错:
float Move( float x, float y ) { return 0; }和void Move( float a, float b ) { }
上面虽然返回值不同,但编译器依旧认为上面定义的函数是同一个,则将说函数重复定义。为什么?因为在书写函数操作符时,函数的返回值类型不能保证获得,即float a = Move( 1, 2 );虽然可以推出应该是前者,但也可以Move( 1, 2 );,这样将无法得知应该使用哪个函数,因此不行。还应注意上面的参数名字虽然不同,但都是一样的,参数名字只是表示在那个函数的作用域内其映射的地址,后面将说明。改成如下就没有问题:
float Move( float x, float y ) { return 0; }和void Move( float a, float b, float c ) { }
还应注意下面的问题:
float Move( float x, char y ); float Move( float a, short b ); Move( 10, 270 );
上面编译器将报错,因为这里的270在计算函数操作符时将被认为是int,即整型,它即可以转成char,也可以转成short,结果编译器将无法判断应是哪一个函数。为此,应该Move( 10, ( char )270 );。
声明和定义
声明是告诉编译器一些信息,以协助编译器进行语法分析,避免编译器报错。而定义是告诉编译器生成一些代码,并且这些代码将由连接器使用。即:声明是给编译器用的,定义是给连接器用的。这个说明显得很模糊,为什么非要弄个声明和定义在这搅和?那都是因为C++同意将程序拆成几段分别书写在不同文件中以及上面提到的编译器只从上朝下编译且对每个文件仅编译一次。
编译器编译程序时,只会一个一个源文件编译,并分别生成相应的中间文件(对VC就是.obj文件),然后再由连接器统一将所有的中间文件连接形成一个可执行文件。问题就是编译器在编译a.cpp文件时,发现定义语句而定义了变量a和b,但在编译b.cpp时,发现使用a和b的代码,如a++;,则编译器将报错。为什么?如果不报错,说因为a.cpp中已经定义了,那么先编译b.cpp再编译a.cpp将如何?如果源文件的编译顺序是特定的,将大大降低编译的灵活性,因此C++也就规定:编译a.cpp时定义的所有东西(变量、函数等)在编译b.cpp时将全部不算数,就和没编译过a.cpp一样。那么b.cpp要使用a.cpp中定义的变量怎么办?为此,C++提出了声明这个概念。
因此变量声明long a;就是告诉编译器已经有这么个变量,其名字为a,其类型为long,其对应的地址不知道,但可以先作个记号,即在后续代码中所有用到这个变量的地方做上记号,以告知连接器在连接时,先在所有的中间文件里寻找是否有个叫a的变量,其地址是多少,然后再修改所有作了记号的地方,将a对应的地址放进去。这样就实现了这个文件使用另一个文件中定义的变量。
所以声明long a;就是要告诉编译器已经有这么个变量a,因此后续代码中用到a时,不要报错说a未定义。函数也是如此,但是有个问题就是函数声明和函数定义很容易区别,因为函数定义后一定接一复合语句,但是变量定义和变量声明就一模一样,那么编译器将如何识别变量定义和变量声明?编译器遇到long a;时,统一将其认为是变量定义,为了能标识变量声明,可借助C++提出的修饰符extern。
修饰符就是声明或定义语句中使用的用以修饰此声明或定义来向编译器提供一定的信息,其总是接在声明或定义语句的前面或后面,如:
extern long a, *pA, &ra;
上面就声明(不是定义)了三个变量a、pA和ra。因为extern表示外部的意思,因此上面就被认为是告诉编译器有三个外部的变量,为a、pA和ra,故被认为是声明语句,所以上面将不分配任何内存。同样,对于函数,它也是一样的:
extern void ABC( long ); 或 extern long AB( short b );
上面的extern等同于不写,因为编译器根据最后的“;”就可以判断出来上面是函数声明,而且提供的“外部”这个信息对于函数来说没有意义,编译器将不予理会。extern实际还指定其后修饰的标识符的修饰方式,实际应为extern"C"或extern"C++",分别表示按照C语言风格和C++语言风格来解析声明的标识符。
C++是强类型语言,即其要求很严格的类型匹配原则,进而才能实现前面说的函数重载功能。即之所以能几个同名函数实现重载,是因为它们实际并不同名,而由各自的参数类型及个数进行了修饰而变得不同。如void ABC(), *ABC( long ), ABC( long, short );,在VC中,其各自名字将分别被变成“?ABC@@YAXXZ”、“?ABC@@YAPAXJ@Z”、“?ABC@@YAXJF@Z”。而extern long a, *pA, &ra;声明的三个变量的名字也发生相应的变化,分别为“?a@@3JA”、“?pA@@3PAJA”、“?ra@@3AAJA”。上面称作C++语言风格的标识符修饰(不同的编译器修饰格式可能不同),而C语言风格的标识符修饰就只是简单的在标识符前加上“_”即可(不同的编译器的C风格修饰一定相同)。如:extern"C" long a, *pA, &ra;就变成_a、_pA、_ra。而上面的extern"C" void ABC(), *ABC( long ), ABC( long, short );将报错,因为使用C风格,都只是在函数名前加一下划线,则将产生3个相同的符号(Symbol),错误。
为什么不能有相同的符号?为什么要改变标识符?不仅因为前面的函数重载。符号和标识符不同,符号可以由任意字符组成,它是编译器和连接器之间沟通的手段,而标识符只是在C++语言级上提供的一种标识手段。而之所以要改变一下标识符而不直接将标识符作为符号使用是因为编译器自己内部和连接器之间还有一些信息需要传递,这些信息就需要符号来标识,由于可能用户写的标识符正好和编译器内部自己用的符号相同而产生冲突,所以都要在程序员定义的标识符上面修改后再用作符号。既然符号是什么字符都可以,那为什么编译器不让自己内部定的符号使用标识符不能使用的字符,如前面VC使用的“?”,那不就行了?因为有些C/C++编译器及连接器沟通用的符号并不是什么字符都可以,也必须是一个标识符,所以前面的C语言风格才统一加上“_”的前缀以区分程序员定义的符号和编译器内部的符号。即上面能使用“?”来作为符号是VC才这样,也许其它的编译器并不支持,但其它的编译器一定支持加了“_”前缀的标识符。这样可以联合使用多方代码,以在更大范围上实现代码重用,在《C++从零开始(十八)》中将对此详细说明。
当书写extern void ABC( long );时,是extern"C"还是extern"C++"?在VC中,如果上句代码所在源文件的扩展名为.cpp以表示是C++源代码,则将解释成后者。如果是.c,则将解释成前者。不过在VC中还可以通过修改项目选项来改变上面的默认设置。而extern long a;也和上面是同样的。
因此如下:
extern"C++" void ABC(), *ABC( long ), ABC( long, short );
int main(){ ABC(); }
上面第一句就告诉编译器后续代码可能要用到这个三个函数,叫编译器不要报错。假设上面程序放在一个VC项目下的a.cpp中,编译a.cpp将不会出现任何错误。但当连接时,编译器就会说符号“?ABC@@YAXXZ”没找到,因为这个项目只包含了一个文件,连接也就只连接相应的a.obj以及其他的一些必要库文件(后续文章将会说明)。连接器在它所能连接的所有对象文件(a.obj)以及库文件中查找符号“?ABC@@YAXXZ”对应的地址是什么,不过都没找到,故报错。换句话说就是main函数使用了在a.cpp以外定义的函数void ABC();,但没找到这个函数的定义。应注意,如果写成int main() { void ( *pA ) = ABC; }依旧会报错,因为ABC就相当于一个地址,这里又要求计算此地址的值(即使并不使用pA),故同样报错。
为了消除上面的错误,就应该定义函数void ABC();,既可以在a.cpp中,如main函数的后面,也可以重新生成一个.cpp文件,加入到项目中,在那个.cpp文件中定义函数ABC。因此如下即可:
extern"C++" void ABC(), *ABC( long ), ABC( long, short );
int main(){ ABC(); } void ABC(){}
如果你认为自己已经了解了声明和定义的区别,并且清楚了声明的意思,那我打赌有50%的可能性你并没有真正理解声明的含义,这里出于篇幅限制,将在《C++从零开始(十)》中说明声明的真正含义,如果你是有些C/C++编程经验的人,到时给出的样例应该有50%的可能性会令你大吃一惊。
调用规则
调用规则指函数的参数如何传递,返回值如何传递,以及上述的函数名标识符如何修饰。其并不属于语言级的内容,因为其表示编译器如何实现函数,而关于如何实现,各编译器都有自己的处理方式。在VC中,其定义了三个类型修饰符用以告知编译器如何实现函数,分别为:__cdecl、__stdcall和__fastcall。三种各有不同的参数、函数返回值传递方式及函数名修饰方式,后面说明异常时,在说明了函数的具体实现方式后再一一解释。由于它们是类型修饰符,则可如下修饰函数:
void *__stdcall ABC( long ), __fastcall DE(), *( __stdcall *pAB )( long ) = &ABC;
void ( __fastcall *pDE )() = DE;
变量的作用域
前面定义函数Move时,就说void Move( float a, float b );和void Move( float x, float y );是一样的,即变量名a和b在这没什么意义。这也就是说变量a、b的作用范围只限制在前面的Move的函数体(即函数定义时的复合语句)内,同样x和y的有效范围也只在后面的Move的函数体内。这被称作变量的作用域。
//////a.cpp//////
long e = 10;
void main()
{
short a = 10;
e++;
{
long e = 2;
e++;
a++;
}
e++;
}
上面的第一个e的有效范围是整个a.cpp文件内,而a的有效范围是main函数内,而main函数中的e的有效范围则是括着它的那对“{}”以内。即上面到最后执行完e++;后,long e = 2;定义的变量e已经不在了,也就是被释放了。而long e = 10;定义的e的值为12,a的值为11。
也就是说“{}”可以一层层嵌套包含,没一层“{}”就产生了一个作用域,在这对“{}”中定义的变量只在这对“{}”中有效,出了这对“{}”就无效了,等同于没定义过。
为什么要这样弄?那是为了更好的体现出语义。一层“{}”就表示一个阶段,在执行这个阶段时可能会需要到和前面的阶段具有相同语义的变量,如排序。还有某些变量只在某一阶段有用,过了这个阶段就没有意义了,下面举个例子:
float a[10];
// 赋值数组a
for( unsigned i = 0; i < 10; i++ )
for( unsigned j = 0; j < 10; j++ )
if( a[ i ] < a[ j ] )
{
float temp = a[ i ];
a[ i ] = a[ j ];
a[ j ] = temp;
}
上面的temp被称作临时变量,其作用域就只在if( a[ i ] < a[ j ] )后的大括号内,因为那表示一个阶段,程序已经进入交换数组元素的阶段,而只有在交换元素时temp在有意义,用于辅助元素的交换。如果一开始就定义了temp,则表示temp在数组元素寻找期间也有效,这从语义上说是不对的,虽然一开始就定义对结果不会产生任何影响,但应不断地询问自己——这句代码能不能不要?这句代码的意义是什么?不过由于作用域的关系而可能产生性能影响,这在《C++从零开始(十)》中说明。
下篇将举例说明如何已知算法而写出C++代码,帮助读者做到程序员的最基本的要求——给得出算法,拿得出代码。
本文地址:http://com.8s8s.com/it/it25042.htm