每日一问15:C++中的.h,cpp以及.hpp文件
1. 编译器角度的头文件(.h)和源文件(.cpp)
先从编译器角度,来看一下头文件(.h)和源文件(.cpp):
对于头文件(.h),在预处理阶段,头文件被包含到源文件后,它的使命就基本结束了。头文件包含了程序运行中可能需要用到的变量和函数等的声明,在编译过程中,编译器只检查所使用的函数和变量的声明是否存在,对于源文件中的实现并不关心。源文件编译后成生成目标文件(obj文件),目标文件中,这些函数和变量就视作一个个符号。链接器会将所有的目标文件链接起来,组成一个exe程序。在link的时候,需要在makefile里面说明需要连接哪个obj文件,此时,链接器去.obj文件中找在.cpp中实现的函数,再把他们build到makefile中指定的那个可以执行文件中。
一个.cpp对应一个.obj,然后链接器将所有的.obj链接起来,组成一个.exe程序。如果一个.cpp要使用另一个.cpp定义的函数,只需在这个.cpp中写上它的函数声明即可。链接器将所有的obj链接起来,但是如果碰巧有相同的函数或外部变量怎么办?C++可以通过一种叫做链接属性的关键字来限定,某个函数是属于整个程序公用的,还是只在一个编译单元obj里面使用,这些关键字就是extern(外部链接)和static(内部链接)。
2. 为什么需要头文件(.h)和源文件(.cpp)
-
如果在h文件中实现一个函数体,那么如果在多个C文件中引用它,而且又同时编译多个C文件,将其生成的目标文件连接成一个可执行文件,在每个引用此头文件的C文件所生成的目标文件中,都有一份这个函数的代码,如果这段函数又没有定义成局部函数,那么在连接时,就会发现多个相同的函数,就会报错。
-
如果在h文件中定义全局变量,并且将此全局变量赋初值,那么在多个引用此头文件的C文件中同样存在相同变量名的拷贝,关键是此变量被赋了初值,所以编译器就会将此变量放入DATA段,最终在连接阶段,会在DATA段中存在多个相同的变量,它无法将这些变量统一成一个变量,也就是仅为此变量分配一个空间,而不是多份空间,假定这个变量在头文件没有赋初值,编译器就会将之放入 BSS段,连接器会对BSS段的多个同名变量仅分配一个存储空间 。
-
如果在C文件中声明宏,结构体,函数等,那么我要在另一个C文件中引用相应的宏,结构体,就必须再做一次重复的工作,如果我改了一个C文件中的一个声明,那么又忘了改其它C文件中的声明,这不就出了大问题了,如果把这些公共的东西放在一个头文件中,想用它的C文件就只需要引用一个就OK了!!!这样岂不方便,要改某个声明的时候,只需要动一 下头文件就行了
-
在头文件中声明结构体,函数等,当你需要将你的代码封装成一个库,让别人来用你的代码,你又不想公布源码,那么人家如何利用你的库中的各个函数呢?一种方法是公布源码,别人想怎么用就怎么用,另一种是提供头文件,别人从头文件中看你的函数原型,这样人家才知道如何调用你写的函数。
3. 头文件(.h)和源文件(.cpp)中该放些什么
头文件(.h)
写类的声明(包括类里面的成员和方法的声明)、函数原型、#define常数等,但一般来说不写出具体的实现。在写头文件时需要注意,在开头和结尾处必须按照如下样式加上预编译语句(如下),目的是为了防止重复编译:
#ifndef {Filename}
#define {Filename}
//{Content of head file}
#endif
源文件(.cpp)
源文件主要写实现头文件中已经声明的那些函数的具体代码。需要注意的是,开头必须#include一下实现的头文件,以及要用到的头文件。
具体头文件和源文件中该放什么,分类如下:
非模板类型(non-template) | 模板类型(template) | |
---|---|---|
头文件 | 1.全局变量声明(带extern限定符) 2.全局函数的声明 3. 带inline限定符的全局函数的定义 |
带inline 限定符的全局模板函数的声明和定义 |
1. 类的定义 2. 类函数成员和数据成员的声明 3. 类定义内的函数定义(相当于inline) 4. 带static const 限定符的数据成员在类内部的初始化 5. 带inline限定符的类定义外的函数定义 |
1. 模板类的定义 2. 模板类成员的定义和声明(定义可以放在类内或者类外,类外不需要写inline) |
|
源文件 | 1. 全局变量的定义(及初始化) 2. 全局函数的定义 |
无 |
1. 类函数成员的定义 2. 类带static限定符的数据成员的初始化 |
ps:
- 为什么inline函数的定义要放在头文件中?
因为在大多数建置环境(build environments)中,inlining是在编译过程中进行的,而为了将一个函数调用替换为被调用函数的本体,编译器必须知道函数的具体实现。程序编译的时候,并不会去找.cpp文件中的函数实现,只有在link的时候才进行这个工作。我们在b.cpp中用#include “a.h”(这里的a.h和b.cpp是指这不是一对对应的头文件和源文件)实际上是引入相关声明,使得编译可以通过,程序并不关心实现是在哪里,是怎么实现的。
4. 为什么引入.hpp文件
hpp文件,其实质就是将.cpp的实现代码混入.h头文件当中,定义与实现都包含在同一文件,则该类的调用者只需要include该cpp文件即可,无需再将cpp加入到project中进行编译。而实现代码将直接编译到调用者的obj文件中,不再生成单独的obj,采用hpp将大幅度减少调用 project中的cpp文件数与编译次数,也不用再发布烦人的lib与dll,因此非常适合用来编写公用的开源库。
同时,引入hpp文件很大一个原因就是模板。
对于模板,最重要的一点,就是在定义它的时候,编译器并不会对它进行编译,因为它没有一个实体可用。只有模板被具体化(specialization)之后(用在特定的类型上),编译器才会根据具体的类型对模板进行编译。所以才定义模板的时候,会发现编译器基本不会报错,也做不出智能提示。但是当它被具体用在一个类上之后,错误就会大片大片的出现,却往往无法准确定位。因为模板的这种特殊性,它并没有自己的准确定义,因此我们不能把它放在.cpp文件中,而要把他们全部放在.h文件中进行书写。这也是为了在模板具体化的时候,能够让编译器可以找到模板的所有定义在哪里,以便真正的定义方法。
模板从本质上来讲很像宏,当模板在一个编译单元里被实例化时,它并不知道自己是否在别的编译单元也被实例化了。为了解决这个问题,目前的主流编译器,将每个模板的实例代码都单独存放在一个段里,每个段只包含一个模板实例。比如有个模板函数时add
这里的具体分析可以看下面参考博客中的4,5两篇。
5. 使用hpp的注意事项
hpp文件也是一个.h文件,所以很多头文件(.h)的注意事项,对.hpp文件同样适用。适用hpp文件的注意事项如下:
- 不可包含全局对象和全局函数
由于hpp本质上是作为.h被调用者include,所以当hpp文件中存在全局对象或者全局函数,而该hpp被多个调用者include时,将在链接时导致符号重定义错误。要避免这种情况,需要去除全局对象,将全局函数封装为类的静态方法。
- 类之间不可循环调用
在.h和.cpp的场景中,当两个类或者多个类之间有循环调用关系时,只要预先在头文件做被调用类的声明即可,如下:
class B;
class A{
public:
void someMethod(B b);
};
Class B{
public:
void someMethod(A a);
};
在使用hpp的场景中,由于定义与实现都已经存在于一个文件,调用者必需明确知道被调用者的所有定义,而不能等到cpp中去编译。因此hpp中必须整理类之间调用关系,不可产生循环调用。同理,对于当两个类A和B分别定义在各自的hpp文件中,形如以下的循环调用也将导致编译错误:
//a.hpp
#include "b.hpp"
class A{
public:
void someMethod(B b);
};
//b.hpp
#include "a.hpp"
class B{
public:
void someMethod(A a);
};
- 不可使用静态成员
静态成员的使用限制在于如果类含有静态成员,则在hpp中必需加入静态成员初始化代码,当该hpp被多个文档include时,将产生符号重定义错误。唯 一的例外是const static整型成员,因为在vs2003中,该类型允许在定义时初始化,如:
class A{
public:
const static int a = 100;
}
如果需要在hpp中使用静态成员,可以考虑用其他方式来迂回实现:
- 使用局部静态变量来模拟
- 采用单例设计模式模拟实现
参考博客: