C++学习笔记----10、模块、头文件及各种主题(四)---- 头文件
在C++20的模块引入之前,头文件作为一项技术用于提供给子系统或代码块的接口。头文件最通用的场景是声明要在别的地方定义的函数。声明告诉编译器有一个带有确定名字的实体(函数,变量等)存在。对于函数来说,声明指定了函数如何被调用,声明了参数的数量与类型以及函数的返回类型。定义告诉编译器带有确定名字的实体存在,并且定义了实体本身。对于函数来讲,定义包含了函数的实体代码。所有的定义都是声明,但是并不是所有的声明都是定义。声明,以及类定义,也是声明,通常位于头文件中,典型的是以.h为扩展名。定义,包括非内联类成员的定义,通常位于源文件中,典型的是以.cpp为扩展名。我们一般情况下都会使用模块,但是本节简要讨论使用头文件的令人头疼的方面,比如避免重复定义与循环依赖,因为会在历史遗留代码库中碰到。
1、单一定义规则(ODR)
单个翻译单元可以只有一个变量、函数、类类型、枚举类型、概念或者模板的定义。对于有些类型,允许多重声明,但是不允许多重定义。更进一步,在整个程序中只能有一个非内联函数与非内联变量的定义。
对于头文件,比较容易破坏单一定义规则,造成重复定义。下一节我们讨论如何通过头文件避免这样的重复定义。
在模块之间,破坏单一定义原则是比较难的,因为每一个模块与其它模块进行了更好的隔离。这样做的一个主要原因是在模块中的实体不会从其它模块导出,其他模块拥有模块连接,因此从其它模块的代码无法访问。也就是说,多个模块可以定义自身本地的非导出的实体,可以拥有同样的名字而不会带来问题。另一方面,在非模块源文件中,本地实体缺省有外部连接。当然了,在模块本身之内,仍然需要确保不要破坏单一定义规则。
2、重复定义
假定A.h包含了Logger.h,定义了Logger类,B.h也包含了Logger.h。如果有一个源文件叫做App.cpp,它包含了A.h与B.h,就会出现Logger类的重复定义,因为Logger.h头文件通过A.h与B.h都包含了。
这个重复定义的问题可以通过叫做包含哨兵的技术来避免,也叫做头文件哨兵。下面的代码段展示了Logger.h头文件,使用了包含哨兵。在每个头文件的开始,#ifndef指令检查是否定义了特定的键。如果该键已经定义了,编译器就会略过直到匹配的#endif,通常会放置在文件的结尾。如果该键还没有定义,文件处理去定义该键,这样后续包含同样的文件就会被略过。
#ifndef LOGGER_H
#define LOGGER_H
class Logger { /* ... */ };
#endif // LOGGER_H
替代解决方案,目前几乎所有的编译器都支持#pragma once指令,它替换了包含哨兵。在头文件的起始位置放置#pragma once确保它只会被包含一次,因此避免了多次包含头文件导致的重复定义。下面是例子:
#pragma once
class Logger { /* ... */ };
注意:在一个单独的翻译单元中头文件被多次包含时,包含哨兵与#pragma once指令只是防止了单一定义规则被破坏,而不是多个翻译单元之间。
3、循环依赖
另一个避免头文件问题的工具是声明传递。如果需要指向一个类,但是不能包含它的头文件(例如,因为它严重依赖你写的类),可以告诉编译器这样的类存在,只是没有通过#include技术提供正式的定义。当然了,在代码中还不能实际使用这个类,因为编译器对这个类一无所知,除非等所有的连接完成之后命名类存在。然而,你仍然可以在代码中使用指针与引用来传递声明类。也可以通过值来声明返回这样的传递声明类的函数,或者拥有传递声明类作为传值函数的参数。当然了,定义了函数的代码与调用该函数的代码都需要包含正确的头文件,它恰当地定义了传递声明类。
例如,假定Logger类使用了另一个类叫做Preferences,它跟踪用户设置。Preferences类可能反过来使用Logger类,这样就有了一个循环依赖,无法通过包含哨兵解决。在这种情况下需要使用传递声明。在下面的代码中,Looger.h头文件对于Preferences类使用了传递声明,接下来指向Preferences类的就不会再包含它的头文件:
#pragma once
#include <string_view>
class Preferences; // forward declaration
class Logger
{
public:
void setPreferences(const Preferences& preferences);
void logError(std::string_view error);
};
推荐在头文件中尽可能地使用传递声明而不是包含其它头文件。这样可以降低编译与重编译时间,因为它打破了头文件之间的依赖。当然了,实现文件需要包含正确的头文件,其类型是你要传递声明的;否则的话,编译不会成功。
3、查询头文件是否存在
查询特定头文件是否存在,使用__has_include(“filename”)或__has_include(<filename>)预处理常量表达式。如果头文件存在结果为1,不存在结果为0.例如,在c++17中<optional>头文件被批准前,有些编译器已经在<experimental/optional>中有了初始版本。可以使用__has_include()来检查下面两个头文件中的哪一个在你的系统中可用:
#if __has_include(<optional>)
#include <optional>
#elif __has_include(<experimental/optional>)
#include <experimental/optional>
#endif
4、模块导入声明
头文件不应包含任何模块导入声明。标准严格要求模块import声明必须在文件的开头,在任何其它声明之前,必须不能来自于头文件包含或预处理宏扩展。这使得易于构建系统来发现模块依赖,然后用于决定模块需要构建的顺序。