一群template引发的血案:记一次非典型性链接错误的排查

这两天用到了一个名叫OpenVolumeMesh的C++库,用来处理四面体网格。此库不大,历史不长,近期开发也不甚活跃;但观其代码规整,显然是一群有组织有纪律的家伙搞出来的,并且貌似衍生于另外两个比较大牌的库(OpenMeshOpenFlipper),还是毅然决定入坑。

此库以模板为主要手段来实现不同类型网格上的代码重用。C++模板的特点是不出错时很爽,一旦出错就让人生不如死。此次我就是(毫不意外地)栽在了template上。

理想情况下我们想要编译器这么提示我们:“亲爱的程序员,您的程序这里有错,错误原因和解决方法我都给你标注好了,么么嗒~~~”但实际上有点困难,一般编译器只会这么提示我们:“吔屎啦,这里这里这里这里这里这里我都看不懂,赶紧给老娘改!”JAVA和javascript就喜欢这么干。然而,C++编译器一般是这样的:C++:”你叼是吧?你调啊?你调啊?怎么不调了?刚才不是很跳么?哎我就不明白了,你现在嘴长哪去了?刚才blablablabla不是很能说嘛?你现在倒是说说看程序错哪里了啊?“[引自知乎](https://www.zhihu.com/question/30806886)

下载,解压。看CMakeLists.txt,也没什么特别的。make一下,一次通过,不错。install一下,很规整的一个include目录和一个lib目录。

这种小众项目显然是没有官方的Find_XXXX支持的。于是动手写了一个,主要就是处理下includelink,然后新建一个项目,Hello world也通过了。

于是就开始用,写了一个解析器把gmsh生成的数据读进来,貌似一切都很顺利(官方还带了一个例子性质的小工具,用于把几种类型的网格文件读进来)。然后开始往里面加物理仿真用的数据,问题终于来了。

OpenVolumeMesh里面有一个功能是对每一个CellFaceVertex等附加自定义的Property,这个功能是用template实现的,比如:

CellPropertyT<double> phi = mesh.request_cell_property<double>("phi");

就是自动往每个Cell上附加一个名叫“Phi”的double型数据,后面可以通过phi对象的迭代器来访问,听起来很美。写好编译,报错:

问题特征1: 对不完整的类型CellPropertyT<double>的非法使用……有初始值但类型不完整

这种错误从来没见过,谷歌也说不出个所以然,大概就是类型不完整的意思。这个库实在是很小众,也搜不出关于这库的这个错误的专门讨论。自带文档里面又确实是这么写的。

于是开始在自带的例子里面搜索,确实有两个unit test涉及到了XXX_PropertyT。研究了一下,发现测试里面貌似比我多include了几个文件?于是把这几个头文件抄进来,再次尝试编译,错误变了:

问题特征2: 尝试加入了几个似是而非、相关但不必要的头文件之后,类型不完整的问题消失了,编译可以通过,但链接时提示“未定义的引用 CellPropertyT<double>::XXXX

这库编译出来的库文件只有一对,一个用于静态链接,另一个用于动态链接。明明已经链接上了,怎么会缺符号呢?nm看一下,C++那替换过的符号名很恶心,但仔细研究之后不得不承认,确实没有那几个符号。

这就奇怪了,按理说既然库文件和工程文件两边的编译都没有报错,那么这几个缺失的符号要么在库文件里,要么在工程文件里,怎么会两边都没有?

根据此时的信息,可以大概推断这个CellPropertyT<double>模板没有被实例化。但是想不出来为什么编译连个警告都没有的情况下,明明在cpp文件里用过的类型,为啥会没有被实例化呢?抱着死马当成活马医的心态,尝试了加入一句class CellPropertyT<double>;,结显然不行。

于是再研究单元测试。默认情况下编译的OpenVolumeMesh是不带单元测试的,因为编译测试需要 编译过的 googletest框架,但我都是直接include它来用的。于是这里我犯下了一个错误:图省事,把单元测试中的整套文件放到我自己的工程下试图编译,结果出现了一模一样的undefied reference

到这个时候,我已经开始怀疑这库本身有问题了:怎么可能自带的文件都编译不过去?于是去官网clone了最新的开发版,结局一样。

于是终于不能再懒下去了,老老实实拿了个gooletest编译成库,按的OpenVolumeMesh所要求的方式放好,用项目自带的CMakeLists.txt来编译,结果——完全正常。

至此,问题已经被定位在了编译方式上。打开编译单元测试的CMakeLists.txt,怎么也看不出什么猫腻,于是使出最后一招:在cmake时,将CMAKE_VERBOSE_MAKEFILE设置为TRUE

再次make,编译时会输出大量信息。刷屏太快可以不看,去build目录下找flags.makelink.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.cmakeinclude之,编译,问题解决!

说运气好,是因为这命令显然不是给连接器看的,而是给编译器看的。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++的模板系统过于强大,是**图灵完备**的——不幸的是,死循环也是图灵完备语言的功能之一。引入这个最大层次的概念之后,就可以在进入可能的死循环之后及时退出。

当然,退出完了之后连个提示都没有,确实是个坑。

  • 最后更改: 2019/05/27 13:11