关于#include我想说的
2020-03-19 / UNIDY

本文首发于github.com/thunlp/OOP-THU/issues/39

#include其实并不是一个非常聪明的机制——直接全文复制,也不管包含了多少用不着的代码;你也不甚清楚你包含的代码中有什么牛鬼蛇神,会不会碰巧撞上了math.h中的y1;假如处理不当,还可能惹来重复定义等令人头秃的麻烦……

在此,我列举一些初次深入了解#include时可能遇到的困扰,并加以说明。


套娃

事情开始于这样的代码:

1
2
3
4
5
6
7
// A.h
#pragma once
#include "B.h"

class A {
B b;
};
1
2
3
4
5
6
7
// B.h
#pragma once
#include "A.h"

class B {
A *a;
};
1
2
3
4
// main.cpp
#include "A.h"

// Do your thing...

我们在类A中设置了B类型的成员变量,因此需要#include "B.h"然而,出于某种需求,我们还希望在类B中保留对应的A的指针,因此还需#include "A.h"看起来顺理成章。

可是,当我们编译时,g++报了错:

1
2
3
B.h:6:5:error: 'A' does not name a type
A *a;
^

是在B.h中报了找不到类型A的错。

奇怪,我们明明在B.h中包含了A.h啊……


探因

我们将目光聚焦到A.h上——原来,A.h标上了#pragma once。也就是说,假如A.h之前已经被包含过了,那么这次就不会再包含它了。再一看main.cpp,确实,A.h早已被包含过了。

破案了!

好,我们将A.h中的#pragma once去掉总行了吧?还不行,这次又报找不到类型B了。

那就把B.h中的#pragma once也去掉吧……停下来!不然那编译器的报错……太美……

不过,至此,这背后的原因已可见端倪——套娃include。C++的include最忌讳的就是套娃了。如果不加#pragma once等处理,则头文件就会永无止境地包含下去;如果加了,那你写代码时可能以为自己include过了,实际上却被编译器拦下了。

总之,这种循环包含的行为是不可取的,在实际编程中应当避免。


解决

那么,应当如何修改代码,才能既满足需求,又不出现套娃的现象呢?

在动手之前,先想想,是否真的需要在B中保留A的指针。因为,这种情况的发生,很有可能意味着你的代码设计时耦合度有些高,才会剪不断理还乱。如果能重新设计代码,让B干脆不依赖A,那是最好的。

不过,如果这一需求不可避免呢?那也有办法:

1
2
3
4
5
6
7
// B.h
#pragma once

class A; // 声明类A
class B {
A *a;
};

我们在B.h中不去#include "A.h",而是声明class A,供B使用,具体的细节则在A.h中给出。这样,既免去了循环包含,又能够在类B中用到类A

至此,“套娃”的问题暂告一段落。下面,再简单提一下#pragma once#ifndef...的事。


重复定义

我们知道,在C++中,对同一个名称,声明可以多次,但定义只能一次。为此,我们需要引入一些保证单次包含的机制,来防止因多次包含同一头文件而造成的重复定义。

#pragma once#ifndef...的用法,在课件上都有写到。这里,对使用过程中可能遇到的疑惑和误区简单说明一下。

#ifndef XXX含义的理解

#ifndef XXX#endif配套,可以理解为if not defined XXX,则……,end if。而在解析……所示的代码之前,需要先#define XXX,从而下次解析到这一头文件时,因为宏定义过XXX了,ifndef条件不满足,就不再解析……部分的代码了,从而保证了单次包含。

#ifndef XXX插入的位置

合理的使用方法,应当是#ifndef XXX#define XXX置于文件的开头而#endif置于文件的末尾,这样才能保证整个文件只被包含一次。

我之前见到过这样的写法:

1
2
3
4
5
6
7
8
9
10
11
#ifndef __HEADER__
#define __HEADER__

#include <iostream>
#include <algorithm>

#endif

class Test {
// ...
};

这就违背保证整个文件只被包含一次的初衷了。假如这一头文件被包含多次,那也会造成Test的重复定义。

(当然,我个人以为出现这样的错误也与课件上只给了用法没给示例有关。)

#pragma once#ifndef...的区别

#pragma once可以简单快捷地保证物理上的这一文件只被包含一次,不过缺点在于一些编译器可能不支持。(当然,越来越多的编译器已经支持这一功能了。)

#ifndef XXX则是从代码层面保证单次包含,且类似写法可以在其它场合有一些灵活的运用。缺点在于你需要保证不同头文件的XXX不要撞车,否则也会导致预期之外的结果。(当然,许多IDE会为新建的.h文件自动加上#ifndef...等语句,可以省去不少麻烦。)


写在最后

读到这里,或许你对#include的机制更加不理解了还有一些困惑。也许,你很想亲自看到,编译器对这些带#的语句到底做了些什么。

这时,我们来了解一下g++的预编译指令。例如:

1
g++ -E main.cpp -o main.i

-E表示当前的任务是对main.cpp进行预编译。预编译的一个任务就是将这些带#的宏命令进行处理,比如#include的内容会在预编译时展开。这时,你就能看到那些头文件到底是谁先谁后了。

本文链接:https://www.unidy.cn/articles/include/