一群template引发的血案:记一次非典型性链接错误的排查
背景
这两天用到了一个名叫OpenVolumeMesh的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<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.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++的模板系统过于强大,是**图灵完备**的——不幸的是,死循环也是图灵完备语言的功能之一。引入这个最大层次的概念之后,就可以在进入可能的死循环之后及时退出。
当然,退出完了之后连个提示都没有,确实是个坑。