编译器自动识别添加头文件应该不是太难实现吧?为啥要手工维护这个头文件呢


我不清楚你问的是,头文件存在的意义,还是质疑 C/C++ 的 IDE 不能帮你自动 include 头文件?我一开始是按后者去答的,看了别人的回答,发现理解成前者的居多。


关于头文件的作用:

传统的编译模型里,一个源文件构成一个编译单元。各个编译单元之间互不知道任何信息,只能靠头文件统一各个介面的调用规则。

这种编译模型有很多优点。

首先,可以支持你闭源发布你的库。只要你提供头文件和一个二进位的库文件给你库的使用者就够了。和 Java 里同样做了一定编译处理的 jar 包不同,C/C++ 库的二进位里是没有任何对类(结构体)的排布信息和对函数的调用规则描述的,这个描述的工作就是头文件做了。好处就是二进位库文件以及链接了库的程序里不需要记录任何的元信息,体积小,而且被逆向的难度相对困难。另外呢,程序发布的时候,也不需要发布头文件。

其次,如果一个头文件对应的源文件内部的实现有更改,只要调用介面保持不变的话,那只要把 callee 所属的编译单元重新编译,再把新目标文件重新链接进二进位就行了,caller 所属的编译单元都不需要重新编译。

还有呢,各个编译单元之间相互独立,有利于对各个编译单元并行编译、做增量编译,因为之间没有互相依赖嘛。这个可以提高编译速度。

后来,随著 C++ 中模板的兴起,以及 inline 语义的变化,C++ 的头文件中就不止有传统的声明语句,也可以给出函数的实现了。这就出现了不同于传统的静态库和动态库的第三种库 —— head-only library(唯头文件库),特点是使用方便,还有利于编译器做优化。另外也利于解决 ABI 冲突问题。


以下是原答案:

不,你太想当然了。要提供一个识别准确的而且识别速度还快的实现挺难。

我当时才学 Java 时,对 Java IDE 的自动 import 印象很深。只要敲出些啥,Eclipse 就在下面提示个下划线。只要滑鼠移过去,点击气泡里的自动 import 就好了。当时确实觉得挺高级,挺智能。

但是这个问题放在 C/C++ 里就完全不一样了。C/C++ 里有宏,而宏会决定头文件里实际生效的代码。如果是 IDE 比较好掌握的条件宏,比如 C++ 里通过 -std= 选项控制标注版本的 __cplusplus 宏,问题还比较容易处理。但如果是通过 -D 选项由用户指定的宏呢?比如有下面这两个头文件,PLATFORM 这个宏是通过 -D 参数定义的,你在源文件里敲个 someAPI,你告诉我该 include 哪个文件?

// posix.h
#ifndef POSIX_H
#define POSIX_H

#if PLATFORM == UNIX

inline void someAPI()
{
// implement under UNIX
}

#endif

#endif // POSIX_H

// windows.h
#define WINDOWS_H
#define WINDOWS_H

#if PLATFORM == WINDOWS

void someAPI()
{
// implement under WINDOWS
}

#endif

#endif // WINDOWS_H

对于有些 IDE,比如 vs 可以从它的解决方案的属性里,又比如 CLion 可以从 CMakeLists.txt 里提前掌握到编译时会传哪些宏,那么这些宏还相对来说好处理一些。但这也不是绝对的。假如我要用 __TIME__ 宏(一个定义编译时间的宏)搞事情呢?如果项目是早上编译的那我启用某段代码,如果是下午编译的我启用另一段,那你怎么搞?

有的人会说,那你别管这些条件宏了,把所有分支都分析一遍不就好了么?那对不起,也不行。如果有些分支启用了,会导致整个文件多了或者少了括弧怎么办?或者有些分支里有使用了其他编译器提供的扩展的内容,你的语法分析器不认识的东西怎么办?你这个分析程序还得支持模糊分析啊!比编译器难做多了啊。

另外呢,你可能对 C++ 的语法复杂度毫无概念。

class MyIntAllocator;

namespace std
{
template &
class vector;
}

extern std::vector& v1, v2;

void f()
{
std::swap(v1, v2);
}

比如这个例子,可能稍熟悉点 C++ 的都知道 std::swap(T , T) 是 & 中提供的模板函数。那对于上一段代码,应该提示缺少 & 头文件吗?如果不假思索说 yes 的那你就错了。为什么?因为 & 中有针对 std::vector 的 swap 特化啊!根据模板中的最佳匹配原则,应该优先适用特化的版本。如果只 include 了 std::swap(T , T) 所在的文件,致使编译器在编译时没「看到」特化版本,而只「看到」并用通用的 std::swap(T , T) 版本去编译,那生成的程序就 tm 有 bug 了呀。而且这种 bug 还很难去查,做这个自动 include 的人就得被骂死了啊。

如果给你再加点难度。假设这个模板特化启不启用是有利用 SFINAE 法则的呢?如果这个 SFINAE 的条件计算很耗时间呢?你可能都没见过一个 .cpp 单文件编译 15 分钟,编译器占了 10G 内存是个什么美妙的情景。

另外,不同命名空间可能有同名的东西。比如你写个 vector,该 include 标准库的 & 呢?还是 & 呢?那你是不是得要把包含目录下的所有头文件都分析一遍,最后才只能得到个「先生,您希望 include & 还是 include & 」的建议?

向你介绍下,我本机的 /usr/include 文件夹可是有 94M 的呦,/usr/local/include 文件夹可是有 192M 的呦。哦对了,还差点忘说了,编译的时候用户还可以通过 -I 再加自定义包含目录的。


实际上,自动 include CLion 就有做,但是我的感觉是还是不太好用,因为经常导入错的头文件。另外就是做分析时太耗资源了,经常出现 12 核的 CPU 跑满。。。

C++20 带来了 module。或许以后,在 C++ 里也将抛弃头文件,改用 import 来导入了。module 与头文件最大的不同就是对宏的使用有一定的限制(比如宏只影响本模块内部,而不会影响到其他模块)。这对解决我开头说的宏给自动 import 带来的难点有些帮助。但,因 C++ 的复杂语法规则给自动 import 带来的难处,仍很难解决。


头文件才是面相介面编程的终极形态


大致看了一下几个回答,感觉说得太深奥。

首先如果一个程序稍微有点规模,我们就不可能把它的所有代码都写在一个文件里、甚至是一个函数里,那是难以阅读和维护的;所以我们分而治之,将代码写在多个文件里,这就是最基本的模块化思想。那么,不用头文件是否可以?当然可以!

假设现在我们有两个 C 语言代码文件,第一个是 bar.c:

typedef struct
{
const char* name;
int age;
const char* address;
const char* postalCode;
} people;

void initPeople(people* p)
{
p-&>name = "John Smith";
p-&>age = 27;
p-&>address = "21 2nd Street, NY";
p-&>postalCode = "10021-3100";
}

第二个是 foo.c:

typedef struct
{
const char* name;
int age;
const char* address;
const char* postalCode;
} people;

void initPeople(people* p);

int main()
{
people p;

initPeople(p);

return 0;
}

这完全是可以工作的:foo.c 调用了 bar.c 中的 initPeople 函数,将自己的 people 结构体的实例 p 初始化了。

我们看到,people 在两个文件中都有定义,并且是一模一样的;编译器并不觉得这有任何问题,对于它来说,这就是同一个 people。但我们在维护代码的时候却发现了一件麻烦事:修改 people 的时候(比如加一个 phoneNumber 栏位)需要同时修改两个文件;假设忘了其中一个,程序在运行的时候可能会出现莫名其妙的问题。于是我们便想,能不能只维护一份 people?

我们试著把 people 单独放在一个 people.h 文件里:

typedef struct
{
const char* name;
int age;
const char* address;
const char* postalCode;
} people;

然后把 bar.c 改成:

#include "people.h"

void initPeople(people* p)
{
p-&>name = "John Smith";
p-&>age = 27;
p-&>address = "21 2nd Street, NY";
p-&>postalCode = "10021-3100";
}

把 foo.c 改成:

#include "people.h"

void initPeople(people* p);

int main()
{
people p;

initPeople(p);

return 0;
}

这样在预处理阶段,预处理器看到 #include 命令后,就会自动把 people.h 的内容插入进来;现在我们只需要维护一份 people 结构体的定义了。简直完美!

可是这还是没有解释为什么两个 .c 文件都要 people 的定义?

这就不得不提及 C/C++ 语言的编译模型了。C++ 基本上延续了 C 的编译模型,C 在当时选择的是一种「小编译模型」,这种编译模型在当时是明智的,即便到了今天也有它的优势。

在这种编译模型里,整个程序的编译是分开到各个 translation unit 即编译单元的;对应到 C/C++ 里,每一个源文件就是一个编译单元。由于各个编译单元分开编译,编译器同一时刻只需要处理一个源文件,不需要把整个程序全部载入到内存中。在当时内存是十分有限的,所以节省内存十分重要(设想一下因为内存不足而导致你的程序永远无法通过编译)。在今天,CPU 是多核的,内存也大了很多,所以现在我们经常使用并行编译——即同时开多个编译器分别编译不同的源文件,编译速度得到了很大提升,而这也得益于这种编译模型的分而治之的思想(编译速度很重要,它甚至能影响一个项目的开发成本)。

另外,这种编译模型是 single pass 即单遍的——每个源文件都只是从头到尾过一遍,就能立即得到目标代码。这就意味著,结构体和变数必须先有定义才能使用——否则编译器就不知道应该分配多大一块内存、每个变数的准确位置。而在不需要头文件的语言里,编译模型是 multiple passes 也就是多遍的,编译器必须多次扫描文件或者将整个程序载入到内存里才能找到定义、从而确定对象的内存布局。single pass 与 multiple passes 两者各有优劣,例如 single pass 往往编译速度更快、占用内存更小,而 multiple passes 则相反,它更慢,更占内存,但有可能生成更好的代码(因为一些问题没有立即决定,而是综合考虑)。

所以,最后,编译器为什么要自动生成、添加头文件?对于编译器来说,根本就不存在头文件(头文件只是一种预处理命令,由预处理器执行)。那么,借助 IDE 工具帮助我们生成一些头文件是否可以?我认为对于部分需求来说,完全可以。但毕竟是人类在开发,也只有人类才了解需求是什么;随著经验的增加,你会发现,头文件很重要,能做很多有实际意义的事情


C和C++可以在头文件上玩的花样可太多了……因为选择多,导致IDE并不能确定你打算在头文件里玩什么花样。

基本上来说,头文件是以文件组织的代码片段,一般是不会直接编译的;相对的,.c/.cpp源码则会编译为符号集(.o文件)。交付使用时头文件还是源码片段,各类经编译的.c/cpp已经成了binary。

于是你可以在头文件里做纯虚类、宏定义、inline函数、模板类……各种花式应用。IDE也保证不了你是打算在头文件里写定义还是写纯虚介面写inline还是做别的什么奇怪的事情。

对于C++和C而言,header都是一个编译期(还包括你自己做个库然后交付给别人用于编译)的重要组成。缺module(等c++20普及)是缺,但并不能替代头文件……


module目的就是去掉这个

头文件是为了在古代机器上 实现编译器,减少内存占用的

明明可以编译器分析,但是还是让开发人员把需要使用的声明单独列出来,就是为了减少编译器内存消耗

后来模板,宏,header only 都是误入歧途,积重难返了

现代不需要方便机器了,也应该逐渐废掉头文件概念,宏 模板 也作为源文件来看待


推荐阅读:
相关文章