C/C++ 头文件和源文件:它们是如何工作的?





5.00/5 (14投票s)
理解头文件和源文件之间的区别、它们的作用以及它们如何协同工作。
引言
我写这篇文章主要是为了我的一个朋友。然而,如果我不与大家分享,我将是对社区的失职,所以就分享给大家了。
我们将探讨头文件和源文件以及它们的作用。大部分代码在 C 和 C++ 中都能工作,但 C++ 特定的代码会明确标识。
我会尽量简短,只涵盖要点。
理解这段乱码
头文件和源文件之间的关系起初可能令人困惑。多年前我在自学 C 和 C++ 时,我很难理解它们之间的关系以及在哪里使用它们。
其中一部分原因是未能完全理解原型声明的必要性,也未能理解 C 或 C++ 中的链接过程。我们将在本文中对此进行讲解。
协议(Prototypes)
在使用结构体、变量或函数之前,必须先声明它们。对于函数,您可以并且通常应该在函数实现本身之前提供函数的原型。原型本质上只是函数签名,即返回类型、名称、参数以及任何修饰符,例如 static
或 (C++) const
。
考虑以下内容
int sum(int lhs, int rhs);
这是名为 sum
的函数的函数原型。请注意,我们没有在其后加上 { }
,也没有在此处包含任何实现。相反,它以分号结尾,这向编译器表明这是一个原型而不是函数本身。
一旦声明了原型,您就可以使用该函数,即使它出现在文件中的更晚位置,甚至出现在单独的 C 或 C++ 源文件中。
没有头文件
当您使用 #include
包含头文件时,编译器(严格来说是预处理器)会将包含的内容逐字复制到包含它的文件中,就在 #include
指令出现的那一行。这发生在任何源代码实际编译之前。编译发生在稍后。
因此,编译过程本身并没有头文件。所有内容都已转换为 C++ 源文件,头文件已直接复制到源文件本身。
如果我们把 sum()
的原型放在头文件 (mymath.h) 中,把实现放在源文件 (main.c) 中,这一切都能正常工作
mymath.h
#ifndef MYMATH_H
#define MYMATH_H
int sum(int lhs, int rhs);
#endif // MYMATH_H
您可能会问 #ifdef
/#define
/#endif
这些是什么?由于 C 和 C++ 的工作方式,您很可能会多次包含一个头文件,因为您会包含不止一个文件,而这些文件本身又包含同一个文件。围绕 sum()
的这些代码确保只有第一次包含会被编译器处理。
在 C++ 中,首选的替代方法是
#pragma once
int sum(int lhs, int rhs);
您应该始终在您编写的任何头文件中使用这些技术之一。
main.c
#include <stdio.h>
#include "mymath.h"
int main(int argc, char** argv) {
printf("2 + 3 = %d\n", sum(2, 3));
}
int sum(int lhs, int rhs) {
return lhs + rhs;
}
您会注意到第一个 include 周围有尖括号。这意味着编译器将搜索预定义的 include 文件夹。这通常意味着“系统”和“标准”头文件。使用引号时,预处理器会相对于源文件目录进行搜索。
在 main()
中,我们使用 sum()
,而它并没有先在此文件中声明。通常,在使用函数之前,我们至少需要提供一个原型(或完整的实现)。但是,我们已经这样做了——只是不在这个文件中,而是在 mymath.h 中。
在编译器开始编译之前,让我们看一下代码的最终形式。
main.c (预处理后)
// <stdio.h> omitted but would otherwise be here
//ifndef MYMATH_H
//define MYMATH_H
int sum(int lhs, int rhs);
//endif // MYMATH_H
int main(int argc, char** argv) {
printf("2 + 3 = %d\n", sum(2, 3));
}
int sum(int lhs, int rhs) {
return lhs + rhs;
}
这就是编译器“看到”的内容(减去注释,我只是为了让一切都更清楚而添加了注释)。
在此版本中,原型出现在函数使用之前,这满足了编译器的要求。
但是为什么?
特别敏锐的读者可能会想,既然我们可以将所有内容都放在头文件中,然后只使用一个包含所有头文件的源文件,为什么我们还要费心同时拥有头文件和源文件。
您会很快发现,这样做是行不通的,特别是当您有多个源文件包含同一个头文件时。您将由于重复的函数实现而遇到链接器错误。
总的来说,您希望将实现保留在源文件中,将您的 struct
或 (C++) class
声明和函数原型保留在头文件中,并将这些东西的实现保留在相关的源文件中。创建“仅头文件”库是可能的,它们可能有一些优点和缺点。创建仅头文件库超出了本文的范围。
在 C++ 中,有一些例外情况,例如模板。当您声明一个 template
时,声明一个原型以及一个实现是不现实的,因此 C++ 标准没有这样做。因此,所有模板代码——包括实现——都属于头文件。C++ 中的另一个情况是您有一个 inline
函数。您可以将实现放在头文件中,但不必这样做。
多个源文件
您的编译器会为项目中的每个源文件创建一个二进制文件。然后,它会将这些二进制文件链接在一起,将这些二进制文件合并成一个可执行文件。
多个源文件允许您更好地组织源代码,并且也使得将第三方源代码包含到您的项目中更加现实。
正如 **steveb** 在本文的评论中指出的那样,它还允许 C++ 编译器只重新编译它需要的源文件,所以如果一个文件发生变化,它就不需要重新编译所有其他文件。
然而,正如前面提到的,您必须设计您的头文件,只包含原型和类型声明,否则您将无法在一个以上的源文件中使用该头文件,因为会有重复的函数实现,即使它们在不同的二进制文件中。一旦链接器尝试链接二进制文件,它就会失败,因为它找到了不止一个函数的副本,并且不知道使用哪一个。
总的来说,当您创建一个头文件时,它应该有一个与之关联的源文件,其基本名称相同,但扩展名不同。例如,我们可能有一个名为 mymath.c 的源文件来配合 mymath.h
mymath.c
#include "mymath.h"
int sum(int lhs, int rhs) {
return lhs + rhs;
}
请注意,我们包含了相关的头文件。这并不总是严格必需的,但通常是必需的,而且这是一个好习惯,因为它有助于读者理解哪个头文件与之相关。
现在在 main.c 中,我们需要移除 sum()
的实现。编译代码时,您将指定两个源文件。根据您的工具链,您要么将多个源文件传递给调用链接器的编译器,要么将每个源文件编译并作为单独的步骤运行链接器。
结论
希望这能为您解开 C 和 C++ 头文件和源文件的一些谜团。掌握这些知识后,您应该能够更好地组织您的项目,并理解其他项目。祝您编码愉快!
历史
- 2022 年 9 月 3 日 - 首次提交