为什么c++编译要花这么长时间?

与c#和Java相比,编译c++文件需要很长时间。编译一个c++文件比运行一个正常大小的Python脚本花费的时间要长得多。我目前使用vc++,但它与任何编译器是一样的。为什么会这样?

我能想到的两个原因是加载头文件和运行预处理器,但这似乎不能解释为什么需要这么长时间。

153377 次浏览

一些原因是:

1) c++语法比c#或Java更复杂,需要更多的时间来解析。

2)(更重要的是)c++编译器生成机器代码,并在编译期间进行所有优化。c#和Java只走了一半,将这些步骤留给JIT。

c++被编译成机器代码。所以你有预处理器,编译器,优化器,最后是汇编器,所有这些都必须运行。

Java和c#被编译成字节码/IL, Java虚拟机/。NET框架执行(或JIT编译成机器代码)之前执行。

Python是一种解释型语言,它也被编译成字节码。

我相信还有其他原因,但总的来说,不需要编译为本机机器语言可以节省时间。

另一个原因是使用C预处理器来定位声明。即使使用了头保护,.h仍然必须在每次包含它们时被反复解析。一些编译器支持预编译的头文件,可以帮助解决这个问题,但它们并不总是被使用。

参见:c++常见问题解答

编译语言总是比解释语言需要更大的初始开销。此外,也许您没有很好地组织您的c++代码。例如:

#include "BigClass.h"


class SmallClass
{
BigClass m_bigClass;
}

编译速度比:

class BigClass;


class SmallClass
{
BigClass* m_bigClass;
}

几个原因

头文件

每个编译单元都需要(1)加载和(2)编译数百甚至数千个头文件。 每个编译单元通常都需要重新编译它们, 因为预处理器确保编译头文件可能的结果在每个编译单元之间是不同的。 (宏可以在一个编译单元中定义,它会改变头文件的内容) 这可能是的主要原因,因为它需要为每个编译单元编译大量的代码, 此外,每个头文件都必须编译多次 (对包含它的每个编译单元执行一次)

链接

一旦编译完成,所有的目标文件都必须链接在一起。 这基本上是一个不能很好地并行化的整体过程,并且必须处理你的整个项目

解析

语法非常复杂,难以解析,严重依赖上下文,很难消除歧义。

模板

在c#中,List<T>是唯一被编译的类型,无论你的程序中有多少个List实例化。 在c++中,vector<int>是一个与vector<float>完全独立的类型,每个类型都必须单独编译 再加上模板构成了编译器必须解释的完整图灵完备“子语言”, 这可能会变得非常复杂。 即使相对简单的模板元编程代码也可以定义创建大量模板实例化的递归模板。 模板还可能导致极其复杂的类型,具有长得离谱的名称,给链接器增加了大量额外的工作。 (它必须比较大量的符号名称,如果这些名称可以增长到数千个字符,这可能会变得相当昂贵) 当然,它们加剧了头文件的问题,因为模板通常必须在头文件中定义, 这意味着必须为每个编译单元解析和编译更多的代码。 在纯C代码中,标头通常只包含前向声明,但实际代码很少。 在c++中,几乎所有的代码都驻留在头文件中是很常见的

优化

c++允许进行一些非常戏剧化的优化。 c#或Java不允许完全消除类(为了反射的目的,它们必须存在), 但即使是一个简单的c++模板元程序也可以很容易地生成几十个或数百个类, 所有这些都在优化阶段被内联并再次消除 此外,c++程序必须由编译器完全优化。 c#程序可以依赖JIT编译器在加载时执行额外的优化, c++没有任何这样的“第二次机会”。编译器生成的是它将要得到的优化

c++被编译为机器代码,这可能比Java或. net使用的字节码更复杂(特别是在x86的情况下)。 (这只是出于完整性而提到的,因为它是在评论中提到的。 实际上,这一步所花费的时间不太可能超过编译总时间的一小部分)

结论

这些因素中的大多数是由C代码共享的,这实际上是相当有效的编译。 在c++中,解析步骤要复杂得多,可能会占用更多的时间,但主要的问题可能是模板。 它们很有用,并使c++成为一种更强大的语言,但它们也在编译速度方面付出了代价

任何编译器的减速都不一定相同。

我没有使用过Delphi或Kylix,但在MS-DOS时代,Turbo Pascal程序几乎可以立即编译,而等效的Turbo c++程序只能爬行。

两个主要的区别是一个非常强大的模块系统和允许单次编译的语法。

编译速度当然可能不是c++编译器开发人员的优先考虑事项,但C/ c++语法中也有一些固有的复杂性,这使得处理起来更加困难。(我不是C方面的专家,但Walter Bright是,在构建了各种商业C/ c++编译器之后,他创建了D语言。他的一个变化是为了加强上下文无关的语法,使语言更容易解析。)

此外,您还会注意到,makefile通常设置为每个文件都单独用C编译,因此如果10个源文件都使用相同的包含文件,则该包含文件将被处理10次。

解析和代码生成实际上相当快。真正的问题是打开和关闭文件。记住,即使使用include守卫,编译器仍然打开. h文件,读取每一行(然后忽略它)。

有一次,我的一个朋友(在工作无聊的时候)把他公司的应用程序——所有的源文件和头文件——放到一个大文件中。编译时间从3小时下降到7分钟。

你得到的代价是程序运行得稍微快一点。在开发期间,这对您来说可能是一种冷漠的安慰,但一旦开发完成,并且程序只是由用户运行时,它就会变得非常重要。

最大的问题是:

1)无限头解析。已经提到过。缓解(如#pragma once)通常只适用于每个编译单元,而不是每个构建。

2)事实上,工具链经常被分离成多个二进制文件(make、预处理器、编译器、汇编器、归档器、impdef、链接器和dll工具),这些二进制文件必须在每次调用(编译器、汇编器)或每一对文件(归档器、链接器和dll工具)时重新初始化和重新加载所有状态。

另请参阅关于comp.compilers: http://compilers.iecc.com/comparch/article/03-11-078的讨论,特别是这个:

http://compilers.iecc.com/comparch/article/02-07-128

请注意,comp.compilers的主持人John似乎也同意这一点,这意味着如果完全集成工具链并实现预编译的头文件,那么C语言也应该可以达到类似的速度。许多商业C编译器在某种程度上都这样做。

请注意,Unix将所有内容分解为单独的二进制文件的模型对于Windows来说是一种最坏的情况模型(其进程创建缓慢)。在比较Windows和*nix之间的GCC构建时间时,这是非常明显的,特别是当make/configure系统还调用一些程序只是为了获取信息时。

大多数答案都不清楚,因为c#总是运行得更慢,因为执行c++中只在编译时执行一次的操作的成本,这种性能成本也受到运行时依赖性的影响(要加载更多的东西才能运行),更不用说c#程序总是会有更高的内存占用,所有这些都导致性能与可用硬件的能力更密切相关。对于其他解释性语言或依赖于VM的语言也是如此。

在大型c++项目中减少编译时间的一个简单方法是创建一个包含项目中所有cpp文件的*.cpp包含文件并编译该文件。这将头爆炸问题减少到一次。这样做的好处是,编译错误仍然会引用正确的文件。

例如,假设你有a.cpp, b.cpp和c.cpp。创建一个文件:everything.cpp:

#include "a.cpp"
#include "b.cpp"
#include "c.cpp"

然后通过将everything.cpp编译项目

构建C/ c++:到底发生了什么,为什么花了这么长时间

软件开发中有相当大一部分时间不是花在编写、运行、调试甚至设计代码上,而是花在等待代码完成编译上。 为了让事情变得更快,我们首先必须理解编译C/ c++软件时发生了什么。步骤大致如下:

  • 配置
  • 构建工具启动
  • 依赖项检查
  • 编译
  • 链接

现在,我们将更详细地查看每个步骤,重点关注如何使它们更快。

配置

这是开始构建的第一步。通常意味着运行配置脚本或CMake、Gyp、SCons或其他工具。对于非常大的基于autotools的配置脚本,这可能需要一秒钟到几分钟的时间。

这一步很少发生。它只需要在更改配置或更改构建配置时运行。如果不改变构建系统,就没有多少事情可以加快这一步。

构建工具启动

这是在IDE上运行make或单击构建图标(通常是make的别名)时发生的情况。构建工具二进制文件启动并读取其配置文件以及构建配置,这通常是同一件事。

根据构建的复杂性和大小,这可能需要几秒到几秒的时间。这本身并没有那么糟糕。不幸的是,大多数基于make的构建系统在每次构建时都会调用几十到几百次make。这通常是由递归使用make(这是不好的)引起的。

应该注意的是,Make如此缓慢的原因并不是实现错误。Makefiles的语法有一些怪癖,使得真正快速的实现几乎不可能。当与下一步结合使用时,这个问题会更加明显。

依赖项检查

一旦构建工具读取了它的配置,它必须确定哪些文件已经更改,哪些文件需要重新编译。配置文件包含一个描述构建依赖关系的有向无循环图。这个图通常在配置步骤中构建。 构建工具启动时间和依赖项扫描程序将在每个构建上运行。它们的组合运行时决定了编辑-编译-调试周期的下界。对于小型项目,这个时间通常是几秒钟左右。这是可以忍受的。 我们有其他选择。其中最快的是Ninja,它是由谷歌的工程师为Chromium开发的。 如果你使用CMake或Gyp构建,只需切换到他们的忍者后端。你不需要改变构建文件本身的任何内容,只需要享受速度的提升。不过,大多数发行版都没有打包Ninja,所以你可能需要自己安装它

编译

此时,我们最终调用编译器。省事起见,以下是大致采取的步骤。

  • 合并包括
  • 解析代码
  • 代码生成和优化

与流行的观点相反,编译c++实际上并没有那么慢。STL很慢,大多数用于编译c++的构建工具都很慢。然而,有更快的工具和方法来减轻语言中缓慢的部分。

使用它们需要一些体力,但好处是不可否认的。更快的构建时间会让开发人员更快乐,更敏捷,并最终产生更好的代码。

我能想到有两个问题可能会影响c++程序的编译速度。

可能的问题#1 -编译头文件:(这可能已经被另一个答案或评论解决了,也可能没有。)Microsoft Visual c++ (A.K.A. vc++)支持预编译头文件,我强烈推荐。当您创建一个新项目并选择要制作的程序类型时,屏幕上将出现一个设置向导窗口。如果你点击它底部的“Next >”按钮,窗口将带你进入一个有几个功能列表的页面;确保“Precompiled header”选项旁边的复选框被选中。(注:这是我使用c++中的Win32控制台应用程序的经验,但这可能不适用于c++中的所有程序。)

今年夏天,我上了一门编程课程,我们必须把所有的项目都存储在8GB的闪存驱动器上,因为我们实验室里的电脑每天午夜都会被擦除,这将会擦除我们所有的工作。如果你是出于可移植性/安全性等考虑而编译到外部存储设备。,它可能需要很长时间的时间(即使使用我上面提到的预编译头)来编译您的程序,特别是如果它是一个相当大的程序。在这种情况下,我给你的建议是在你正在使用的计算机的硬盘上创建和编译程序,无论什么时候你想要/需要停止你的项目,无论什么原因,将它们转移到你的外部存储设备,然后点击“安全移除硬件和弹出媒体”图标,它应该出现在一个小的闪存驱动器后面,上面有一个白色的检查标记,断开它。

我希望这对你有帮助;如果有,请告诉我!:)

在大型面向对象项目中,重要的原因是c++很难限制依赖关系。

私有函数需要在它们各自的类的public头文件中列出,这使得依赖关系比它们需要的更具传递性(传染性):

// Ugly private dependencies
#include <map>
#include <list>
#include <chrono>
#include <stdio.h>
#include <Internal/SecretArea.h>
#include <ThirdParty/GodObjectFactory.h>


class ICantHelpButShowMyPrivatePartsSorry
{
public:
int facade(int);


private:
std::map<int, int> implementation_detail_1(std::list<int>);
std::chrono::years implementation_detail_2(FILE*);
Intern::SecretArea implementation_detail_3(const GodObjectFactory&);
};

如果这个模式被愉快地重复到头的依赖树中,这将倾向于创建一些“神头”。间接地包括项目中所有头文件的大部分。它们和上帝的对象一样无所不知,只是在绘制它们的包含树之前,这一点并不明显。

这会以两种方式增加编译时间:

  1. 它们添加到包含它们的每个编译单元(.cpp文件)的代码量很容易比cpp文件本身多很多倍。从这个角度来看,catch2.hpp是18000行,而大多数人(甚至是ide)在编辑超过1000-10000行的文件时开始遇到困难。
  2. 编辑头文件时必须重新编译的文件数量不包含在依赖它的真实文件集中。

是的,有一些缓解措施,比如前向声明、哪个已经察觉到了缺点pimpl成语,这是非零成本抽象。尽管c++在你能做的事情上是无限的,但如果你偏离了它的本意,你的同事会想知道你到底在吸什么。

最糟糕的是:如果你仔细想想,在它们的公共头中声明私有函数的需求甚至是不必要的:成员函数的道德等效可以在C中被模仿,而且通常也被模仿,这不会重现这个问题。

简单地回答这个问题,c++是一种比市场上其他可用语言复杂得多的语言。它有一个遗留的包含模型,可以多次解析代码,并且它的模板库没有针对编译速度进行优化。

语法和ADL

让我们通过一个非常简单的例子来看看c++的语法复杂性:

x*y;

虽然你可能会说上面是一个带有乘法的表达式,但在c++中不一定是这样。如果x是一个类型,那么该语句实际上是一个指针声明。这意味着c++语法是上下文敏感的。

下面是另一个例子:

foo<x> a;

同样,您可能认为这是变量"a"的声明;类型为foo,但它也可以被解释为:

(foo < x) > a;

这将使它成为比较表达式。

c++有一个叫做参数依赖查找(ADL)的特性。ADL建立规则来控制编译器如何查找名称。考虑下面的例子:

namespace A{
struct Aa{};
void foo(Aa arg);
}
namespace B{
struct Bb{};
void foo(A::Aa arg, Bb arg2);
}
namespace C{
struct Cc{};
void foo(A::Aa arg, B::Bb arg2, C::Cc arg3);
}


foo(A::Aa{}, B::Bb{}, C::Cc{});

ADL规则规定,我们将寻找名称“;food "考虑函数调用的所有参数。在这种情况下,将考虑所有名为“foo”的函数进行重载解析。这个过程可能需要时间,特别是如果有很多函数重载。在模板化上下文中,ADL规则变得更加复杂。

# include

这个命令可能会极大地影响编译时间。根据所包含文件的类型,预处理器可能只复制几行代码,也可能复制数千行。

此外,编译器不能优化此命令。如果头文件依赖于宏,则可以复制可以在包含前修改的不同代码段。

对于这些问题,有一些解决方案。您可以使用预编译头文件,这是编译器在头文件中解析内容的内部表示。然而,这离不开用户的努力,因为预编译的头文件假定头文件不依赖于宏。

模块特性为这个问题提供了语言级的解决方案。它可以从c++ 20版本开始使用。

模板

模板的编译速度具有挑战性。每个使用模板的翻译单元都需要包含这些模板,并且需要提供这些模板的定义。一些模板的实例化最终在其他模板的实例化中结束。在某些极端情况下,模板实例化可能会消耗大量资源。一个使用模板的库,而不是为编译速度而设计的库可能会变得很麻烦,正如您可以在这个链接中看到的元编程库的比较:http://metaben.ch/。它们在编译速度上的差异是显著的。

如果您想了解为什么一些元编程库在编译时间上比其他库更好,请查看这个关于切尔法则的视频

结论

c++是一种编译缓慢的语言,因为编译性能在该语言最初开发时并不是最高优先级。结果,c++的特性在运行时可能有效,但在编译时不一定有效。

附注:我在Incredibuild工作,这是一家软件开发加速公司,专注于加速c++编译,欢迎您访问免费试用