这期我们一起学习一下头文件。
什么是头文件呢?我们为什么需要它们?它们存在于 C++中的意义是什么?
你可能学过很多其他语言,比如 Java 或 C#,它们都没有头文件。
头文件是一种很奇怪的文件,我们总是把它包含在某些地方,为什么要这样呢,它们的用途远不止只是声明一些你想要的声明,然后在多个 CPP 文件中使用,随着这个系列的进行,我们要学习的很多新概念确实需要头文件才能工作,所以不要忽略它们。
就 C++ 的基础而言,头文件通常用于声明某些类型的函数,以便它们能够被使用在你的程序中,如果你回想一下 C++ 编译器和链接器那两讲,里面涉及到了我们需要某些声明存在,以便我们知道它们的功能和类型是否可供我们使用。
01 函数声明
例如,如果我们在一个文件中创建函数,我们想在另一个文件中使用它,C++ 不会知道这个函数存在,当我们尝试编译另一个文件的时候,所以我们需要公共的地方来存放东西,只是声明就行,没有定义也可以。因为我们只能定义函数一次,一旦我们需要公共的地方来存储函数声明。也就相当于是在表达:这个函数没有实际的定义,没有函数体,但是这个函数是存在的。
让我们举一个简单的例子。
在 log.cpp 中 Ctrl+F7 ,我们会得到一个错误,因为这个 Log 函数在此文件中实际上不存在,这个文件不知道 Log 函数是什么东西。
当然,在 main.cpp 中,Log 函数是存在的,如果我尝试编译我的程序按下 Ctrl + F7 你会看到它工作得很好,我们没有任何错误。
log.cpp 到底需要怎么处理才能不出错呢?我们怎么知道 Log 函数是确实存在的,它只是在别处定义呢?需要函数声明。
我们做以下的修改。
这个函数实际上在本文件中没有实体,表示它是函数的声明,我们还没有定义这个函数,这个函数的作用就是说,有个函数叫 Log,返回 void 并接受一个 const char * 指针,这个函数确实存在。
你可以看到我们的 vs 智能感知,错误已经消失了。点击 Ctrl+F7, 我们可以编译通过。如果我右键点击项目,然后点击生成,它找到了 Log 函数,神奇。
我们找到了一种方法,可以告诉 log.cpp,这个Log 函数存在,但是如果我们创建另一个文件呢,如果其他的文件也需要用到 Log 函数,这是否意味着我们要一直把这个 void Log 函数声明复制粘贴到其他地方,答案是肯定的,你确实需要这样做,但是,有一种方法可以更简单,使用头文件。
02 头文件
什么是头文件?我们应该怎样看待头文件呢?
头文件通常会被包含在 CPP 文件中,我们做的通过 #include 预处理器指令来实现将头文件的内容复制粘贴到 CPP 文件中。#include 具有复制和粘贴的能力,把文件放入另一个文件,而这正是我们所需要做的事情,我们需要复制并粘贴这个 Log 函数声明到每个需要使用 Log 函数的文件,让我们来创建一个头文件。
现在的想法是这样的,这个头文件 Log.h 可以包含在任何我希望使用 Log 函数的地方,对我来说我不想手动地复制并粘贴到每个需要它的文件。所以我用这种方法,在某种程度上,它看起来有点整洁和自动化。
你可以跑一下编译, 完全没有问题,我们还能做什么呢?同样可以包含在 main.cpp 中,虽然它已经有了函数的定义了,看起来它并不需要包含 Log 的声明了,我们可以直接调用Log,但你要知道,将 Log.h 头文件包含进来也不会有什么问题的。
编译成功。
好了,回到 log.cpp 中,你可以看到我们定义了 lnitLog这个函数,然而,除了 log.cpp,没有人真正知道它, 如果我想要在 main 函数中调用它的话,我就需要提前声明,我们需要做以下修改。
现在一切看起来都很好,程序可以通过编译并运行。
我们再多做一些更加合理的修改,将 Log 函数的定义转移到 log.cpp 文件中,这样代码就会更整齐一点。
编译通过,没有问题。
03 pragma
好的,让我们回到实文件,看下那个 #pragma 声明到底是什么,看上去是VS为我们插入了 #pragma ,这是怎么回事呢?
首先任何以 #
开头的东西被称为预处理器或预处理器指令,这意味着再实际编译此文件之前它将先被处理。
Pragma 本质上是一个被发送到编译器或预处理器的预处理指令,它的任务就是监督:只包括这个文件一次。
#pragma 会监督这个头文件,阻止我们单个头文件多次被包含,并转换为单个转换单元,你要明白这并不妨碍我们将头文件放到程序的多个位置,而只是说放在一个转换单元。原因是如果我们不小心多次包含了一个文件,并转换成一个转换单元,我们会得到 duplicate 复制错误,因为我们会复制粘贴整个头文件多次,验证这一点的最好方法是我们创建一个结构体来试一下。
Log.h 被包含了两次,如果我尝试编译我的文件, 它说我们重复定义了player 结构体的错误。
我们只能定义一个名为 player 的结构体,结构体名必须是唯一的。
好了,你会说, 我为什么要这么做,我不是一个勤奋的程序员,我不像你想的那么笨,为什么我会包含一个文件两次?
你确定?
现在回忆一下 include 是如何工作的,记住 include 的工作原理是复制和和粘贴文件到其他文件,这意味着你可以创建一链条的头文件,
假设我们有一个名叫 player 的头文件,里面有player 结构体、Log 函数等等,而这些东西也被包含进了其他头文件,然后第三个头文件就会包含所有。
看看下面的例子。
我创建了一个头文件,这个头文件包含一些其他的头文件。
如果我编译我的文件,我仍然会得到那个错误,还是那个原因,player 结构体被重新定义了。
将 #pragma 解除注释,就不会得到错误了,因为它识别了 Player 已经被包含,所以后面没有重复包含。
还有另一种方法可以做头文件的监督,实际上,出于教学目的,我喜欢这个,它比 pragma 更有意义,虽然 pragma once 看起来更简洁,那就是 ifndef。
04 ifndef
先看下面的代码。
这样写的的含义是:是否有一个叫做 LOG H 的符号被定义了, 如果它没有被定义,将继续在编译中包含以下代码,如果被定义了,那么下面中间所有这些都不会被包含进来, 将全部被禁用。
一旦我们通过了初始检查,下次我们再用到这些代码的时候,它将被定义, 因此不会重复,这很容易证明。
你甚至可以看到,第一次的时候,一切都很好。它包含了文件,一切正常, 第二次就变成灰色了,因为 LOG H 已经定义。
这个头文件保护符在过去被广泛使用,但是现在我们有了这个新的预处理语句 Pragma once,现在已经被广泛使用,在某种程度上,你用哪一个并不重要, 程序看起来更加简洁点就行,我个人喜欢用 Pragma,因为这是大多数人在工业中使用的,几乎每个编译器现在都支持 pragma once, Visual Studio、 GCC、Clang、MSVC,他们都支持 pragma once。 所以不要害怕使用它,也就是说,如果有一天你找到历史遗留代码或人们用不同的风格写的代码,可能会遇到 ifndef 这样的头文件保护符,要知道它是什么意思。
05 还有一些事
在 include 语句中的一些差异也需要我们认识一下,有些 include 使用引号,有些 include 语句使用尖括号,到底是怎么回事?
其实很简单,当我们编译程序时,它们有两种不同的含义,我们有能力告诉编译器,包括文件的路径是什么。
如果我们要包含的文件是在其中一个文件夹里,我们可以使用尖括号来告诉编译器,搜索包含路径文件夹,而引号则通常用于包含相对于当前文件的文件。
例如,如果我有一个名为 Log.h 文件, 例如,如果我有一个名为 Log.h 文件,如果它在 Log.cpp 文件所在目录的上层目录下, 我可以使用 #include “…/Log.h”, 就可以返回到当前文件的上级目录去寻找包含,因为这是相对于当前文件的路径。而有了尖括号,这里就没有相对于当前文件的了, 他们只需要在其中一个包含目录里面就行了, 我们将在未来讨论更多关于设置包含目录,我不想把事情复杂化,但这就是头文件工作的基本要点,你可以使用引号,来指定编译器包含目录的相对路径里面的文件。
我当然也可以将 iostream 替换为引号表示,这完全可行,尖括号只用于编译器包含路径,引号可以做一切。但我通常只用它在相对路径,主要还是用尖括号。
还有一件事要注意, iostream 实际上看起来不像一个文件,它不包含任何扩展,这又是怎么回事?
它实际上是一个文件,只是它没有扩展名,写 C++ 标准库的家伙决定要这么做,将 C++ 标准库与 C 标准库进行区分,C 标准库通常会有 .h
扩展,但是,C++ 文件没有,这是一种区分 C 标准库和 C++ 标准库的方法,即他们是否有 .h
扩展。iostream 是一个文件,就像其他任何东西,事实上,在 Visual Studio 中,如果我们右键点击它,点击打开文档,你可以看到它带我们去到了 iostream 头文件中,我们可以看到它的具体内容。
06 后话
好了,就是这样。头文件很容易,也很有用,我们将在这个系列中广泛地使用它,后面会有很多相关的例子,你将看到我是如何使用它们的,你现在应该明白它们是如何工作的了吧。
好了,本期就到这里,下期见。