这里转载自知乎: 为什么C/C++要分为头文件和源文件? - wzsayiie的回答 - 知乎 https://www.zhihu.com/question/280665935/answer/649503865
上世纪70年代初,C语言初始版本被设计出来时,是没有头文件的。这一点与后世的Java只有 .java 文件,C#只有 .cs 文件很相似。即使是现代的C编译器,头文件也不是必须的。我使用下面这个例子说明:
// alpha.c
int main() {
print_hello();
}
// beta.c
void print_hello() {
puts("hello");
}
上例只有两个源文件,alpha.c 与 beta.c 。其中 alpha.c 使用了一个自定义函数 print_hello ,beta.c 中使用了标准库函数 puts 。注意:alpha.c 与 beta.c 都没有包含任何头文件。
我在gcc version 7.4.0 (Ubuntu 7.4.0-1ubuntu1~18.04.1)
环境下编译:
gcc -o program alpha.c beta.c
虽然有警告, 但是通过了编译. 这样会得到一个名为 program 的可执行文件,并且它可以正常工作。
以 beta.c 为例:当 beta.c 被编译时,编译器解析到名为 puts 的符号,虽然它是未定义的,但从语法上可以判断 puts 是一个函数,故而将其认定为函数,作为外部符号等待链接就可以了(倘若 alpha ,beta 是 C++ 源文件,编译无法通过,这个后文会做解释)。
下面我用ASCII字符绘制的“编译”与“链接”流程图:
alpha.c -> alpha.obj
\
program
/
beta.c -> beta.obj
相信这个流程作为基础知识已广为人知,我就不再赘述了。问题在于:当初为什么要采用这样的设计 ?将“编译”、“链接”两个步骤区分开,并让用户可知是什么意图 ?
其实这是上世纪60、70年代各语言的“套路”做法,因为各个 obj 文件可能并不是同一种语言源文件编译得到的,它们可能来自于 C,可能是汇编、也可能是 Fortran 这样与 C 一样的高级语言。即是说“编译”、“链接”的流程其实是这样的:
alpha.c -> alpha.obj
\
beta.asm -> beta.obj --> program
/
gamma.f -> gamma.obj
所以,编译阶段C源文件(当然也包括其它语言的源文件)是不与其它源文件产生关系的,因为编译器(这里指的是狭义的编译器,不包括链接器)本身有可能并不能识别其它源。
说到这里,定然有人要问:连函数参数和返回值都不知道,直接链接然后调用,会不会出现问题。答案是:不会,至少当时不会。因为当时的C只有一种数据类型,即“字长”(同时代的大多数语言也一样)。
我们考虑这样一个函数调用:
n = add(1, 2, 3, 4);
[1] 首先,add函数的调用者,将4个参数自右向左压入栈,即是说压栈完成后 1 在栈顶,4在栈底;[2] 然后,add被调用,对于被调用者(也就是 add)而言,栈长度是不可知的,但第一个参数在栈顶,往下一个字长就是第二个参数,以此类推,所以栈长度不可知并不会带来问题;[3] add 处理完成后,将返回值放入数据寄存器,并返回;[4] 调用者弹栈,因为压栈操作是调用者实施的,故而栈长度、压栈前栈顶位置等信息调用者是可知的,可以调用者有能力保持栈平衡。
通过上面的论述,我们得知C语言设计之初是没有头文件的,调用某个函数也不需要提前声明。
不过好景不长,后来出现了不同的数据类型。例如出于可移植性和内存节省的考虑,出现了 short int 、long int ;为了加强对块处理的 IO 设备的支持,出现了 char 。如此就带来了一个问题,即函数的调用者不知道压栈的长度。例如有函数调用:
add(x, y);
调用者知道 add 是一个函数,也知道需要将 x、y 压栈,但应该是先压2个字节、再压4个字节呢,还是先压4个字节,再压2个字节喃;还是连续压2个4字节呢?
紧接着结构体等特性陆续引入,问题变得更复杂。在这种情况下,函数调用需要提前声明,以便让调用者得知函数的参数与返回值尺寸(结构体使用也需要提前声明,以便让调用者知道其成员、尺寸、内存对其规则等,这里不赘述了)。
于是,头文件就出现了。这里有人可能就会问了:为什么在编译一个源文件时,不去其它源文件查找声明,就如后世的Java、C#一样。主要原因上文已经说过:**C源文件在编译时不与其它源产生关系,因为其它源可能根本就不是C;**此外使用 include 将声明插入到源文件中,技术实现毕竟很简单,也可以说是一种技术惯性。
又后来出现了C++,由于函数重载、模板等特性,当编译器识别到一个函数,不仅是参数与返回值尺寸,连调用哪一个函数都无法从函数名辨别了(即上文的“倘若 alpha ,beta 是 C++ 源文件,编译无法通过,这个后文会做解释”一语)。函数与数据结构需要提前声明才能使用更是不可或缺。
另外一个来源于轮子哥的知乎回答: 为什么c++要“在头文件中声明,在源文件中定义”? - vczh的回答 - 知乎 https://www.zhihu.com/question/58547318/answer/157433980
如果你在头文件里面写普通函数的实现,那么这个实现就会被#include给复制到不同的cpp文件里面,到时候就变成了你的exe里面一个函数重复实现了若干次,这显然是不行的。
但是C++的类除外,类默认是inline的,而且人类有义务保证这个类在每一个cpp里面看到的东西都是一样的。所以如果你在两个cpp里面,写了两个名字一样的类,函数名也一样,只是实现不一样,那么编译是能够通过的。只是C++编译器完全可以在每一次调用这个类的时候,随机挑选不同的实现来运行。(我还没想到例子…)
但是在正常情况下,我们的不同的cpp看到的同一个类都是一样的.
假设我们有一个文件: math.cpp
double f1(){
//do something
return 1.0;
}
double f2(double a){
//do somthing
return a*a;
}
还有个头文件为math.h
double f1();
double f2(double);
主函数文件为main.cpp
#include "math.h"
int main(int argc, char const *argv[])
{
/* code */
double d1 = f1();
double d2 = f2(d1);
return 0;
}
根据下图, 我们一步一步看C/C++的编译过程, 以及头文件在这过程中起到的作用.
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mklIARq5-1571627716970)(assets/Sun,%2020%20Oct%202019%20213733.png)]
首先把main.cpp
和math.cpp
两个文件进行预处理
命令为:
g++ -E main.cpp -o main.i
g++ -E math.cpp -o math.i
这样就可以得到宏展开后的预处理文件,注意这个步骤不需要头文件
得到的文件分别为:main.i
, 可以看到main.i
文件仅仅是把#include "math.h"
的里面的东西给展开了.
# 1 "main.cpp"
# 1 ""
# 1 ""
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 1 "" 2
# 1 "main.cpp"
# 1 "math.h" 1
# 仅仅加入了#include "math.h" 的东西
double f1();
double f2(double);
# 7 "main.cpp" 2
int main(int argc, char const *argv[])
{
double d1 = f1();
double d2 = f2(d1);
return 0;
}
math.i
:
# 1 "math.cpp"
# 1 ""
# 1 ""
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 1 "" 2
# 1 "math.cpp"
double f1(){
return 1.0;
}
double f2(double a){
return a*a;
}
把生成的main.i
和math.i
文件通过-S
参数生成汇编代码
命令如下:
g++ -S main.i -o main.s
g++ -S math.i -o math.s
注意, 这里的两个文件还是没什么关系的, 意思就是这两个文件编译成汇编代码都是独立的
main.s
.file "main.cpp"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $48, %rsp
movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
call _Z2f1v@PLT
movq %xmm0, %rax
movq %rax, -16(%rbp)
movq -16(%rbp), %rax
movq %rax, -40(%rbp)
movsd -40(%rbp), %xmm0
call _Z2f2d@PLT
movq %xmm0, %rax
movq %rax, -8(%rbp)
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0"
.section .note.GNU-stack,"",@progbits
math.s
:
.file "math.cpp"
.text
.globl _Z2f1v
.type _Z2f1v, @function
_Z2f1v:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movsd .LC0(%rip), %xmm0
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size _Z2f1v, .-_Z2f1v
.globl _Z2f2d
.type _Z2f2d, @function
_Z2f2d:
.LFB1:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movsd %xmm0, -8(%rbp)
movsd -8(%rbp), %xmm0
mulsd -8(%rbp), %xmm0
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE1:
.size _Z2f2d, .-_Z2f2d
.section .rodata
.align 8
.LC0:
.long 0
.long 1072693248
.ident "GCC: (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0"
.section .note.GNU-stack,"",@progbits
接着就是把汇编语言编译成二进制文件
命令为:
g++ -c main.s -o main.o
g++ -c math.s -o math.o
这里依然是单独编译的, 二进制的代码就不放出来了, 都是乱码.
最后就是把生成的二进制文件main.o
和math.o
链接起来, 生成可执行文件
命令:
g++ main.o math.o -I ./ -o output.out
可以看到, 直到最后链接的时候, 才把这两个文件给结合起来.
C+ +语言支持“分别编译”(separate compilation)。
也就是说,一个程序所有的内容,可以分成不同的部分分别放在不同的.cpp
文件里。.cpp
文件里的东西都是相对独立的,在编译(compile)时不需要与其他文件互通,只需要在编译成目标文件后再与其他的目标文件做一次链接(link)就行了。
比如,在文件a.cpp
中定义了一个全局函数void a(){}
,而在文件b.cpp
中需要调用这个函数。即使这样,文件a.cpp
和文件b.cpp
并不需要相互知道对方的存在,而是可以分别地对它们进行编译,编译成目标文件之后再链接,整个程序就可以运行了。
这是怎么实现的呢?从写程序的角度来讲,很简单。在文件b.cpp
中,在调用 void a()
函数之前,先声明一下这个函数void a();
,就可以了。这是因为编译器在编译b.cpp
的时候会生成一个符号表(symbol table),像void a()
这样的看不到定义的符号,就会被存放在这个表中。再进行链接的时候,编译器就会在别的目标文件中去寻找这个符号的定义。一旦找到了,程序也就可以顺利地生成了。
注意这里提到了两个概念,一个是**“定义”,一个是“声明”。简单地说,“定义”就是把一个符号完完整整地描述出来:它是变量还是函数,返回什么类型,需要什么参数等等**。而“声明”则只是声明这个符号的存在,即告诉编译器,这个符号是在其他文件中定义的,我这里先用着,你链接的时候再到别的地方去找找看它到底是什么吧。定义的时候要按C++语法完整地定义一个符号(变量或者函数),而声明的时候就只需要写出这个符号的原型了。需要注意的是,一个符号,在整个程序中可以被声明多次,但却要且仅要被定义一次。试想,如果一个符号出现了两种不同的定义,编译器该听谁的呢?
头文件的作用就是被其他的.cpp
包含进去的。它们本身并不参与编译,但实际上,它们的内容却在多个.cpp
文件中得到了编译。**通过“定义只能有一次”的规则,我们很容易可以得出,头文件中应该只放变量和函数的声明,**而不能放它们的定义。因为一个头文件的内容实际上是会被引入到多个不同的.cpp
文件中的,并且它们都会被编译。放声明当然没事,如果放了定义,那么也就相当于在多个文件中出现了对于一个符号(变量或函数)的定义,纵然这些定义都是相同的,但对于编译器来说,这样做不合法。
所以,应该记住的一点就是,.h
头文件中,只能存在变量或者函数的声明,而不要放定义。即,只能在头文件中写形如:extern int a;
和void f();
的句子。这些才是声明。如果写上int a;
或者void f() {}
这样的句子,那么一旦这个头文件被两个或两个以上的.cpp
文件包含的话,编译器会立马报错。
比如在1中
的三个文件中, 在math.h
中添加
int a;
同时增加一个头文件math2.h
:
int a;
然后main.cpp
中增加
#include "math2.h"
最后编译
g++ main.cpp math.cpp
得到的结果为:
In file included from main.cpp:7:0:
math2.h:1:5: error: redefinition of ‘int a’
int a;
^
In file included from main.cpp:6:0:
math.h:8:5: note: ‘int a’ previously declared here
int a;
^
可以看到, 这样子的重复定义是会报错的.
但是,这个规则是有三个例外的。
一,头文件中可以写const
对象的定义。因为全局的const
对象默认是没有extern
的声明的,所以它只在当前文件中有效。把这样的对象写进头文件中,即使它被包含到其他多个.cpp
文件中,这个对象也都只在包含它的那个文件中有效,对其他文件来说是不可见的,所以便不会导致多重定义。同时,因为这些.cpp
文件中的该对象都是从一个头文件中包含进去的,这样也就保证了这些.cpp
文件中的这个const
对象的值是相同的,可谓一举两得。同理,static
对象的定义也可以放进头文件。
二,头文件中可以写内联函数(inline
)的定义。因为inline
函数是需要编译器在遇到它的地方根据它的定义把它内联展开的,而并非是普通函数那样可以先声明再链接的(内联函数不会链接),所以编译器就需要在编译时看到内联函数的完整定义才行。如果内联函数像普通函数一样只能定义一次的话,这事儿就难办了。因为在一个文件中还好,我可以把内联函数的定义写在最开始,这样可以保证后面使用的时候都可以见到定义;但是,如果我在其他的文件中还使用到了这个函数那怎么办呢?这几乎没什么太好的解决办法**,因此C++规定,内联函数可以在程序中定义多次,**只要内联函数在一个.cpp
文件中只出现一次,并且在所有的.cpp
文件中,这个内联函数的定义是一样的,就能通过编译。那么显然,把内联函数的定义放进一个头文件中是非常明智的做法。
三,头文件中可以写类(class)的定义。因为在程序中创建一个类的对象时,编译器只有在这个类的定义完全可见的情况下,才能知道这个类的对象应该如何布局,所以,关于类的定义的要求,跟内联函数是基本一样的。所以把类的定义放进头文件,在使用到这个类的.cpp
文件中去包含这个头文件,是一个很好的做法。在这里,值得一提的是,类的定义中包含着数据成员和函数成员。数据成员是要等到具体的对象被创建时才会被定义(分配空间),但函数成员却是需要在一开始就被定义的,这也就是我们通常所说的类的实现。一般,我们的做法是,把类的定义放在头文件中,而把函数成员的实现代码放在一个.cpp
文件中。这是可以的,也是很好的办法。不过,还有另一种办法。那就是直接把函数成员的实现代码也写进类定义里面。**在C++的类中,如果函数成员在类的定义体中被定义,那么编译器会视这个函数为内联的。**因此,把函数成员的定义写进类定义体,一起放进头文件中,是合法的。*注意一下,如果把函数成员的定义写在类定义的头文件中,而没有写进类定义中,这是不合法的,因为这个函数成员此时就不是内联的了。*一旦头文件被两个或两个以上的.cpp
文件包含,这个函数成员就被重定义了。
考虑一下,如果头文件中只包含声明语句的话,它被同一个.cpp文件包含再多次都没问题——因为声明语句的出现是不受限制的。然而,上面讨论到的头文件中的三个例外也是头文件很常用的一个用处。那么,一旦一个头文件中出现了上面三个例外中的任何一个,它再被一个.cpp包含多次的话,问题就大了。因为这三个例外中的语法元素虽然“可以定义在多个源文件中”,但是“在一个源文件中只能出现一次”。设想一下,如果a.h中含有类A的定义,b.h中含有类B的定义,由于类B的定义依赖了类A,所以b.h中也#include了a.h。现在有一个源文件,它同时用到了类A和类B,于是程序员在这个源文件中既把a.h包含进来了,也把b.h包含进来了。这时,问题就来了:类A的定义在这个源文件中出现了两次!于是整个程序就不能通过编译了。你也许会认为这是程序员的失误——他应该知道b.h包含了a.h——但事实上他不应该知道。
例子如下:
//start of a.h
class A{
public:
int a_;
void print(){}
};
//end of a.h
//start of b.h
#include "a.h"
//类B用到了类A
class B{
public:
void print(A a){};
};
// end of b.h
//start of main.cpp
#include
#include "a.h"
#include "b.h"
using namespace std;
int main(){
//使用类A
A a;
//使用类B
B b;
return 0;
}
//end of main.cpp
使用命令编译:
g++ main.cpp
出来的错误信息:
In file included from b.h:6:0,
from main.cpp:8:
a.h:6:7: error: redefinition of ‘class A’
class A{
^
In file included from main.cpp:7:0:
a.h:6:7: note: previous definition of ‘class A’
class A{
^
可知报了重复定义的错误, 也就是在main.cpp
中出现了类A的重复定义. 改进方法如下:
//start of a.h
#ifndef __A_H__
#define __A_H__
class A{
public:
int a_;
void print(){}
};
#endif
//end of a.h
//start of b.h
#ifndef __B_H__
#define __B_H__
#include "a.h"
//类B用到了类A
class B{
public:
void print(A a){};
};
#endif
// end of b.h
//start of main.cpp
#include
#include "a.h"
#include "b.h"
using namespace std;
int main(){
//使用类A
A a;
//使用类B
B b;
return 0;
}
//end of main.cpp
这样子就没问题了.
使用"#define"配合条件编译可以很好地解决这个问题。在一个头文件中,通过#define定义一个名字,并且通过条件编译#ifndef…#endif使得编译器可以根据这个名字是否被定义,再决定要不要继续编译该头文中后续的内容。这个方法虽然简单,但是写头文件时一定记得写进去。