65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (14投票s)

2022年9月3日

公共领域

6分钟阅读

viewsIcon

45711

理解头文件和源文件之间的区别、它们的作用以及它们如何协同工作。

引言

我写这篇文章主要是为了我的一个朋友。然而,如果我不与大家分享,我将是对社区的失职,所以就分享给大家了。

我们将探讨头文件和源文件以及它们的作用。大部分代码在 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 日 - 首次提交
© . All rights reserved.