# 一群template引发的血案:记一次非典型性链接错误的排查 ## 背景 这两天用到了一个名叫[OpenVolumeMesh](https://www.openvolumemesh.org/)的C++库,用来处理四面体网格。此库不大,历史不长,近期开发也不甚活跃;但观其代码规整,显然是一群有组织有纪律的家伙搞出来的,并且貌似衍生于另外两个比较大牌的库(`OpenMesh`与`OpenFlipper`),还是毅然决定入坑。 此库以模板为主要手段来实现不同类型网格上的代码重用。C++模板的特点是不出错时很爽,一旦出错就让人生不如死。此次我就是(毫不意外地)栽在了`template`上。 > 理想情况下我们想要编译器这么提示我们:“亲爱的程序员,您的程序这里有错,错误原因和解决方法我都给你标注好了,么么嗒~~~” > 但实际上有点困难,一般编译器只会这么提示我们:“吔屎啦,这里这里这里这里这里这里我都看不懂,赶紧给老娘改!” > JAVA和javascript就喜欢这么干。 > 然而,C++编译器一般是这样的: > C++:”你叼是吧?你调啊?你调啊?怎么不调了?刚才不是很跳么?哎我就不明白了,你现在嘴长哪去了?刚才blablablabla不是很能说嘛?你现在倒是说说看程序错哪里了啊?“ > [引自知乎](https://www.zhihu.com/question/30806886) ## 过程 下载,解压。看CMakeLists.txt,也没什么特别的。`make`一下,一次通过,不错。`install`一下,很规整的一个`include`目录和一个`lib`目录。 这种小众项目显然是没有官方的`Find_XXXX`支持的。于是动手写了一个,主要就是处理下`include`和`link`,然后新建一个项目,`Hello world`也通过了。 于是就开始用,写了一个解析器把`gmsh`生成的数据读进来,貌似一切都很顺利(官方还带了一个例子性质的小工具,用于把几种类型的网格文件读进来)。然后开始往里面加物理仿真用的数据,问题终于来了。 `OpenVolumeMesh`里面有一个功能是对每一个`Cell`,`Face`,`Vertex`等附加自定义的`Property`,这个功能是用`template`实现的,比如: ``` CellPropertyT phi = mesh.request_cell_property("phi"); ``` 就是自动往每个`Cell`上附加一个名叫“Phi”的`double`型数据,后面可以通过`phi`对象的迭代器来访问,听起来很美。写好编译,报错: *问题特征1: 对不完整的类型`CellPropertyT`的非法使用……有初始值但类型不完整* 这种错误从来没见过,谷歌也说不出个所以然,大概就是类型不完整的意思。这个库实在是很小众,也搜不出关于这库的这个错误的专门讨论。自带文档里面又确实是这么写的。 于是开始在自带的例子里面搜索,确实有两个`unit test`涉及到了`XXX_PropertyT`。研究了一下,发现测试里面貌似比我多`include`了几个文件?于是把这几个头文件抄进来,再次尝试编译,错误变了: *问题特征2: 尝试加入了几个似是而非、相关但不必要的头文件之后,类型不完整的问题消失了,编译可以通过,但链接时提示“未定义的引用 `CellPropertyT::XXXX`”* 这库编译出来的库文件只有一对,一个用于静态链接,另一个用于动态链接。明明已经链接上了,怎么会缺符号呢?`nm`看一下,C++那替换过的符号名很恶心,但仔细研究之后不得不承认,确实没有那几个符号。 这就奇怪了,按理说既然库文件和工程文件两边的编译都没有报错,那么这几个缺失的符号要么在库文件里,要么在工程文件里,怎么会两边都没有? 根据此时的信息,可以大概推断这个`CellPropertyT`模板没有被实例化。但是想不出来为什么编译连个警告都没有的情况下,明明在`cpp`文件里用过的类型,为啥会没有被实例化呢?抱着死马当成活马医的心态,尝试了加入一句`class CellPropertyT;`,结显然不行。 于是再研究单元测试。默认情况下编译的`OpenVolumeMesh`是不带单元测试的,因为编译测试需要 **编译过的** `googletest`框架,但我都是直接`include`它来用的。于是这里我犯下了一个错误:图省事,把单元测试中的整套文件放到我自己的工程下试图编译,结果出现了一模一样的`undefied reference`。 到这个时候,我已经开始怀疑这库本身有问题了:怎么可能自带的文件都编译不过去?于是去官网`clone`了最新的开发版,结局一样。 于是终于不能再懒下去了,老老实实拿了个`gooletest`编译成库,按的`OpenVolumeMesh`所要求的方式放好,用项目自带的`CMakeLists.txt`来编译,结果——完全正常。 至此,问题已经被定位在了编译方式上。打开编译单元测试的`CMakeLists.txt`,怎么也看不出什么猫腻,于是使出最后一招:在cmake时,将`CMAKE_VERBOSE_MAKEFILE`设置为`TRUE`。 再次`make`,编译时会输出大量信息。刷屏太快可以不看,去`build`目录下找`flags.make`和`link.txt`可以看到编译时实际发送给`gcc`的命令。 由于是链接时的报错,我先打开了对应于单元测试目录的`link.txt`。倒霉了一下午,此时终于交上了好运,一眼就发现了可疑内容: ``` /usr/bin/c++ -O3 -DNDEBUG -DINCLUDE_TEMPLATES -ftemplate-depth-100 -W -Wall -Wno-unused -rdynamic ... ``` 从来没见过`-ftemplate-depth-100`一类的东西,看起来像是限制模板深度的内容。在我自己的工程的编译目录下`grep`一下,绝对没有类似内容。回到`OpenVolumeMesh`源码目录,`grep`发现有一个`.cmake`文件包含了好几条`-ftemplate-depth-100`,打开一看,是附加的各种编译器选项,于是拷贝之,在最初自己写的`FindOpenVolumeMesh.cmake`中`include`之,编译,问题解决! ## 总结 说运气好,是因为这命令显然不是给连接器看的,而是给编译器看的。`flags.make`文件中也有它,那才是它发挥作用的地方。 命令的意义不出所料,就是限制模板的最大深度,超过此深度的模板不予实例化。 > Set the maximum instantiation depth for template classes to n. A limit on the template instantiation depth is needed to detect endless recursions during template class instantiation. ANSI/ISO C++ conforming programs must not rely on a maximum depth greater than 17. 然而平时编译`boost`也没看见这个选项用默认值出问题,可见`OpenVolumeMesh`的模板真的是相当复杂。 这也解释了为啥多包含某些文件可以缓解问题(从“不完整的类”到“为定义的引用”),大概是附加的引用文件是模板链条的中间位置,所以包含它之后又多编译了几层? 引入此机制的原因,是因为 **C++的模板系统过于强大**,是[**图灵完备**](https://zh.wikipedia.org/zh-cn/%E5%9C%96%E9%9D%88%E5%AE%8C%E5%82%99%E6%80%A7)的——不幸的是,死循环也是图灵完备语言的功能之一。引入这个最大层次的概念之后,就可以在进入可能的死循环之后及时退出。 当然,退出完了之后连个提示都没有,确实是个坑。 ## 相关资料 [[翻译]看老夫如何调戏C++编译器](https://kelvinh.github.io/blog/2014/03/30/how-to-abuse-a-cpp-compiler/) [C++模板的图灵完备](http://sighingnow.github.io/%E7%BC%96%E7%A8%8B%E8%AF%AD%E8%A8%80/template_turing_completeness.html)