为什么C++标准库没有预先包含在任何C++源代码中?

35
在 C++ 中,标准库被封装在 std 命名空间中,程序员不应在该命名空间内定义任何内容。当然,标准包含文件不会在标准库中产生命名冲突(因此包含标准头文件从未是问题)。
那么为什么不默认包含整个标准库,而是强制程序员每次都写例如 #include <vector>?这也将加速编译,因为编译器可以使用预先构建的符号表开始编译所有标准头文件。
预先包含所有内容还可以解决某些可移植性问题:例如,当您包含<map>时,定义了采用哪些符号进入 std 命名空间,但不能保证其他标准符号不会加载到其中,并且例如您可能最终得到理论上可用的 std::vector
有时程序员忘记包含一些标准头文件,但由于特定实现的包含依赖性,程序仍然可以编译。但是,将程序移动到另一个环境(或同一编译器的另一个版本)时,相同的源代码可能无法编译。
从技术角度看,我可以想象编译器只需使用 mmap 预加载标准库的最佳完美哈希符号表。这应该比加载和对单个标准头文件进行 C++ 解析更快,应该能够提供更快的查找速度以获取 std:: 名称。此数据也是只读的(因此可能允许更紧凑的表示,并且可以在编译器的多个实例之间共享)。
然而,这些只是理论想法,我从未实现过。
我唯一看到的缺点是我们 C++ 程序员将失去编译咖啡时间和 Stack Overflow 访问 :-)

我认为主要的优势在于程序员,尽管C++标准库是一个单一的命名空间,但他们仍需要知道哪个子部分(包括文件)包含哪个函数/类。更让人沮丧的是,当他们犯了错误并忘记包含一个文件时,代码可能会根据实现而编译或不编译(从而导致不可移植的程序)。


历史上并不是这样做的。头文件很大,每个程序都包含所有头文件会增加编译器处理的代码量。你不需要包含库,只需要包含声明库提供的设施的头文件。当你链接时,库就会被添加到你的程序中。你关于“缺少头文件”的问题是正确的。你可以通过谷歌搜索“include what you use”(IWYU)来了解更多信息。我记得它是/曾经是一个谷歌代码项目(所以现在可能在Github上)。 - Jonathan Leffler
2
相关链接:https://dev59.com/R1wZ5IYBdhLWcg3wIdR7 - πάντα ῥεῖ
4
您认为使用mmap将预解析的数据结构加载到标准库中会比每次加载标准文件更快吗? - 6502
3
请注意,之前的问题“为什么不总是包含所有标准头文件?”(https://dev59.com/emQn5IYBdhLWcg3wcWzM)已经涉及了这个问题中隐含的一些问题。 - Jonathan Leffler
2
这与标准委员会无关,而是取决于编译器编写者如何实现头文件包含。没有任何规定头文件必须是磁盘上的文件。就标准而言,它们可以内置到编译器中。 - Galik
显示剩余14条评论
7个回答

18
简短的答案是“因为这不是使用C++语言应该的方式”。 有以下几个原因: - 命名空间污染——即使可以缓解此问题,因为std命名空间应该是自我连贯的,并且程序员不强制使用“using namespace std;”。但是,包含整个库使用“using namespace std;”肯定会导致一团糟...... - 强制程序员声明他想使用的模块,以避免意外调用错误的标准函数,因为标准库现在非常庞大,并非所有程序员都知道所有模块 - 历史:C++仍然受到C的强烈影响,其中不存在命名空间,并且标准库应像其他库一样使用。
就你的意义而言,Windows API就是一个例子,您只需要一个大文件(windows.h)即可加载许多其他较小的文件。实际上,预编译头文件允许它足够快。
所以,在我看来,从C++派生出的新语言可以决定自动声明整个标准库。新的主要版本也可以这样做,但它可能会打破大量使用“using namespace”指令并具有与某些标准模块相同名称的自定义实现的代码。
但是,我知道的所有通用语言(C#,Python,Java,Ruby)都需要程序员声明他想使用的标准库的部分,因此,我认为系统地提供标准库的每个部分仍然比真正有用的程序员更为尴尬,至少直到有人找到如何声明不应加载的部分。这就是为什么我说了一个从C++派生出的新衍生物的原因。

8
如果您使用任何与标准库头文件冲突的名称,那么using namespace std不能保证起作用(例如<map>头文件也可以合法地包含std::vector)。实际上,using namespace std是一个错误(除了一些简单的例子),基本上违反了命名空间的设计初衷。 - 6502
在你的列表中,唯一有实质性意义的是“历史”...换句话说,“它就是这样。结束了。”污染命名空间是无意义的,因为该命名空间(除了swap)是禁止程序员使用的,如果您使用using namespace std导入所有内容并冲突了标准名称,则您的程序无法移植(即使您没有列出名称的官方包含文件)。对于不知道调用标准函数的情况也是如此(即使您没有列出名称的官方包含文件,今天的标准也可能发生这种情况)。 - 6502
1
作为一种妥协,他们可以定义一个标准头文件 <std>,它会引入所有标准库头文件。这样旧代码就不会出错(因为它不包含 <std>),而新代码只需在需要时包含 <std>(如果作者愿意的话,仍然可以选择包含单个头文件)。 - celtschk

11
大多数的C++标准库都是基于模板的,这意味着它们所生成的代码最终取决于您的使用方式。换句话说,除了像 std::vector<MyType> m_collection; 这样实例化一个模板之外,很少有东西可以在编译之前编译。

此外,C++可能是编译速度最慢的语言,当您#include一个包含其他头文件的头文件时,编译器必须进行大量的解析工作。

8
C++难以解析的事实是支持预解析整个标准库的论点,而不是反对它。 - 6502
@6502 是的,但这意味着C++编译器基本上需要实现对预解析的支持,无论它是否符合其特定需求。这也将使使用预处理器宏来配置标准库变得不可能。 - Sneftel
1
模板的实现在某种程度上取决于是否实现了两阶段查找。曾经有一段时间,MSVC++ 在实例化之前对模板定义的最佳处理方式基本上只是将其标记化并检查是否有相同数量的开放和关闭括号(我有些夸张)。但是,编译器要进行两阶段查找,包括微软最近的努力,基本上意味着它可以并且确实会在知道参数之前编译模板并执行更多“非常少量”的有用工作。 - Steve Jessop

7
首先,C++试图遵循“你所使用的才是你所付出的”的原则。标准库有时并不属于你使用的范畴,甚至如果你想使用也未必需要。此外,如果有理由这么做,你可以替换它:请参见libstdc++和libc++。这意味着毫无疑问地全部包含它并不是一个明智的选择。
总之,委员会正在缓慢地创建一个模块系统(这需要大量时间,希望它能在C++1z中实现:C++模块——为什么它们被从C++0x中移除?以后会回来吗?),当它完成后,包括更多的标准库比严格必要的部分应该消失了,每个单独的模块应该更干净地排除他们不需要包含的符号。此外,由于这些模块是预解析的,它们应该给予您想要的编译速度提升。

一个模块系统是一件不同的事情,将标准库分区可能是个好主意(但我不太喜欢当前提议的IIUC部分,这涉及在编译单元命名空间中注入模块导出名称)。 - 6502

6

你提出了方案的两个优点:

  • 在编译时具有更好的性能。但是标准中没有阻止实现通过一些微小的修改来做你所建议的事情[*],即只有当翻译单元包括至少一个标准头文件时才映射预编译表。从标准的角度来看,在QoI问题上强加潜在的实现负担是不必要的。

  • 对程序员很方便:根据你的方案,我们不需要指定需要哪些头文件。我们之所以这样做,是为了支持那些选择将标准头文件变成单片的C++实现(目前所有的C++实现都是这样),所以从C++标准的角度来看,这是“支持现有实践和实现自由,以可接受的成本为代价”的问题。这也是C++的口号吧?

既然没有任何C++实现(我所知道的)真正做到了这一点,我的怀疑就是它实际上并没有给你带来你想象中的性能提升。Microsoft提供了预编译头文件(通过stdafx.h)正是出于这个性能原因,但它仍然没有为“所有标准库”提供选项,而是要求你列出需要的头文件。这个或任何其他实现要想提供一个具有与包含所有标准头文件相同效果的特定于实现的头文件,也很容易。这让我认为,至少在Microsoft看来,提供它并不会带来很大的整体好处。

如果实现开始提供证明编译时性能提高了的单片标准库,那么我们会讨论C++标准是否继续允许不支持的实现是否是一个好主意。就目前而言,必须这样做。

[*] 除了<cassert>定义为根据包含它的位置上NDEBUG的定义而有不同行为之外。但我认为实现可以像平常那样预处理用户的代码,然后根据是否定义NDEBUG之一映射两个不同的表格。


您关于 <cassert> 的观点确实是通用的,因为各种标准库实现都存在许多“调试”选项... 当然,这还不包括编译的平台(它会影响类型的大小和对齐方式!)。尽管如此,预编译头文件可以分支(每个选项集一个预编译头文件),也可以是“全包含”的,还可以介于两者之间。 - Matthieu M.
@MatthieuM:说得好,对于某些实现来说,选项的组合可能是棘手的。我猜测,针对gcc目标的预编译表可能比支持指令集所需的所有代码更小,但在许多情况下,它比每个小型架构变体的附加支持/配置更大,因此如果不在首次使用时生成和缓存,则会使编译器安装本身膨胀。 - Steve Jessop
主要的优势是为程序员而设计的。C++标准库是一个单一的整体命名空间,但程序员需要知道哪个子部分包含哪个函数/类(加上这样一个复杂的情况:当你犯了一个错误时,在某些编译器上代码仍然可以编译,导致程序不可移植)。我认为它甚至会更快、更轻巧,这只是附带的。 - 6502
@6502:如果性能不重要而便利性重要,那么任何程序员都可以编写一个列出所有标准头文件并将其包含在所有内容中的文件。此时很快就会发现实际上性能是重要的,在C++中仅列出所需的标准头文件是改善性能的一种方式。或者也许(在你使用的实现中)情况并非如此,在这种情况下,无论标准规定什么,你都可以这样做。标准通常允许程序员和实现者在考虑强制执行之前先解决这些问题。 - Steve Jessop
@SteveJessop:实际上,我用g++ -c -std=c++11 -x c++-header std 建立了一个小的测试程序,制作了一个名为 std.gch 的预编译头文件,其中包含了我能够预编译的所有标准库头文件(生成的.gch文件大约为78MB)。在这之后,当我使用 #include "std" 时,在我的电脑上编译一个 “hello world” 需要160ms,而使用 #include <cstdio> 则需要约50ms。但是,如果我包括我最频繁使用的头文件(vector、map、string、functional、cstdio、unordered_map、sstream、iostream、atomic、thread和mutex),那么相同的 hello world 编译将需要550ms。 - 6502
你对于<cassert>的解决方法并不可行。在单个翻译单元中可以多次包含它,并且它会根据包含时 NDEBUG 的定义而执行不同的操作。我认为<cassert>必须从预编译的标准库中省略。 - Martin Bonner supports Monica

4
我认为答案归结于C++的哲学,即不强制让你为你不使用的部分付出代价。这也给了你更多的灵活性:如果你不需要标准库的某些部分,你并不被强制使用它们。还有一点是,有些平台可能不支持像抛出异常或动态分配内存这样的功能(例如Arduino中使用的处理器)。还有另外一件事你说错了。只要不是模板类,你就可以为自己的类添加swap运算符到std命名空间中。

C++与C不同,基本上你不能避免异常(程序“int main(){ new char[1000]; }”是有效的C++,不使用任何#include并且可以抛出异常)。自由存储分配也是核心语言的一部分。容器在独立实现中可能会缺失,只是因为实现者懒得使用其他东西。预先包含标准库(或独立实现中的实现定义部分)仍然是可能的。 - 6502
既然你提到了,我之前提到的AVR芯片确实支持抛出异常。但是,这样做的开销非常大。但就动态内存分配而言,这些芯片不支持它。(或者至少在几年前肯定不支持)。因此,明显的“malloc”等操作是通过在堆栈上保留(最大)内存区域来实现的。(而“free”等操作则是NOPs)。 - user4992621

4

首先,我担心前奏有点晚了。或者说,由于前奏不容易扩展,我们只能满足于一个非常简单的前奏(内置类型...)。

举个例子,假设我有一个C++03程序:

#include <boost/unordered_map.hpp>

using namespace std;
using boost::unordered_map;

static unordered_map<int, string> const Symbols = ...;

一切都运作良好,但突然我迁移到C++11时:
error: ambiguous symbol "unordered_map", do you mean:
- std::unordered_map
- boost::unordered_map

恭喜你,你发明了最不向后兼容的标准库增长方案(开个玩笑,谁用了using namespace std;就该受到责备……)。
好吧,我们不预先包含它们,但仍然捆绑完美哈希表。这样做可以获得性能提升,对吧?
嗯,我非常怀疑。首先是因为标准库与您包含的大多数其他头文件相比非常小(提示:将其与Boost进行比较)。因此,性能提升会很小。
哦,不是所有的程序都很大;但是小程序已经很快地编译好了(因为它们本身就很小),而大程序包含的代码比标准库头文件要多得多,因此您不会从中获得太多收益。 注意:是的,我在一个具有“仅”一百个-I指令的项目中对文件查找进行了基准测试;结论是预计算“包含路径”到“文件位置”的映射并将其提供给gcc可使其速度提高30%(已使用ccache)。生成它并保持其更新很复杂,所以我们从未使用过它......
但是我们至少可以在标准中包含一个编译器可以执行的条款吗?
据我所知,它已经被包含了。我不记得是否有关于此的具体说明,但是标准库实际上是“实现”的一部分,因此将#include <vector>解析为内部哈希映射将符合as-if规则。
但他们仍然可以这样做!
并失去任何灵活性。例如,Clang在Linux上可以使用libstdc++或libc++,我认为它与VC++附带的Dirkumware的派生版本兼容(如果不完全兼容,至少也很大程度上兼容)。
这是另一个自定义点:如果标准库不适合您的需求或平台,则凭借被视为任何其他库的优势,您可以相对轻松地替换其部分或全部内容。
但!但!
#include <stdafx.h>

如果你使用Windows,你会认识它。这被称为预编译头文件。必须首先包含它(否则所有优点都将丧失),并且作为交换,你不需要解析文件,而是引入已解析文件的高效二进制表示形式(即序列化的AST版本,可能已执行某些类型解析),这可以节省大约30%至50%的工作量。是的,这与你的建议接近;这就是计算机科学,总有其他人先想到了...
Clang和gcc有类似的机制;但据我所听,使用起来非常痛苦,人们在实践中更喜欢更透明的ccache。
所有这些努力都将毫无成果,因为有了模块。
这是解决这种预处理/解析/类型解析疯狂的真正解决方案。因为模块是真正隔离的(即不像头文件那样受包含顺序的影响),所以可以为你依赖的每个模块预先计算出有效的二进制表示形式(如预编译头文件)。
这不仅意味着标准库,还包括所有库。
你的解决方案更加灵活,穿着华丽!

抱歉,我的许多观点在这里得到了呈现。+1 但我认为我的演讲有用的不同之处在于:) - Steve Jessop
@SteveJessop:不需要道歉;事实上,我喜欢你的回答。我的回答更多地构建为模拟讨论,而不是想象中的任性程序员,我希望这使它有趣,但我完全承认它并不十分简洁。 - Matthieu M.
@6502:嗯,你的编辑大大澄清了你的问题,但我仍然认为我在我的答案的第一部分就回答了它。再次强调一下:带有前奏的语言不能轻易地随着时间的推移扩展该前奏,据我所知,所有语言都存在这个问题,你必须知道去哪里查找。这通常是IDE或良好编译器介入的地方;使用任何你想用的东西,它们会告诉你哪个文件/模块包含。 - Matthieu M.
@6502:我确实有点担心你的态度。 (1) 请保持礼貌,毕竟我们是在帮助你,(2) 避免评判你从未见过的项目。 - Matthieu M.

3

你可以使用与编译器附带的C++标准库不同的备选实现。或者使用自己的定义包装头文件,以添加、启用或禁用功能(请参见GNU包装器头文件)。普通文本头文件和C包含模型是比二进制黑盒更强大、更灵活的机制。


标准并没有考虑替换标准库的部分。实际上,程序不允许越过任何标准名称。实现可能会提供这种功能,但即使使用预先包含的标准库(例如,提供特定工具来打包序言),这仍将保持真实。 - 6502
我不是在谈论替换部件。C++标准库的便携式第三方实现已经存在并且已经被广泛使用了数十年(例如Apache stdcxx、STLPort等)。包装器头文件是至少两个编译器(gcc和clang)的众所周知的特性。这两种方法都是可行的,同时也可以实现黑盒方法无法实现或难以实现的用途。 - Liviu Nicoara
正式的角度来看,没有办法编写“C++标准库的第三方实现”。当然,大多数C++编译器确实使用常规文件进行标准包含...但就标准而言,可能存在一种编译器,您无法替换<vector>的实现,因为该实现已经硬编码在编译器中,实际上不存在任何包含文件。 - 6502
同时,这样的实现也存在。标准可以被理解为头文件是一个抽象的概念,可能没有任何永久存储。然而,考虑到语言的历史,这显然是一种牵强附会的解释。 - Liviu Nicoara

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接