这里是一位同学的提问,粗看之下,这个问题似乎不是问题,但仔细想想,要想回答好还真不容易,我试着回答一下,如果大家有不同意见,欢迎补充。
一家之言哈,欢迎拍砖。
原
问题帖子:http://student.csdn.net/space.php?uid=116706&do=thread&id=3688
《c++ primer》第四版 p227页
正如在第 2.9 节提及的,通常将类的声明放置在头文件中。大多数情况下,在类外定义的成员函数则置于源文件中。C++ 程序员习惯使用一些简单的规则给头文件及其关联的类定义代码命名。类定义应置于名为 type.h 或 type.H 的文件中,type 指在该文件中定义的类的名字。成员函数的定义则一般存储在与类同名的源文件中。依照这些规则,我们将类 Sales_item 放在名为 Sales_item.h 的文件中定义。任何需使用这个类的程序,都必须包含这个头文件。而 Sales_item 的成员函数的定义则应该放在名为 Sales_item.cc 的文件中。这个文件同样也必须包含 Sales_item.h 头文件。
问题:为何他说是通常将类的声明放在头文件中?虽然可以将定义当作是一种特殊的声明,但一般好像都是区分它们的.我不明白为什么书中为什么要说成"声明"而不是"定义".
我的回答:
这实际上应该是C的习惯,C++仅仅是继承。
我们知道,在C和C++语言中,程序源文件包含两类,一类是.h头文件,一类是.c和.cpp之类的程序原文文件。从我们大家第一天开始写C和C++语言程序,大家就是这么约定的,好像这是一个定论了,程序就得这么写。
呵呵,我的感觉,其实不一定的。
上文两类文件,其实都是一种纯文本文件,语言编译器本身并没有对后缀名有什么约定,上述命名约定,其实是程序员的约定俗成,并不是必须的。比如有些朋友喜欢在写C++时,把.h文件定名为.hpp,或者.hxx,或者.hh,都可以,c和cpp也不是唯一的,我也见过有人用.cxx或者.cc作为文件名后缀,其实都可以的,甚至,我们自己乱起个名字,比如我叫肖舸,那么我定义.xg是我的程序源文件,.hxg是头文件,都可以,编译器不会报错的。
那么,另外一个问题就出来了,为什么要分两种,既要有.c或者.cpp的程序原文,还要有.h这种头文件。
这还得说说C和C++语言编译器的一个习惯,不过,这也应该是所有编译器的一个习惯了。就是“要想调用,首先看见”。就是编译器要求,你要调用一个什么函数或者变量,总得让我先知道它是什么东东吧。
编译器很笨的,除了C和C++语言基本的语句,什么都不知道,这些语句就是for、while、break、if等基本语句。而我们程序中所有以xxxx()方式调用的语句,它都视为函数,既然是函数,就必须要知道函数的定义,它好检查你的函数调用是不是对的,不能人家要一个int的参数,你这边给个double,因此,如果我们在程序中,可能因为笔误,写了个前面没有定义的函数,比如xxxx(123),这种调用,编译器不知道这个函数在哪里定义的,就会报错。
所以,一般说来,被调用函数,应该在调用者前面写。在很古老的C语言中,有一种写法,就是main函数写在.c文件最后,所有调用函数都写在它前面,就是这个原因。我以前写程序不规范,不喜欢写.h文件,全部是.c文件,在main函数有一大堆include,都是直接包含.c文件,也是这个原因,程序这样也能编译成功。
类也差不多,如果我们要调用,或者实例化一个类,在这个实例化点前面,类必须定义完整。否则出错。
但这就带来一个问题,我们知道,有时候总需要一些全局变量,或者全局对象,比如每个MFC程序,都有个theApp的全局对象,这实际上就是整个程序的总对象,比如我们在VC下创建一个工程,就叫MFC,那马,在MFC.cpp中,一定看得到一句话:CMFCApp theApp;其实,所有的MFC程序,都从这里启动,WinMain已经被MFC封装到自己的库中,看不到了,我们可以认为,MFC的Windows程序,是一个对象,当这个对象实例化,程序就开始,当程序结束,对象就摧毁。
这里又得说一点C和C++编译的知识,才好进一步解说。
C和C++的程序,编译好了,在运行时,私有内存分为三个区域,基栈,浮动栈和堆,当然,32位开发环境下,系统的高端内存可以共享访问,不过这不是我们这个话题的重点。
我前面有文章讲解过C和C++的内存使用原理,大家可以参考这篇文章《C/C++内存的理解》(http://student.csdn.net/space.php?uid=39028&do=blog&id=813),这里就不多说了。
简单说,堆就是malloc,new这些命令,动态分配内存,动态创建对象时,存放数据的空间,需要程序自己释放,使用free或delete,浮动栈呢,是函数运行期,函数内部成员变量的空间,每次调用一个函数,浮动栈就加一层,为各种变量分配空间,函数return返回,该层就拆除,给别的函数调用使用。
对象也差不多,new的对象在堆上,这个说过了,需要程序员自己delete,函数内对象实体,是在浮动栈上,函数退出,对象就摧毁,但是,上述的theApp这个全局对象,情况就很复杂,上它应该属于浮动栈,不过它总是在程序开始之前第一个自动实例化,因此带有某些基栈的特性,就是起码在我们的程序中,看不到它被生命的结束。
不过我们的重点,还是说说基栈,基栈,其实就是exe文件里面的东东,在执行程序时一起被调入内存,作为这个程序运行的基础。
基栈里面有什么呢?
我们所有的代码,编译成的二进制机器码,是在基栈里面,所有的静态变量,静态对象,就是用static修饰过的,不管在不在类里面,都会在基栈中有自己的单元。全局变量,这里说简单变量,比如我们在函数外定义个char g_szBuffer[500];,500个字节的缓冲区,这个是放在基栈里面的,对象比较复杂,就是前面说的这个theApp这个全局对象,不好说,它是运行期动态创建,因为对象必须在运行期才有意义,但是,其内部很多单元的分配,其实是在基栈中,所以我说,C++的非静态全局对象很奇怪,又像基栈成员,又像浮动栈成员,这个我也说不好。
不过这些细节的差异性,不是我们的重点,我们的重点是,基栈里面的东东是怎么来的?
C和C++编译器,编译一段程序,首先就会检查所有的全局变量,起码简单类型的全局变量,是必须检查的,如上文的g_szBuffer,一旦定义,编译器就会在未来的程序运行内存中先分配500个字节,并且给出一个标签,我们定一个全局的char,或者int,也差不多,都是这么做的,而编译器的第一遍编译成果就是.obj,Linux下叫做.o,也是习惯用法,这些分配好的单元,其实就在obj文件里面。
注意,obj不是可执行文件,必须经过连接(link)才可以,ling做什么事情呢?首先,用二进制拷贝,把各个obj的内容连在一起,然后,注意,8086汇编语言中,是有段的,必须做段修饰,否则机器语言中的jmp大跳,会跳不了那么远。80386以上的32位汇编语言我没有研究过,不清楚有没有,不过,总之连接程序有个很重要的功能,就是做这种绝对地址的跳转修饰,这个修饰工作在将来exe文件调进内存时,还要做一次,因为程序每次运行,可能面临的内存环境不一样。
不过,连接有个非常非常非常重要的功能,就是把各个标签换算成具体的指针。
比如我们的Hello World程序:
int main()
{
printf("hello world!\n");
return 0;
}
注意,这里面return是基本语句,编译器知道怎么做,可以为其直接附上机器码,但是,printf,这就是个函数,是C语言基本库的函数,在第一遍编译时,会先以标签形式暂存,因为基本的obj里面,没有printf该做什么的解释,但是,连接程序,就有责任,有义务,从C基本库中找到printf所包含的二进制程序段落,并把它拷贝到将来的exe文件中,由于拷贝进来了,它就有了一个相对exe文件开头的偏移量,这就是它在可执行文件,也就是将来的基栈中的指针,然后,注意,连接器把这个指针,替换原来obj文件里面的标签,最终生成可以执行的exe文件。Linux虽然不叫exe,但这个过程差不多。
这样,以后的main函数,执行到printf这一行,就是一个典型的函数指针跳转,至于这个指针具体指到哪里,各个程序编译出来都不一样,取决与当时连接器的爱好,把这段代码拷贝到exe的哪个角落了,呵呵,整个编译就是这么一个过程。
ok,这时候我们知道了吧,当我们一个大型工程,里面有多个c或者cpp文件,编译器会生成一串串的obj,然后连接器把它们连接到一起,并把各个函数的调用标签修改成具体指针,完成最终的exe。
因此,这里我们得出一个结论,当编译器遇到c,就会生成obj,而这个过程,就是把一些全局的变量实例化(对象暂时不讨论),为其分配空间。
这个时候就出来一个问题,当我们在.c文件中include另外一个.c文件的时候,比如我们有个a.c,b.c,在b.c里面include包含了a.c,ok,如果我们在a.c里面定义了一个全局的变量,int g_nStatus,编译时就会出问题。
首先,编译器编译a.c的时候,会为g_nStatus分配一次单元,生成一个实例,但是,在编译b.c的时候,由于include就是把a.c这个文件在b.c里面做文本展开,因此又会分配一次单元,生成一次实例。
编译时,这两个g_nStatus的实例,由于分处a.obj和b.obj,没有问题,但是,连接器不干了,你有两个单元,使用同一标签,请问我到底用哪个?于是会报连接错误。一般错误类型就是“标签重定义”。
因此,我们发现C和C++语言的编程,看似很宽泛,但还是有些规则的,“凡是可能引发实例化的语句,全部工程编译期间,只能出现一次”。
什么叫可能引发实例化的语句呢?前面的定义全局变量和全局对象,就是可能引发实例化。因此,它们只能被执行一次,如果多次,就会引发连接器bug。
但是,我们知道,C和C++语言开发,我们都大量使用include来包含库文件,包含来包含去,很容易就犯这个错误,程序就编译失败。
因此,为了规避这个情况,C语言程序员(那会还没C++呢)就约定,C程序代码分为两类,一类叫做.c,里面包含上述可能引发实例化的代码,其实函数定义也是可能引发实例化代码的,因为编译器看见一个函数的实体,就是函数名后面直接跟大括号,里面有实际代码的定义,就会直接为其编译机器码,并且给标签,这类代码,统一全部放在.c文件中,同时约定,所有的include,不允许包含.c文件,避免引发连接bug。
然后,大家单独定义一类.h文件,里面全部是声明,供大家include,就是让大家看见,方便编译器把标签翻译成指针。
什么叫做声明呢?简单说就是一个函数,变量,或者类的纯说明,定义了名字后面直接跟分号,什么解释都没有的,就是不包含任何实例化代码的,就可以。举几个例子:
.c文件实例化代码 .h文件声明
int a extern int a;
char szBuffer[500]; char szBuffer[]; 或者 extern char szBuffer[];
CClass obj; extern CClass obj;
大家注意到没有,基本上所有的声明,就是加一个extern的修饰,放在.h文件里面,并且,对于定义数组这类变量,声明可以不加具体的数组大小描述。
简单说,声明,就是一句简单的说明,告诉编译器,这个世界上有这么个东东,你现在看不到,没关系,连接器看得到就可以,编译器就可以不去管访问的这个元素到底是什么,只知道有这么个标签就好了。
所以,简单总结一下,声明就是标签说明,它不会让编译器误会,跑去又产生一遍实例化代码,而仅仅是提示标签,声明一般应该在这个类型已经说明完成以后,出现,并且在调用者之前说明。
C++有点特例,结构体和类的声明,属于声明,编译器不会产生实例代码,而各种成员的详细说明,存在与cpp文件中,一般都是这么约定的。
这里面特别要说明C++类的内联函数,就是在声明中直接实现的函数代码,这部分编译器实际上不是作为实例化代码处理的,而是看做是宏定义,在所有调用者处直接展开代码,因此不存在标签和指针的调用,因此这种写法是可以的。
因此,C和C++的程序员,应该对声明和定义很敏感,看见一行代码,随时提醒自己这是声明还是定义,定义就会有实例化机器码产生,而声明就是简单的标签索引。
在我的新书《0 Bug ---- C/C++商用工程之道》的第三章中,特别提示了C和C++语言,.h文件和.cpp文件的标准写法,大家有兴趣的话,等书出来之后,可以参考一下。
======================================