我正在阅读 Bjarne Stroustrups 使用 C++ 的原理和实践,并在第 8.3 章中遇到有关头文件的以下内容:
为了简化一致性检查,我们在源代码中 #include 一个标头 使用其声明的文件以及提供的源文件 这些声明的定义。这样,编译器就会捕获 尽快出错。
由此产生了两个问题:
然后我进行了一些测试,看看如果我不将标头包含到实现文件中会发生什么:
main.cpp:
#include "test_utility.h"
int main()
{
utility::printString("hello");
}
test_utility.h:
#ifndef TEST_UTILITY
#define TEST_UTILITY
#include <string>
namespace utility
{
void printString(const std::string& str);
}
#endif
test_utility.cpp:
#include <iostream>
#include <string>
//#include "test_utility.h"
namespace utility
{
void printString(const std::string& str)
{
std::cout << str << std::endl;
}
}
我预计这段代码会出现链接器错误,指出找不到
printString()
的定义。令我惊讶的是,编译器(gcc Ubuntu 11.4.0-1ubuntu1~22.04)没有给出任何警告、错误,并且代码运行良好。那么将标头包含到实现文件中到底有什么意义呢?难道只是像 Stroustrup 所说的那样帮助编译器更快地捕获错误吗?看来链接器能够将相应的实现连接到相应的定义,而无需实现文件中的标头。
例如:
#include <iostream>
#include <string>
//#include "test_utility.h"
namespace utility
{
void printString(const std::string& str, int x)
{
std::cout << str << std::endl;
}
}
在我的测试中仍然生成链接器错误。
头文件基本上说“存在一个函数
void printString(const std::string&, int)
,但没有给出实际的实现。它仍然足以让编译器编译调用该函数的代码。
实现文件包含标头,以确保它是同一件事,并获取在那里声明的其他内容。
如果您创建一个全局函数而没有前面的原型声明,编译器甚至可以警告您,这样您就不会意外错过标头。对于
g++
,这是由 -Wmissing-declarations
启用的(这是我一直启用的众多警告之一)。由于您没有收到该警告,因此您的第二个示例编译得很好,只有链接器抱怨,因为您承诺存在的函数实际上并不存在于您的代码中。
另外,对于类来说,头文件已经提供了很多详细信息,所以它是绝对必需的。对于函数,您可以不包含标头,但根本不建议这样做,因为您不想在代码中不必要地重复某些内容,并且您希望编译器尽早检查而不是稍后检查。
此外,大多数标头不只是声明一个函数——还有类和类型。
我想到并证明其合理性的一个例子
将头文件包含到实现文件中实际上如何让编译器更快地捕获错误。
考虑
header.h
:
int printString(const std::string& str);
和实施
impl.cpp
int printString(const std::string& str) {
std::cout << str << std::endl;
return 0;
}
然后假设您在代码中的某个位置包含
header.h
并使用 int res = printString("hello");
。您的 res
等于 0。然后您决定进行重构,并将实现更改为
void printString(const std::string& str) {
std::cout << str << std::endl;
}
现在您的程序可以编译并链接,但是此语句
int res = printString("hello");
具有未定义的行为。如果您在实现中包含 header.h
,编译器会抱怨
错误:对‘void printString(const string&)’的新声明产生歧义