头文件是什么?我们为什么需要它,它为什么存在于C++中?你可能学过很多其他语言,比如java或C#,这些语言实际上没有头文件。而C++有,头文件是一种很奇怪的文件,我们总是把它包含在某些地方,为什么要这样呢。头文件的用途远不只是声明一些你想要的声明,然后在多个cpp文件中使用。随着章幅的深入,我们会了解到许多新的概念,确实需要头文件才能工作,所以不要忽略它们。
就C++的基础而言,头文件通常用于声明某些类型的函数,以便它们能够在你的程序中被使用。如果你看过前面关于编译器和链接器的内容就知道,我们需要某些声明的存在,以便知道函数的功能和类型可供我们使用。
例如,如果我们在一个文件中创建函数,我们想在另一个文件中使用它,但是C++并不会知道这个函数的存在,当我们尝试编译另一个文件的时候。所以我们需要一个公共的地方来存放东西,只是声明,没有定义。因为我们只能定义函数一次(ODR)。一旦我们需要一个公共的地方来存储声明,函数声明,没有实际的函数定义,没有函数体。只是一个地方说,这个函数是存在的。
让我们举个简单的例子
准备一个简单的项目,项目包含一个main.cpp文件,内容如下。
现在,假设我们有一个Log函数,用来记录一些东西到控制台,他会就收一个const char* message
参数,然后使用std::cout
将message
打印到控制台,如下
如果我们继续创建一个额外的文件,并命名为Log.cpp
然后,我们在这个log.cpp文件中编写一个初始化log的函数InitLog来初始化log函数,并决定将一些内容记录到控制台。如下
我们会得到一个错误,因为Log函数在log.cpp文件中实际并不存在。log.cpp文件并不知道这个log函数是个什么东西。
切换到main.cpp中,我们知道Log函数是存在的。所以main函数中的std::cout << "Hello World" << std::endl;
这句就可以替换为Log("Hello World");
,如下
当文件编译main.cpp文件
可以看到,编译通过,并没有什么报错。
但是,切换到log.cpp文件,我们单文件编译log.cpp文件
我们会得到一个错误error C3861: "Log": 找不到标识符
,就这个log.cpp文件而言,log函数不存在。
log.cpp文件到底需要什么才能不出错呢。我们知道log函数是确实存在的,它只是定义在别处。也就是说log.cpp文件只需要函数声明。
回到代码,在log.cpp文件,我们只需要声明log函数确实存在,切换到main.cpp文件,我们可以看下log函数的签名,可以看到log是一个返回void的函数,它的参数是const char* message
一个const char* 指针,void Log(const char* message)
这就是函数签名。
我们可以直接复制log的函数签名到log.cpp文件中,用分号;
结束它,如下。
在log.cpp文件中,这个log函数实际上没有实体,表示它是函数的声明,我们还没有定义这个函数,以及这个函数的作用。void Log(const char* message);
只是说,我们有个函数叫log,并且该函数返回void并接受一个const char* 指针。只是说这个函数存在。
添加了这个声明后,我们再次单文件编译log.cpp文件
可以看到,这次编译通过了。
如果我们编译构项目
可以看到,链接也很好。因为log.cpp文件找到了log函数。
很好,我们找到了一种办法,可以告诉log.cpp文件,这个log函数的存在。但是如果我们创建另一个文件呢?其他文件也需要用到log函数,这是否意味着我们还需要复制和粘贴,把这个void Log(const char* message);
log函数声明复制粘贴到其他地方。答案是肯定的,你确实需要这样做。但是,有一种方法更简单,那就是头文件。
什么是头文件,我们应该怎样看待头文件,因为这是C++,你可以做任何事情。头文件通常会被包含在cpp文件中,我们做的就是通过#include预处理器指令将头文件中的内容复制粘贴放入到cpp文件中。因为#include预处理器具有复制和粘贴的能力,把一个文件内容复制粘贴到另一个文件。而这正是我们现在需要做的事情。
我们需要复制并粘贴这个log声明void Log(const char* message);
到每个需要使用log函数的文件中。
让我们来创建一个头文件,并命名为log.h
可以看到创建头文件时自动插入了一些代码#pragma once
,这个稍后会讲到。
在这个log.h文件中我们声明log函数,我们从log.cpp文件中将log函数的声明剪切过来翻到log.h文件中,如下
现在的想法是这样,这个头文件log.h可以包含在任何地方,我们希望使用log函数的地方,对我来说,我不想手动的复制粘贴到每一个需要它的文件,我不想自己复制粘贴。所以我们找到了一种方法。在某种程度上,他看起来有点整洁和自动化。
回到log.cpp,你可以看到有一个错误
因为没有声明这个log函数。但是,我们输入#include "Log.h"
,可以看到报错消失了
文件可以编译了,单文件编译log.cpp文件
那么,我们还能做什么呢。同样,回到main.cpp文件中,但是main.cpp文件已经有了函数的定义了,所以它并不需要log的声明了。我们在main.cpp文件中可以直接调用log函数。
但是需要知道的是,其实我们将log.h头文件包含进来也没有什么问题
单文件编译main.cpp文件
可以看到编译成功
回到log.cpp文件,我们定义了InitLog这个函数,然而除了log.cpp文件,没有人真正知道它。如果我们想要在main.cpp文件中调用它,那么main.cpp文件就需要InitLog函数的声明。
可以看到,VS会给我们一个错误。因为main.cpp文件没有声明InitLog函数。
让我们将InitLog函数的签名添加到Log.h头文件中。
现在,回到main.cpp文件,可以看到错误消失了。
现在,我们继续将main.cpp文件中的Log函数归类到Log.cpp文件中,将main.cpp文件中的Log函数剪切到Log.cpp文件中。如下
这样功能看起来会更加合理。可一从VS上看到有,我们得到了一个错误
cout没有找到,没有关系,我们加上iostream就行
可以看到错误消失了。
回到main.cpp文件
我们F5运行我们的程序
可以看到我们初始化了我们的log,然后将"Hello World"日志记录到了控制台。
好的,让我们回到log.h头文件,看下那个#pragma once
声明到底是什么。
首先,任何以#
开头的东西,都是被称为预处理器命令或指令。这意味着在实际编译log.h文件之前,#pragma once
将会被预先处理。
pragma
本质上是一个被发送到编译器或预处理器的预处理指令,它到底想要做什么呢。有这个#pragma once
预处理指令,那就表示当前文件只被包含一次,这就是pragma想要的。
#pragma once
监督当前这个头件,阻止我们单个头文件被多次包含,并转换为单个翻译单元。现在我非常谨慎的选择我的措辞,因为你要明白这并不妨碍我们将头文件放到程序的多个位置。而只是说放在一个翻译单元,一个cpp文件。因为如果我们不小心多次包含了一个文件,并转换成一个翻译单元,我们会得到duplicate复制错误,因为我们会复制粘贴整个头文件多次。
演示这一点的最好方法是我们创建一个结构体(结构体,现在只需要使用,后面的篇章中会继续深入)
例如,我们在log.h文件里创建一个名为player的结构体,我可以让它空着,这不重要,因为这个现在不是我们关注的重点。
如果我将这个log.h头文件,包含两次,并转换成一个翻译单元,放弃#pragma once
的监督。它实际上会包含该文件两次,log.cpp文件和main.cpp文件。这意味着我将有两个相同名字的player结构体,我们注释掉#pragma once
,如下。
回到log.cpp文件,我们再包含一次log.h文件,如下
单文件编译log.cpp文件
可以看到"Player":"struct"类型重定义
,报错信息告诉我们,我们重新定义了player结构体。
我们只能定义一个名为player的结构体,结构体名字必须是唯一的。好了,你可以会说,我为什么会这样做,实际编写程序时,也不会这样写,为什么我会包含一个文件两次。
好的,现在回到include,记住include的工作原理是复制和粘贴文件内容到其他文件,这意味着你可以创建一链条的头文件,所以我们有一个名叫player的头文件,里面有player结构体、log函数等等,而这些东西也被包含进了其他文件中。然后第三个头文件包含了所有
如果我们创建另一个头文件common.h,common就是包含一些其他的头文件,例如,log.h,如下
回到log.h,确保log.h中#pragma once
是被注释掉的。
现在log.cpp里面,我们包含log.h,common.h两个头文件,如下
单文件编译log.cpp文件
可以看到,我们仍然得到了那个错误error C2011: "Player":"struct"类型重定义
。player结构体被重新定义了。
如果我们想知道预处理器到底做了什么。你可以看到它实际上包含了两次log.h。
回到log.h文件,取消对#pragma once
的注释,如下
再次编译log.cpp文件
可以看到编译通过了,没有那个错误了,因为编译器识别了那个log.h已经被包含了,不会去包含第二次。
还有另一种方法来做头文件的监督。出于学习的目的,这种方法比pragma更有意义,那就是#ifndef
,虽然#pragma once看起来更加简洁。
回到log.h头文件中,我们使用#ifndef
替换掉#pragma once
,首先输入#ifndef后接需要检查的符号,如_LOG_H,然后#define定义_Log_H。然后在文件的末尾加上#endif。如下
#ifndef _LOG_H
检查是否有一个叫做_LOG_H
的符号被定义。如果没有被定义,将执行#ifndef
到#endif
之间的内容,将继续在编译中包含中区区域的代码。
如果_LOG_H
这个符号已经被定义了,那么编译的时候中间区域的这些代码就不会被包含到使用了该头文件的其他文件中。中间区域的内容将全部被禁用掉。我们一旦通过了这个初始检查,我们定义_LOG_H,也就是,下次我们再用到这些代码时,它将被定义,因此不会重复。很容易证明,
如果我将log.h中的所有内容复制并粘贴我们的log.cpp文件,并注释掉#include "Common.h"
和#include "Log.h"
。如下
可以看到粘贴的这块内容是正常高亮显示
我们放开#include "Log.h"
或#include "Common.h"
可以看到#ifndef _LOG_H
到#endif // !_LOG_H
之间的内容变成灰色了。这是因为_Log_H
已经在#include "Log.h"
定义了。所以头文件保护符(监督、警卫)的东西,在过去被广泛使用。但是现在我们有了这个新的预处理语句#pragma once
。所以我们经常用这个新的。在某种程度上来说,使用那个并不重要,#pragma once
的使用会更加简单,也是大家在实际编程中首选的。几乎每个编译器现在都支持#pragma once
,所以它不止VS支持,还有GCC,clang,MSVC都支持#pragma once
,所以可以放心使用#pragma once
。也就是说,如果你在遗留代码或用不同人不同风格写的代码中,你可能会看到#ifndef #endif
这样的头文件保护符,这个时候要知道#ifndef #endif
是什么意思。
最后我想展示的是头文件在#include
语句中的差异。有些include语句使用引号""
,有些include语句使用的是尖括号<>
。
这是为什么?其实这也很简单,当我们编译程序时,他们有两种不同的含义,我们可以通过这两种方式来告诉编译器,包含文件的路径是什么,这些是我们电脑里文件夹的路径含有需要包含的文件。
如果我们要包含的文件是在其中一个文件夹里,我们可以使用尖括号<>
来告诉编译器搜索包含路径文件夹
。
而引号""
则通常用于包含相对于当前文件的文件
。
例如,我们创建一个与Log.cpp文件同级的header目录,然后再header目录下创建一个Header.h头文件,那么在log.cpp文件中通过""
的方式包含进来就是#include "./header/Header.h"
,./
表示当前文件所在位置,相对于当前所在位置去寻找Header.h文件。
而有了尖括号,这种方式就不是相对于当前文件了,文件只需要在其中一个包含目录里面就行了,以后会继续深入<>
这种方式,这里不再继续深入。
这就是头文件工作的基本要点。你可以使用引号""
,来指定编译器包含目录的相对路径里面的文件。所以你在任何地方使用引号。
例如,我们使用引号#include "iostream"
的方式替换#include <iostream>
这完全是可行的。尖括号<>
只用于编译器包含路径,引号可以做一切。但我通常只用它在相对路径上。
这里主要还是用尖括号<>
。
最后一件事,这个iostream
实际上看起来不像一个文件,因为它不包含任何扩展,后面没有类似于.h
这样的扩展,这是怎么回事。他实际上是一个文件,只是它没有扩展名,这是写C++标准库的人决定的,将C++标准库与C标准库进行区分。
C标准库通常会有.h扩展,如stdlib.h
,但是C++没有,如iostream
。
这只是一种区分C标准库和C++标准库的方法,看他们是否有.h扩展。
iostream
就是一个文件,就像其他文件。实际上,我们鼠标在#include <iostream>
上右键点击打开文档。
可以看到VS跳转到了iostream
头文件里面。
如果我们右键iostream
标签,打开包含它的文件夹。
可以看到它在我们电脑上实际的位置。