C/C++的头文件 / 源文件 / 条件编译 / 多文件编译

C/C++的头文件 / 源文件 / 条件编译 / 多文件编译

  • 头文件
    • 头文件的定义与作用
    • #include编译指令
    • 头文件实例
  • 源文件
  • 条件编译
    • 什么是条件编译
    • 为什么要用条件编译
    • 条件编译的使用
  • 多文件编译
  • 总结

头文件

头文件的定义与作用

之前我的博客讲了C/C++中声明(declaration)和定义(definition)的区别,让大家好区分一下这两个概念。其实不区分也行,把他们统称为定义都行。只不过今天要讲的头文件,他就是用来放声明用的(也有其他东西),比较少用来直接定义。

这里引用一下百度百科对头文件的定义:

在C语言家族程序中,头文件被大量使用。一般而言,每个C++/C程序通常由头文件和定义文件组成。头文件作为一种包含功能函数、数据接口声明的载体文件,主要用于保存程序的声明,而定义文件用于保存程序的实现。

C语言的头文件通常以.h或者.hpp作为后缀,像stdio.h(标准输入输出)或者是stdlib.h(标准库),都是我们入门C时比较常用的两个系统头文件。使用Linux系统的同学可以在/usr/include目录下找到这两个头文件:

ls /usr/include/std*
/usr/include/stdc-predef.h  /usr/include/stdint.h  /usr/include/stdio_ext.h  /usr/include/stdio.h  /usr/include/stdlib.h

用cat命令可以查看一下里面的内容,内容太多我在这就不放出来了。大体都是些条件编译,#define宏定义,还有一些函数的声明等等,没有具体的定义。他的作用主要有:

头文件的主要作用在于多个代码文件全局变量(函数)的重用、防止定义的冲突,对各个被调用函数给出一个描述,其本身不需要包含程序的逻辑实现代码,它只起描述性作用,用户程序只需要按照头文件中的接口声明来调用相关函数或变量,链接器会从库中寻找相应的实际定义代码。

#include编译指令

我们在学习写C语言代码的时候,都会在最前面写上#include 和#include 表示包含这两个头文件,然后在代码中就可以使用printf、scanf、malloc、free等等一些列常用的功能函数。那么在这个过程中,编译器做了什么事情呢?

其实#include是一条编译的指令,他在编译阶段告诉编译器:我需要包含这个头文件。然后编译器就会去系统路径中找到对应的头文件,然后将其原封不动地复制到你的代码中,所以你就可以使用头文件中声明的函数了。

这里要说明的是#include <>和#include ""是有区别的。尖括号表示该头文件要去系统路径找,找不到的话就在当前路径下找;而双引号则表示直接在当前路径上找,经常用于包含用户自定义的头文件。

头文件实例

这里我们自己来写个头文件来包含一下,看看编译器是怎么出来#include指令的。这里为了演示我直接给出函数的定义了,然后随便写了点东西进去(真正在工程中是不推荐的)。编写一个头文件如下:

//my_header.h
double glob = 10;

void fun(){
	int a = 1;
	char b = 2;
}

然后写一个源文件来包含上面的头文件:

#include "my_header.h"

//test_header.c
int main(){
	return 0;
}

使用gcc进行编译,只激活预编译:

[dyamo@~/code 15:23]$ gcc -E test_header.c
# 1 "test_header.c"
# 1 ""
# 1 ""
# 31 ""
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "" 2
# 1 "test_header.c"
# 1 "my_header.h" 1
double glob = 10;

void fun(){
 int a = 1;
 char b = 2;
}
# 2 "test_header.c" 2


int main(){
 return 0;
}

可以看到编译器做了一些处理。首先编译器会把注释先删除了,然后再把my_header.h文件的内容复制到test_header.c文件中。

源文件

既然头文件只是用来放声明的,那他的定义放在哪呢?源文件就是用来解决这个问题的。

我们在工程中通常将声明和定义分开放在头文件和源文件中,这样方便工程的模块化管理。源文件就是我们经常写的.c或者.cpp文件。可能刚刚入门C语言的同学没有太多的感触,因为刚刚学习的时候代码功能简单,不需要刻意去分模块。最多就是在main函数所在的文件多写几个函数来实现模块功能,不会特地去分什么头文件源文件。

其实除了方便模块化管理的原因外,还有一个更重要的原因导致了头文件和源文件必须分开来写,那就是避免重复定义。 上面也说了,编译器是直接将头文件复制到包含的地方的。如果在头文件里面定义了某个函数,然后另外一个头文件也定义一个同名的函数,然后一个main.c文件同时包含这两个头文件,就会出现相同函数在同一个地方被定义了两次,这明显是不被允许的,直接编译报错。

但是如果头文件里面只写声明不写定义,那么情况就不一样了。编译器不管你写了多少次声明,都能给你编译通过,最多给个编译警告,只要不重复定义产生二义性问题就行。然后再写个定义去实现函数功能,使用函数的时候也不会报段错误。

现在我们将上面写的my_header.h修改一下,用一个main.c文件来放fun函数的定义,并在main函数中调用:

//my_header.h
void fun();
//main.c
#include 
#include "my_header.h"

int main(){
	fun();
	return 0;
}

void fun(){
	printf("这是my_header.h的fun()\n");
}
[dyamo@~/code 15:55]$ gcc -o main.exe main.c 
[dyamo@~/code 15:55]$ ./main.exe 
这是my_header.h的fun()

可以看到,在main.c中fun函数的定义是放到main函数后面的,main函数要想使用fun函数必须要有前向声明。这也证实了该声明是在my_header.h中,被#include包含进了main.c文件中,并且在main函数的前面。当然这么写也是为了演示方便,真正在工程中也不会将头文件的定义写在main函数的后面。到下面的多文件编译,我再具体讲讲。

条件编译

什么是条件编译

这里直接引用一下百度百科的定义:

—般情况下,C语言源程序中的每一行代码.都要参加编译。但有时候出于对程序代码优化的考虑,希望只对其中一部分内容进行编译,此时就需要在程序中加上条件,让编译器只对满足条件的代码进行编译,将不满足条件的代码舍弃,这就是条件编译(conditional compile)。

预编译指令是给编译器看的指令,如#include就是告诉编译器把头文件给复制过来。通过预编译指令我们可以让编译器按照我们的意愿去执行编译,条件编译只是其中中的一部分。这里只介绍条件编译,其他预编译指令我就不一一介绍了,感兴趣的同学可以去查查。

为什么要用条件编译

上面说了,对程序代码的优化考虑,也就是对编译效率的优化考虑。这是最主要的原因,而且这种情况也只有在大工程中才有明显体会的。对于初学者来说,大工程还有点遥远,比较难实际体会条件编译带来的编译效率优化。这里我只能举个比较简单的例子,来供大家去理解到底怎么个优化法。

在描述头文件和源文件的时候,我说了不管包含多少个头文件,只有不重复定义,无论写多少声明编译都是能通过的。但说实在的,写重复的声明就没必要了,能避免就避免。但是有一种情况就很难避免得了,那就是多重包含。看如下三个头文件和源文件:

//a.h
void fun1();
//b.h
#include "a.h"

void fun2();
//c.h
#include "a.h"
#include "b.h"

void fun3();
//main.c
#include "a.h"
#include "b.h"
#include "c.h"

int main(){
	return 0;
}

激活gcc的预处理看看编译器是怎么处理这样的多重包含的:

[dyamo@~/code 16:33]$ gcc -E main.c 
# 1 "main.c"
# 1 ""
# 1 ""
# 31 ""
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "" 2

# 以下是#include "a.h"处理的结果
# 1 "main.c"
# 1 "a.h" 1
void fun1();

# 以下是#include "b.h"处理的结果
# 2 "main.c" 2
# 1 "b.h" 1
# 1 "a.h" 1
void fun1();	# b.h中#include "a.h"的处理结果
# 2 "b.h" 2

void fun2();	# b.h本身的fun2

# 以下是#include "c.h"处理的结果
# 3 "main.c" 2
# 1 "c.h" 1
# 1 "a.h" 1
void fun1();	# c.h中#include "a.h"的处理结果
# 2 "c.h" 2
# 1 "b.h" 1
# 1 "a.h" 1
void fun1();	# c.h中#include "b.h"的处理结果
# 2 "b.h" 2

void fun2();
# 3 "c.h" 2

void fun3();	# c.h本身的fun3
# 4 "main.c" 2

int main(){
 return 0;
}

从预处理的结果可以看出,有很多无用的声明,这些都是重复包含的结果。如果是在大工程的话,可能会用到大量的重复包含,可想而知这样的处理结果势必会造成编译效率的降低。这个情况还算简单的,如果是a.h中包含b.h,b.h又包含a.h,这种互相包含的关系会更加复杂,而且可能报编译错误。要避免这种重复包含的情况,条件编译就派上用场了。

条件编译的使用

我们写头文件的时候,都会习惯将头文件的内容放入这样的框架中:

//my_header.h
#ifndef _MY_HEADER_H_
#define _MY_HEADER_H_

void fun();

#endif

翻译成人类的语言就是:如果没有定义_MY_HEADER_H_,那就定义_MY_HEADER_H_,(内容),结束如果。这是我们最常用到的条件编译,每每写头文件的时候就一定先把这个给写上,然后再在中间写上头文件的内容,这样写就能避免上面的重复声明。其实造成上面重复声明的根本原因就是编译器不知道什么头文件被编译过,什么头文件没有被编译过,他看见#include只会无脑地执行复制的操作。所以我们就要通过这样的预编译指令,来告诉编译器这个头文件之前已经被编译过了。

把这些条件编译写到上面的a.h、b.h和c.h中,再进行预编译就会是以下的结果:

[dyamo@~/code 17:03]$ gcc -E main.c 
# 1 "main.c"
# 1 ""
# 1 ""
# 31 ""
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "" 2
# 1 "main.c"
# 1 "a.h" 1

void fun1();
# 2 "main.c" 2
# 1 "b.h" 1

void fun2();
# 3 "main.c" 2
# 1 "c.h" 1

void fun3();
# 4 "main.c" 2

int main(){
 return 0;
}

完美避免重复声明。这样的条件编译可以用另一个预编译指令来代替,那就是#pragma once,意思是该头文件只会被编译一次。

欸有的同学就会想:既然这样我把声明和定义都写在头文件里面,然后通过这个条件编译来控制其只被编译一次,不就避免了重复定义的冲突了吗?其实结果是不一定的,这样的条件编译只是可以简单规避重复的声明,并不能避免a.h和b.h定义两个同名的函数。要是工程量大了,你根本没有办法记住一些头文件的细节,说不定两个头文件就是重复定义了,工程编译了一个多小时突然崩溃,然后你找出错误又得编译一个小时,得不偿失。所以宁可重复声明,也不要在头文件中定义(有一些情况除外,比如内联函数和模板),然后写头文件的时候写上如上的条件编译就好了。

多文件编译

在软件工程中,我们肯定是有很多个头文件和源文件的。而且为了工程的方便管理,这些头文件和源文件不一定放在同一目录下。在这里就简单介绍下如何把我们的代码模块化放在不同的目录下,然后如何通过多文件编译组合成一个可执行文件。

首先我们先新建一个my_project目录,然后在该目录下又新建header和src目录,分别用来存头文件和源文件,最后在新建一个output作为编译输出的目录。我们编译的时候是在my_project这个路径下的,所以main.cpp文件我们还是写在这个路径下。然后整个的目录结构如下:

[dyamo@~/code/my_project 17:35]$ tree .
.
├── header
├── main.cpp
├── output
└── src

3 directories, 0 files

这里还是用我们的Character类来演示吧,把类的声明写在头文件里面,定义放到源文件:

//character.h
#ifndef _CHARACTER_H_
#define _CHARACTER_H_

#include 

class Character {
private:
	int hp;
	int mp;
	const std::string name;
	
public:
	Character(int, int, const std::string&);	//声明的时候使用匿名参数,写具体参数也行
	Character(const Character &);
	~Character();
	void do_something();
	//像这种简单的函数可以直接在类中实现,这样的话就是内联函数
	int get_hp() { return hp; }
	void set_hp(int h) { hp = h; }
	int get_mp() { return mp; }
	void set_mp(int m) { mp = m; }
	std::string getName() { return name; }
};

#endif
//character.cpp
#include 
#include "../header/character.h"	//src的上一级路径(my_project)下的header目录下的character.h

using namespace std;

Character::Character(int h, int m, const string& n) 	//实现的时候一定要写具体参数
	: hp(h)
	, mp(m) 
	, name(n)
{
		cout << "新建英雄[" << name << "]成功!" << endl;
}

Character::Character(const Character &another) 
	: hp(another.hp)
	, mp(another.mp)
	, name(another.name)
{
		cout << "角色被拷贝!" << endl;
}

Character::~Character() {
		cout << "角色被销毁!" << endl;
}

void Character::do_something() {
	cout << "[" << name << "] do something." << endl;
	cout << "hp: " << hp << endl;
	cout << "mp: " << mp << endl;
}

然后我们另外定义几个函数来操作我们的Character对象,当然也是要分开头文件和源文件来实现:

//function.h
#ifndef _FUNCTION_H_
#define _FUNCTION_H_

class Character;	//声明有这个类,使用的时候只要包含对应的头文件就行
void do_something(Character);
void do_something_and_die(Character&);

#endif
//function.cpp
#include "../header/character.h"
#include "../header/function.h"

void do_something(Character c) {
	c.do_something();
}
//复习一下函数参数使用引用和不使用引用的区别
void do_something_and_die(Character& c) {
	c.set_hp(0);
	c.set_mp(44);
	c.do_something();
}

最后我们在main.cpp文件里面创建一个对象,然后对其进行一定的操作:

#include "header/character.h"
#include "header/function.h"

int main() {
	Character mario(100, 100, "马里奥");
	do_something(mario);
	do_something_and_die(mario);
	mario.do_something();
	return 0;
}

现在我们的目录结构是这样的:

[dyamo@~/code/my_project 18:23]$ tree .
.
├── header
│   ├── character.h
│   └── function.h
├── main.cpp
├── output
└── src
    ├── character.cpp
    └── function.cpp

3 directories, 5 files

完成之后我们对所有的文件进行编译,把结果输出到output目录下:

[dyamo@~/code/my_project 18:26]$ g++ -o output/main.exe main.cpp src/character.cpp src/function.cpp 
[dyamo@~/code/my_project 18:26]$ tree .
.
├── header
│   ├── character.h
│   └── function.h
├── main.cpp
├── output
│   └── main.exe
└── src
    ├── character.cpp
    └── function.cpp

3 directories, 6 files

可以看到多个文件被编译成了一个可执行文件,最后运行./output/main.exe:

[dyamo@~/code/my_project 18:26]$ ./output/main.exe 
新建英雄[马里奥]成功!
角色被拷贝!
[马里奥] do something.
hp: 100
mp: 100
角色被销毁!
[马里奥] do something.
hp: 0
mp: 44
[马里奥] do something.
hp: 0
mp: 44
角色被销毁!

至于代码的结果,我就不多做解释了。不懂的话可以去补我的上一篇博文,也是非常重要的C++知识点。

总结

整篇博文的知识点都是建立在有一定代码规模的前提下的。如果只是简单的写一些脚本啊,或者只是用C/C++来刷一刷LeetCode的话,duck不必去分模块分文件,全部写在main文件下他不香吗。就像一个十个人的公司,就没有必要去学习别人大公司分什么人力部门、公关部门、开发部门、运维部门、测试部门等等,人人都是CEO,duck不必。当然提前熟悉和了解这些内容,有能力就划分划分也是挺好的,尽快养成分模块分文件的好习惯。

你可能感兴趣的:(Linux,C/C++学习笔记)