C和CPP中头文件的作用

1. 头文件的由来

这里转载自知乎: 为什么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看到的同一个类都是一样的.

2. C/C++的编译过程

假设我们有一个文件: 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)]

  1. 首先把main.cppmath.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 "<built-in>"
    # 1 "<command-line>"
    # 1 "/usr/include/stdc-predef.h" 1 3 4
    # 1 "<command-line>" 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 "<built-in>"
    # 1 "<command-line>"
    # 1 "/usr/include/stdc-predef.h" 1 3 4
    # 1 "<command-line>" 2
    # 1 "math.cpp"
    
    
    
    
    
    double f1(){
    
        return 1.0;
    }
    
    double f2(double a){
    
    
        return a*a;
    }
    
  2. 把生成的main.imath.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
    
  3. 接着就是把汇编语言编译成二进制文件

    命令为:

    g++ -c main.s -o main.o
    g++ -c math.s -o math.o
    

    这里依然是单独编译的, 二进制的代码就不放出来了, 都是乱码.

  4. 最后就是把生成的二进制文件main.omath.o链接起来, 生成可执行文件

    命令:

    g++ main.o math.o -I ./ -o output.out
    

    可以看到, 直到最后链接的时候, 才把这两个文件给结合起来.

3. C++的编译方式

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++语法完整地定义一个符号(变量或者函数),而声明的时候就只需要写出这个符号的原型了。需要注意的是,一个符号,在整个程序中可以被声明多次,但却要且仅要被定义一次。试想,如果一个符号出现了两种不同的定义,编译器该听谁的呢?

4. 头文件里应该放什么

头文件的作用就是被其他的.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文件包含,这个函数成员就被重定义了。

5. 头文件中的保护措施

考虑一下,如果头文件中只包含声明语句的话,它被同一个.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 <iostream>
#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 <iostream>
#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使得编译器可以根据这个名字是否被定义,再决定要不要继续编译该头文中后续的内容。这个方法虽然简单,但是写头文件时一定记得写进去。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

zedjay_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值