C++函数声明和定义深度解析(转)

概述:

声明是将一个名称引入一个程序.

定义提供了一个实体在程序中的唯一描述.

声明在单个作用域内可以重复多次(类成员除外),定义在一个给定的作用域内只能出现一次. 一个定义就是一个声明,除非:

  • 它定义了类的一个静态数据成员.
  • 它定义了类的非内联成员函数.

 

声明是告诉编译器一些信息,以协助编译器进行语法分析,避免编译器报错。而定义是告诉编译器生成一些代码,并且这些代码将由连接器使用。即:声明是给编译器用的,定义是给连接器用的。这个说明显得很模糊,为什么非要弄个声明和定义在这搅和?那都是因为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%的可能性会令你大吃一惊。

声明的含义 
     前面已经解释过声明是什么意思,在此由于成员函数的定义规则这种新的定义语法,必须重新考虑声明的意思。注意一点,前面将一个函数的定义放到main函数定义的前面就可以不用再声明那个函数了;同样如果定义了某个变量,就不用再声明那个变量了。这也就是说定义语句具有声明的功能,但上面成员函数的定义语句却不具有声明的功能,下面来了解声明的真正意思。 
     声明是要求编译器产生映射元素的语句。所谓的映射元素,就是前面介绍过的变量及函数,都只有3栏(或3个字段):类型栏、名字栏和地址栏(成员变量类型的这一栏就放偏移值)。即编译器每当看到声明语句,就生成一个映射元素,并且将对应的地址栏空着,然后留下一些信息以告诉连接器——此.obj文件(编译器编译源文件后生成的文件,对于VC是.obj文件)需要一些符号,将这些符号找到后再修改并完善此.obj文件,最后连接。 
     回想之前说过的符号的意思,它就是一字符串,用于编译器和连接器之间的通信。注意符号没有类型,因为连接器只是负责查找符号并完善(因为有些映射元素的地址栏还是空的)中间文件(对于VC就是.obj文件),不进行语法分析,也就没有什么类型。 
     定义是要求编译器填充前面声明没有书写的地址栏。也就是说某变量对应的地址,只有在其定义时才知道。因此实际的在栈上分配内存等工作都是由变量的定义完成的,所以才有声明的变量并不分配内存。但应注意一个重点,定义是生成映射元素需要的地址,因此定义也就说明了它生成的是哪个映射元素的地址,而如果此时编译器的映射表(即之前说的编译器内部用于记录映射元素的变量表、函数表等)中没有那个映射元素,即还没有相应元素的声明出现过,那么编译器将报错。 
     但前面只写一个变量或函数定义语句,它照样正常并没有报错啊?实际很简单,只需要将声明和定义看成是一种语句,只不过是向编译器提供的信息不同罢了。如:void ABC( float );和void ABC( float ){},编译器对它们相同看待。前者给出了函数的类型及类型名,因此编译器就只填写映射元素中的名字和类型两栏。由于其后只接了个“;”,没有给出此函数映射的代码,因此编译器无法填写地址栏。而后者,给出了函数名、所属类型以及映射的代码(空的复合语句),因此编译器得到了所有要填写的信息进而将三栏的信息都填上了,结果就表现出定义语句完成了声明的功能。 
     对于变量,如long a;。同上,这里给出了类型和名字,因此编译器填写了类型和名字两栏。但变量对应的是栈上的某块内存的首地址,这个首地址无法从代码上表现出来(前面函数就通过在函数声明的后面写复合语句来表现相应函数对应的代码所在的地址),而必须由编译器内部通过计算获得,因此才硬性规定上面那样的书写算作变量的定义,而要变量的声明就需要在前面加extern。即上面那样将导致编译器进行内部计算进而得出相应的地址而填写了映射元素的所有信息。 
     上面难免显得故弄玄虚,那都是因为自定义类型的出现。考虑成员变量的定义,如: 
     struct ABC { long a, b; double c; }; 
     上面给出了类型——long ABC::、long ABC::和double ABC::;给出了名字——ABC::a、ABC::b和ABC::c;给出了地址(即偏移)——0、4和8,因为是结构型自定义类型,故由此语句就可以得出各成员变量的偏移。上面得出三个信息,即可以填写映射元素的所有信息,所以上面可以算作定义语句。对于成员函数,如下: 
     struct ABC { void AB( float ); }; 
     上面给出了类型——void ( ABC:: )( float );给出了名字——ABC::AB。不过由于没有给出地址,因此无法填写映射元素的所有信息,故上面是成员函数ABC::AB的声明。按照前面说法,只要给出地址就可以了,而无需去管它是定义还是声明,因此也就可以这样: 
     struct ABC { void AB( float ){} }; 
     上面给出类型和名字的同时,给出了地址,因此将可以完全填写映射元素的所有信息,是定义。上面的用法有其特殊性,后面说明。注意,如果这时再在后面写ABC::AB的定义语句,即如下,将错误: 
     struct ABC { void AB( float ){} }; 
     void ABC::AB( float ) {} 
     上面将报错,原因很简单,因为后者只是定义,它只提供了ABC::AB对应的地址这一个信息,但映射元素中的地址栏已经填写了,故编译器将说重复定义。再单独看成员函数的定义,它给出了类型void ( ABC:: )( float ),给出了名字ABC::AB,也给出了地址,但为什么说它只给出了地址这一信息?首先,名字ABC::AB是不符合标识符规则的,而类型修饰符ABC::必须通过类型定义符“{}”才能够加上去,这在前面已多次说明。因此上面给出的信息是:给出了一个地址,这个地址是类型为void ( ABC:: )( float ),名字为ABC::AB的映射元素的地址。结果编译器就查找这样的映射元素,如果有,则填写相应的地址栏,否则报错,即只写一个void ABC::AB( float ){}是错误的,在其前面必须先通过类型定义符“{}”声明相应的映射元素。这也就是前面说的定义仅仅填充地址栏,并不生成映射元素。 
声明的作用 
     定义的作用很明显了,有意义的映射(名字对地址)就是它来做,但声明有什么用?它只是生成类型对名字,为什么非得要类型对名字?它只是告诉编译器不要发出错误说变量或函数未定义?任何东西都有其存在的意义,先看下面这段代码。 
     extern"C" long ABC( long a, long b ); 
     void main(){ long c = ABC( 10, 20 ); } 
     假设上面代码在a.cpp中书写,编译生成文件a.obj,没有问题。但按照之前的说明,连接时将错误,因为找不到符号_ABC。因为名字_ABC对应的地址栏还空着。接着在VC中为a.cpp所在工程添加一个新的源文件b.cpp,如下书写代码。 
     extern"C" float ABC( float a ){ return a; } 
     编译并连接,现在没任何问题了,但相信你已经看出问题了——函数ABC的声明和定义的类型不匹配,却连接成功了? 
     注意上面关于连接的说明,连接时没有类型,只管符号。上面用extern"C"使得a.obj要求_ABC的符号,而b.cpp提供_ABC的符号,剩余的就只是连接器将b.obj中_ABC对应的地址放到a.obj以完善a.obj,最后连接a.obj和b.obj。 
     那么上面什么结果,由于需要考虑函数的实现细节,这在《C++从零开始(十五)》中再说明,而这里只要注意到一件事:编译器即使没有地址也依旧可以生成代码以实现函数操作符的功能——函数调用。之所以能这样就是因为声明时一定必须同时给出类型和名字,因为类型告诉编译器,当某个操作符涉及到某个映射元素时,如何生成代码来实现这个操作符的功能。也就是说,两个char类型的数字乘法和两个long类型的数字乘法编译生成的代码不同;对long ABC( long );的函数调用代码和void ABC( float )的不同。即,操作符作用的数字类型的不同将导致编译器生成的代码不同。 
     那么上面为什么要将ABC的定义放到b.cpp中?因为各源文件之间的编译是独立的,如果放在a.cpp,编译器就会发现已经有这么个映射元素,但类型却不匹配,将报错。而放到b.cpp中,使得由连接器来完善a.obj,到时将没有类型的存在,只管符号。下面继续。 
     struct ABC { long a, b; void AB( long tem1, long tem2 ); void ABCD(); }; 
     void main(){ ABC a; a.AB( 10, 20 ); } 
     由上面的说法,这里虽然没有给出ABC::AB的定义,但仍能编译成功,没有任何问题。仍假设上面代码在a.cpp中,然后添加b.cpp,在其中书写下面的代码。 
     struct ABC { float b, a; void AB( long tem1, long tem2 ); long ABCD( float ); }; 
     void ABC::AB( long tem1, long tem2 ){ a = tem1; b = tem2; } 
     这里定义了函数ABC::AB,注意如之前所说,由于这里的函数定义仅仅只是定义,所以必须在其前面书写类型定义符“{}”以让编译器生成映射元素。但更应该注意这里将成员变量的位置换了,这样b就映射的是0而a映射的是4了,并且还将a、b的类型换成了float,更和a.cpp中的定义大相径庭。但没有任何问题,编译连接成功,a.AB( 10,20 );执行后a.a为0X41A00000,a.b为0X41200000,而*( float* )&a.a为20,*( flaot* )&a.b为10。 
     为什么?因为编译器只在当前编译的那个源文件中遵循类型匹配,而编译另一个源文件时,编译其他源文件所生成的映射元素全部无效。因此声明将类型和名字绑定起来,而名字就代表了其所关联的类型的地址类型的数字,而后继代码中所有操作这个数字的操作符的编译生成都将受这个数字的类型的影响。即声明是告诉编译器如何生成代码的,其不仅仅只是个语法上说明变量或函数的语句,它是不可或缺的。 
     还应注意上面两个文件中的ABC::ABCD成员函数的声明不同,而且整个工程中(即a.cpp和b.cpp中)都没有ABC::ABCD的定义,却仍能编译连接成功,因为声明并不是告诉编译器已经有什么东西了,而是如何生成代码。 
头文件 
     上面已经说明,如果有个自定义类型ABC,在a.cpp、b.cpp和c.cpp中都要使用它,则必须在a.cpp、b.cpp和c.cpp中,各自使用ABC之前用类型定义符“{}”重新定义一遍这个自定义类型。如果不小心如上面那样在a.cpp和b.cpp中写的定义不一样,则将产生很难查找的错误。为此,C++提供了一个预编译指令来帮忙。 
     预编译指令就是在编译之前执行的指令,它由预编译器来解释执行。预编译器是另一个程序,一般情况,编译器厂商都将其合并进了C++编译器而只提供一个程序。在此说明预编译指令中的包含指令——#include,其格式为#include <文件名>。应注意预编译指令都必须单独占一行,而<文件名>就是一个用双引号或尖括号括起来的文件名,如:#include "abc.c"、#include "C:\abc.dsw"或#include 。它的作用很简单,就是将引号或尖括号中书写的文件名对应的文件以ANSI格式或MBCS格式(关于这两个格式可参考《C++从零开始(五)》)解释,并将内容原封不动地替换到#include所在的位置,比如下面是文件abc的内容。 
     struct ABC { long a, b; void AB( long tem1, long tem2 ); }; 
     则前面的a.cpp可改为: 
     #include "abc" 
     void main() { ABC a; a.AB( 10, 20 ); } 
     而b.cpp可改为: 
     #include "abc" 
     void ABC::AB( long tem1, long tem2 ){ a = tem1; b = tem2; } 
     这时,就不会出现类似上面那样在b.cpp中将自定义类型ABC的定义写错了而导致错误的结果(a.a为0X41A00000,a.b为0X41200000),进而a.AB( 10, 20 );执行后,a.a为10,a.b为20。 
     注意这里使用的是双引号来括住文件名的,它表示当括住的只是一个文件名或相对路径而没有给出全路径时,如上面的abc,则先搜索此时被编译的源文件所在的目录,然后搜索编译器自定的包含目录(如:C:\Program Files\Microsoft Visual Studio .NET 2003\Vc7\include等),里面一般都放着编译器自带的SDK的头文件(关于SDK,将在《C++从零开始(十八)》中说明),如果仍没有找到,则报错(注意,一般编译器都提供了一些选项以使得除了上述的目录外,还可以再搜索指定的目录,不同的编译器设定方式不同,在此不表)。 
     如果是用尖括号括起来,则表示先搜索编译器自定的包含目录,再源文件所在目录。为什么要不同?只是为了防止自己起的文件名正好和编译器的包含目录下的文件重名而发生冲突,因为一旦找到文件,将不再搜索后继目录。 
     所以,一般的C++代码中,如果要用到某个自定义类型,都将那个自定义类型的定义分别装在两个文件中,对于上面结构ABC,则应该生成两个文件,分别为ABC.h和ABC.cpp,其中的ABC.h被称作头文件,而ABC.cpp则称作源文件。头文件里放的是声明,而源文件中放的是定义,则ABC.h的内容就和前面的abc一样,而ABC.cpp的内容就和b.cpp一样。然后每当工程中某个源文件里要使用结构ABC时,就在那个源文件的开头包含ABC.h,这样就相当于将结构ABC的所有相关声明都带进了那个文件的编译,比如前面的a.cpp就通过在开头包含abc以声明了结构ABC。 
     为什么还要生成一个ABC.cpp?如果将ABC::AB的定义语句也放到ABC.h中,则a.cpp要使用ABC,c.cpp也要使用ABC,所以a.cpp包含ABC.h,由于里面的ABC::AB的定义,生成一个符号?AB@ABC@@QAEXJJ@Z(对于VC);同样c.cpp的编译也要生成这个符号,然后连接时,由于出现两个相同的符号,连接器无法确定使用哪一个,报错。因此专门定义一个ABC.cpp,将函数ABC::AB的定义放到ABC.obj中,这样将只有一个符号生成,连接时也就不再报错。 
     注意上面的struct ABC { void AB( float ){} };。如果将这个放在ABC.h中,由于在类型定义符中就已经将函数ABC::AB的定义给出,则将会同上,出现两个相同的符号,然后连接失败。为了避开这个问题,C++规定如上在类型定义符中直接书写函数定义而定义的函数是inline函数,出于篇幅,下篇介绍。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值