<一句话概括C++工程开发>

由多个翻译(编译)单元经C++编译器编译后产生多个独立目标二进制文件(游离态object),通过链接器将其主线上所需的代码符号绑定组合成最终的二进制程序。

或者还有另一种表述:C++工程开发 是一种“以符号链接为核心”的分布式编译模型。

注:上述讨论仅限于传统C++工程开发中所述obj它是游离态的说法,关于C++20 Moduels和全程序优化(或LTCG链接期延迟编译)本篇不作讨论。

<VS中的编译单元>

编译单元的别名是翻译单元(Translation Unit)简称TU

在VS-IDE中默认情况下它按照后缀来区分(".c"、".cpp"等)编译单元,因为它们产生了obj,而其他的后缀文件没有,注意我这里说的是默认情况;

事实上C++标准是没有提其什么后缀的才能作为编译单元,标准也从未规定:

源文件必须叫 .cpp
头文件必须叫 .h
或 编译单元必须来自某种后缀

故以上这些都是工具链层面的约定

[.filters配置文件中看到的过滤器后缀]

当然如果你打开VS工程中的.filters文件似乎有看到它好像约定了一些后缀

会让人误以为在“源文件”这个(虚拟目录)过滤器中的才能放".cpp"等能成为编译单元的文件

其实这个过滤器算是VC6.0上古遗留方案(当时还没允许你在任意指定虚拟目录内右键新建文件的功能),它这一设定是让你在解决方案管理器中对当前C++项目(也就是顶级虚拟项)右键进行新建时即用这些规定的后缀它就自动落到哪个虚拟目录内

PS:现这些"筛选器"名称是叫什么没有根本的差别,它的作用完全就只是虚拟目录仅用来作形式上的分类(而且也不对应实际的工程目录,任何物理路径中的文件都可以加进去)

[在VS中真正视为编译单元的设定]

默认情况下确实是 ".cpp .c"等后缀 当作编译单元(无需额外加上任何配置的情况下),验证方法看其编译后是否产生obj文件

 .h的并没有产生obj

现在我们希望.h变为编译单元,

先对其右键属性后看到项类型为“C/C++ 标头”

然后这个是.cpp的

接着把.h的项类型改为"C/C++编译器"

再次编译后就出来了

回看.vcxproj配置文件,这个标头前面的节点类型变为了ClCompile

所以我们认定但凡写进ClCompile的才能是编译单元,当然你还能打开实际的cl.exe(VC编译器主程序)的命令行日志(在生成版本的项目名.tlog的目录内)

当".h文件"甚至".cpp文件"不被当作ClCompile时这里边不会传递这个"标头.h"作为独立的编译单元进行命令行编译

PS:当然为了后续表述的习惯性,我们通常就把源文件(默认后缀为".cpp、.cxx、.c"等)认定为编译单元文件,所以下面有提其到源文件的均等同于编译单元文件或编译单元的等价习惯性称呼。

 

[#include的作用]

它的本质(别想那么多)就只是对当前文件的#include处读入文件进行字符串插入,一个标头文件(不被写进ClCompile的那种)它自身最终到底会不会成为编译单元看被引用的地方它本身是不是编译单元,如果是那么它被直接或间接地#include后将直接成为目标源文件中编译单元的一部分

另外它分""和<>,前者是在当前目录下找,后者是在VS项目指定的全局包含目录中找,

VC++目录这里通常是系统级的一般不会去改动它,还有个用户级的叫“附加包含目录”

它的作用就是如意思上的附加,等价于上边的“包含目录”,都能被#include <...> 去检索

更多VC++中的目录详解可以看:VC++的目录(包含目录、库目录、附加包含目录、附加依赖项).mht

 

 

<两种传统C++开发模式>

[传统C++开发引出编译单元的符号]

传统C++(非C++20 Modules)它的开发机制是先把编译单元的文件编译到obj,一般头文件只写其声明(源文件与头文件分离),而后链接期根据主线内所给的各个头文件所声明要绑定哪些符号从而需要寻找并正确链接到各自游离的obj上。

但要注意:如果符号在多个obj中能被找到将在链接期报“符号重定义”的错误,如果未能找到则报“无法解析的外部符号”

那哪些代码会成为C++的编译单元符号?

1. 函数(Functions)
2. 全局变量(Global Variables)
3. 静态对象(Static Storage Objects)
4. 类的成员函数(Class Methods)
5. 虚函数表和运行时类型识别信息(vtable、RTTI Info)
6. 模板实例化(Template Instantiation)
7. inline 函数(可能产生特殊符号)

补充:关于第6点模板本身不会直接生成符号,只有模板实例化之后才会成为编译单元的一部分进而产生符号(否则没法确立具体的类型信息和内存模型)
关于第7点,我上边只是说可能产生特殊符号,但实际导出符号经过验证后其实跟普通函数没有差别(只是section节的属性值差别),下面再详细讨论。

 

[含声明+定义单头文件的C++开发模式引入]

而当C++引入了模板后,传统的分离写法会失效,因为模板不具备编译单元(即便它在.cpp之类源文件中),所以后期的C++工程一般都习惯性只写声明跟定义都在一个单头文件中——也就是.hpp那种形式(它与.h后缀并无任何区别,只是对其明面上的概念区分,它依旧没有脱离传统C++开发体系),

但这种混合模式也有一定弊端,如果有多个源文件它们均#include了同个hpp,如果里边是纯模板那也还好,但如果是非模板函数也就是传统可引出编译单元符号的函数就会造成符号重定义的问题

在C++标准中有一条规则叫做ODR(One Definition Rule)即单一定义规则:

在整个程序中,一个具有外部链接的实体必须只有一个定义

那么在现代C++中的若还想完全使用单文件引入的模式(毕竟分离式写法两头都维护起来较为麻烦),有一个解决方案是对函数前面加"inline"关键字,表示其跨编译单元具备符号的唯一选择性。

注:inline 的历史其实可以分为 三个阶段,它的语义在 C++ 发展过程中发生了明显变化。
 

 


此外在C++链接器中有个叫COMDAT(Common Data)的机制

具体技术细节做法就是同个符号代码/数据可以允许先都编译(这个本质上就是我一开始提到的分布式编译下obj游离的特性,这其实跟COMDAT机制无关),然后该机制真正要做的是在链接期通过判断该符号是否标记为COMDAT后(当然实际不叫COMDAT),可采取某种策略方可进行符号合并(如:任选其一或是必须检测由编译后的字节内容完全相同)

这里给出一个工程示例:

 ,

dumpbin.exe工具对编译出的main.obj进行查看(dumpbin.exe /all /rawdata:none main.obj),
注:我下面查看main.obj只是看这一个编译单元,源.cpp、源1.cpp的obj并没有去看,但你还要知道它们是不同的编译单元,并非main.cpp包含了一切(test函数现在在main函数中是只有声明,假若在main.cpp连声明都没有,事实上在"源.cpp"编译出"源.obj"依旧还是会有test的定义二进制代码,所以符合编译单元编译出的obj各自游离的状态)

可以看到mymain是直接在本源文件中的定义(在SECT7中),func这个因为#include了标头.hpp,所以它也能够找到定义(事实上#include过来那种本质上没有区别,都是隶属于一个编译单元中的)
而test这个符号(在main.obj中)是UNDEF类别,那么它就只是一个声明的待定态,需要在链接期才能确定符号绑定

接着我们再看到main.obj的section5部分

这里func(它是inline函数)标记为COMDAT

但如果我们再看mymain(main.cpp中的已被定义的普通函数)在section7中

这里也是被标记COMDAT并没看出有什么差别啊(说好的selection type呢?)

其实这个问题它藏在“COFF SYMBOL TABLE”中

在看到含有 section 5(注意是section不是selection,其缩写是SECT,中文为"节") 的描述部分,它是把整个节5(mymain函数所在的节)作为一个定性的selection=2(即pick any属性)印证了inline函数它的特性就是在多个编译单元中可任意选其一

而func这个函数(节7)它的selection=1(pick no duplicates),具体的链接器含义是禁止重复(重定义会报错)

更权威的解释参考微软官方文档(下拉一点找到COMDAT Sections):
https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#auxiliary-format-5-section-definitions

总结一下:
以上案例中
1. mymain直接在main.cpp中既是声明也是定义,且也只有一个该符号被编译进main.obj中

2. 一个标头.hpp的头文件中定义的func函数但被3个源文件(编译单元)所包含(本质是func现在已有三份代码),在链接时如果发现它是可以被"ANY"任一选择的(即inline关键字的作用)那么链接不会报符号重定义的错误。

3. test在main.cpp中仅是声明故在main.obj的分析中可以看出它是被标识为UNDEF且External(事实上所有的全局函数都会是External即便没写extern关键字作为声明——这个关于全局变量的对比后面章节会提到),而源.cpp有test符号的定义所以最后被正确链接上。

另外,static和匿名命名空间也能解决ODR问题,但它们的实现方式跟inline的解法还不太一样,如果没有全程序的链接期优化技术它们或多会残留进每个编译单元,即都含有各自的内部静态副本,最终的体积当然是会激增了,限于篇幅这里就不多展开了,感兴趣的再自己查资料和做实验。

 

[#pragma once真的管用吗?]

对于同一个编译单元#pragma once通常是管用的,但跨编译单元不一定。

在该预处理指令还没出之前,一般情况下程序员会随机生成一个保证唯一不冲突的字符串,然后对该工程打上条件预处理宏判断,类似于下面

#ifndef __HEAD_123456__
#define __HEAD_123456__
...
#endif

不过现代编译器#pragma once它内部一定不是这种宏的实现形式,只需维护一个类似hash表的结构就可以了,但二者实际功能的效果是等价的。

在一个编译单元中,a.hpp有fa()的定义,b.hpp中包含a.hpp的同时有fb()的定义(注意二者都不是inline函数),且假定a.hpo和b.hpp都已经打上了"#pragma once",然后main.cpp同时引入a.hpp、b.hpp;

由于都在同一个编译单元中,编译器但凡看到一次"#if已经def __HEAD_123456__"则不会再产生包在条件宏中的代码,故fa()、fb()的定义在最终main.cpp中的插入就只有一份代码;

但如果是跨编译单元,#pragma once可管不到多个编译单元中,比如现在还有一个test.cpp(也引入了a.hpp),现在各源文件其实都会同时包含a.hpp也都有fa()的定义,那么在链接期依旧会报符号重定义的问题!

如果a.hpp、b.hpp当中没有做符号定义实现的话,而只有声明,那么即便跨编译单元存在多份声明那也是完全OK的

而如果一旦要实现定义了(被多处的多个源文件所#include)那么跨编译单元务必要使用inline函数,才能安全最终在连接阶段通过COMDAT机制来安全生成可执行文件!

 


<类方法和全局变量编译单元符号重定义问题>

[C++类中的方法似乎不会报符号重定义?]

不知道你有没有发现感觉上好像只有全局函数那种会报符号重定义,但类中去定义方法好像就不会了..

我们先来上个例子吧,

其他的源文件都跟之前一样,会包含该标头.hpp

而后我只在main.cpp中去使用这个类使之成为实例化的对象,并调用其中的zzz方法

编译没有问题,也没有报任何符号重定义的错误,确实给人感觉在类中定义的成员方法就是跟全局函数不太一样

那如果 源.cpp 中也使用该标头的MyClass呢?(这样MyClass::zzz就一定有多个副本定义了)

这样也没问题,那凭什么只有全局函数会报符号重定义而类中方法不会?之前不是说类中方法也会导出符号不是吗?

是的,类中方法确实会导出符号,但你大概能够猜到类中方法它或许也是跟inline一样的作用

先定位obj中的zzz(其实符号这个也在COFF SYMBOL TABLE条目中,看这个其实更加直观它是导出入口声明)

(在SECT5)

最后可在COFF SYMBOL TABLE中定位到它的节selection属性为pick any

至此水落石出,类中方法默认就是inline特性(selection 2即pick any)

注意我这里说的是默认,其实也有改变它为no duplicates特性(不允许重定义),那是加extern关键字吗?其实加了并没什么卵用(在类方法中加了C++语法上直接报错这是不给加),而且extern的语义也并不是用来干这个的

方案是分离式写法(至于分离给其他编译单元中去实现也是可以的,只要拿到该类的声明再自己定义就是了)

我这里只是想演示说分离式写法后zzz就重新变回一个(从selection特性上来看)它就是跟全局函数一样拥有no duplicates特性

所以如果你仍然要坚持在一个单源文件中写分离的话,对于外部的那种类成员方法的定义一样也要加inline关键字去规避ODR问题,而传统声明头和源文件那种双分离做法就更不会有问题则不需要加inline。

注:关于类的inline导出其实还存在一些问题,后面我还继续补充写了关于导出整个C++类.mht

 

 

[C++中的全局变量]

全局变量不同于函数,它的常规形态你看似是声明式的写法实际上它既是声明也是定义,比如

那不对啊,如果是这样全局变量无论怎样写似乎都是在本编译单元中直接定义了,那我该怎么搞成多个声明一个定义?

C++标准中实际规定了所有的全局函数不需要显式声明extern(表示这是一个外部链接的符号),函数它默认就是extern声明,但全局变量默认情况下它你如果只字面书写“类型+空格+变量名”的形式它不代表声明而是直接定义(值为内存全0),要写成全局变量的声明则必须要加上extern关键字

所以这样才是全局变量的声明,而后该标头被多个C++源文件所包含,那现在全局变量的定义该去哪写呢?

这个无所谓,只要整个程序中定义只有一处即可(即链接时多个obj中只能找到一份),
这里我就随便写在源1.cpp中

这里我是显式给了123,若不给初值(只写分号那种)刚说了它也是定义(值为全0)

到现代C++17标准中,才正式引入了inline关键字支持到全局变量的用法,它才能像之前函数一样(函数的inline语义是早在C++1998就支持的特性)解决ODR问题

现在删一下之前 源1.cpp 中的全局定义int g = 123(不然下面报错)

然后在一个标头中使用inline关键字去定义全局变量

然后还有一个符号重定义问题,那就是一个编译单元里边确实不能再进行同个符号的定义:

因为标头里边已经给定义了,这里依旧是同个编译单元,肯定不能再定义

事实上任何含有引入了标头.hpp的源文件也都不能再定义g了
如果要定义,那么我这里源.cpp我就不引入这个标头

现在确实已经可以正常编译链接了!

但其实这里还隐含出一个新问题,inline可以解决单一符号重定义问题那初始化应该最终会怎么样?也就是信谁或谁先谁后的问题?

从结果来看是10

当然你可以说它是any,但假若它是一个函数调用呢?

所以这里就又牵扯到另一个C++开发中的问题了——C++标准不保证不同 TU 之间的静态对象(包括全局变量)初始化顺序,总而言之这是个未定义行为,特别是全局对象的构造函数中还有如下更隐蔽问题的例子:

不过介于篇幅有限,这里就不继续展开说解决方案了,感兴趣可以自行再深入研究,

写到最后:

读者看到这已经大致了解C++开发模式的现状了,也足矣应对大部分C++工程开发解决符号重定义的问题!