std::any vs any_ref
众所周知, std::any是一个类型擦除的容器. 相比void*的, 它的主要区别就是:
any_cast会保证类型安全.- 有明确的所有权语义, 会自己管理被容纳对象的析构, 拷贝等操作. 而
void*的所有权语义不明.
非所有权的类型擦除引用
如果我们把上述的第二点改为明确的无所有权语义, 那就得到了any_ref. 类似于std::function和std::function_ref的区别.
不过, C++标准库中目前还没有any_ref. 在一些第三方库中能见到.
实现any_ref
any_ref的实现要比std::any简单很多, 因为不需要管理对象的生命周期, 实际上可以看做是void*的一个简单wrapper. 相比之下, std::any需要一个类似虚表的结构, 存一些函数指针, 用于析构, 拷贝, move等操作. 一般来说, any_ref只需要存一个void*指针, 和一个用来辨别类型的变量.
无RTTI实现
如果使用RTTI, 那么只需要对比构建时和cast时的typeid即可. 但为了性能等原因, 如果不使用RTTI, 能不能实现? 答案是肯定的, 只需要利用inline函数或变量地址的唯一性.
An inline function or variable shall be defined in every translation unit in which it is odr-used and shall have exactly the same definition in every case (6.2). …… An inline function or variable with external linkage shall have the same address in all translation units.
– C++17 specification n4713, §10.1.6
例如, 我们可以使用一个inline的变量模版, 这个模版若用相同的地址单态化, 即使是在不同的编译单元(TU)内, 产生的变量也会具有相同的指针地址.
反之, 如果用不同的类型单态化, 变量地址也会不同. 利用这一点就能实现any_refcast时的类型验证.
这里直接给出一个简单的实现:
| |
inline int any_ref_helper作为上述的变量模版. 构建any_ref时, 成员type_ptr_记录了此模板以原对象的类型实例化后的地址.
cast()时, 检查以目标类型和原类型实例化的any_ref_helper地址是否相同. 如果相同, 前后类型一致. 否则类型不一致.
注意, 构建函数用一个SFINAE禁用了以AnyRef为参数(即指向另一个AnyRef)的构建, 否则会导致AnyRef无法拷贝构建.
(由于AnyRef是一个非所有权引用, 因此它应当以拷贝传递/值传递, 类似std::string_view)
跨编译单元行为
为了测试上面的AnyRef, 如果我们写一个简单的测试程序, 只有一个main.cpp, 那么显然可以work, 这种情况下any_ref_helper<T>只有单个定义, 加不加inline都没区别.
有趣的部分在于, 如果在不同编译单元, 甚至跨动态链接边界, 分别使用AnyRef的构建和cast, 是否能如期达成类型安全?
毕竟这种类型擦除的主要意义就在于跨API, 跨编译单元, 用于减少编译头文件依赖, 加快编译, 统一API处类型等目的. 如果只在单个编译单元内部使用它, 属于是脱裤子放屁.
回顾上文引用的C++标准段落, 单个程序(program)中, 一个inline对象在不同编译单元的多个定义必定有相同的地址.
但是:
- 如果这些编译单元最终一起被静态链接到一个程序中, 那么它们应当是属于单个program, 因此地址的唯一性成立.
- 如果一些编译单元最终成为一个动态链接库(DSO), 那么原则上这个DSO应当是一个单独的program, 和调用它的程序独立.
因此, 问题主要在于动态链接. 这里的bottomline是, C++标准不能保证各个平台/编译器上inline地址唯一性能够跨动态链接成立.
但是, 不同的平台对动态链接的实现不同, 因此具体行为可能存在差异.
总之关键在于: inline对象在不同编译单元的地址唯一性, 在不同编译/链接环境和平台上, 具体行为如何?
测试程序
我们为AnyRef写一个简单的跨TU测试项目.
项目中定义一个库和一个主程序. 库导出一个函数, 参数为AnyRef.
而主程序会管理被引用的对象, 把它用AnyRefwrap起来, 传给库的函数.
| |
注意头文件中使用了API导出宏, 保证作为动态库时能正确导出符号.
| |
| |
最后是项目的CMake定义, 通过-DBUILD_SHARED_LIBS=<0/1>可选择mylib是编译为静态库还是动态库.
| |
如果程序如期运行, 那么主程序中lib_fn()应输出"Hello!". 否则将输出"Bad type!".
静态链接
当我们使用静态链接(-DBUILD_SHARED_LIBS=0)编译并运行测试程序, 不出意外, 在各个平台都测试成功. 程序输出了两次"Hello!".
动态链接
这里我们先给出结果:
(TODO: 等我有空了把macOS和MinGW补上)
Windows 11, MSVC
结果为失败. 通过debugger或手动print能看出, inline变量any_ref_helper在主程序和动态库中的地址确实不同.

Linux, GCC, glibc
实际测试平台是Fedora 42 和 Ubuntu 20.04. 编译器是GCC 15 和 GCC 9.
结果为成功.
Linux, GCC, musl
测试平台是Alpine Linux, GCC 14.
结果为成功.

Linux, Clang, glibc
实际测试平台是Fedora 42 和 Ubuntu 20.04. 编译器是Clang 20 和 Clang 12.
结果为成功.
Linux, 但-fvisibility=hidden
- Clang 结果均为失败.
- 所有 GCC 结果仍为成功.
结果解读
Linux
用nm查看编译出的二进制文件, 可以看到符号导出情况:
| |
根据man nm:
“V”, “v”: The symbol is a weak object. When a weak defined symbol is linked with a normal defined symbol, the normal defined symbol is used with no error. When a weak undefined symbol is linked and the symbol is not defined, the value of the weak symbol becomes zero with no error. On some systems, uppercase indicates that a default value has been specified.
“u”: The symbol is a unique global symbol. This is a GNU extension to the standard set of ELF symbol. For such a symbol the dynamic linker will make sure that in the entire process there is just one symbol with this name and type in use.
Clang
可以看出, Clang编译出的二进制中, 动态库和主程序均将any_ref_helper导出为一个weak symbol.
来自多个链接单元(这里就是我们测试的库mylib和主程序anyref)的同名weak symbol, 在(动态)链接时会统一选用其中的一个(一般是按顺序的第一个. 如果存在一个同名的strong symbol, 就总是选择strong symbol).
对于定义为inline的对象, 只要保证在各处的定义相同, 这么做就相当于使它们有了统一的定义, 且均指向其中某一个链接单元内的实例. 例如在我们的程序中, 如果动态链接下最终选择了libmylib.so中的weak symbol, 主程序也会使用来自libmylib.so的any_ref_helper定义.
(需要注意一点, 显然只有经过单态化后, any_ref_helper<T>才可能被导出为symbol, 不然它只是一个模板.
在我们的测试项目中, any_ref_helper只以const std::string作为模板类型被单态化了, 且被定义了2次, 分别在mylib和主程序中.
因此, 链接时可能选择其中的任意一个.)
GCC
根据上面引用的man page, GCC则是使用了一个非标准ELF符号类型, 也就是nm输出的"u". 这种符号专门用于保证整个进程中符号所代表对象的唯一性.
可以看到, 测试的动态库和主程序中均含有这种类型的symbol对应any_ref_helper. 因此, 链接时能确保两者引用的any_ref_helper是同一个对象, 具有相同的地址.
-fvisibility=hidden
众所周知, Linux下大部分编译器在编译动态库时的默认设置都是导出所有具有external linkage的对象. (参看: https://gcc.gnu.org/wiki/Visibility)
利用选项-fvisibility=hidden, 可将默认的符号可见性设为不可见, 除非用__attribute__((visibility("default")))特别标注为需要导出. 这样做后的行为就和Windows平台上类似了.
在我们的例子中, Clang由于any_ref_helper是external linkage, 且默认visibility为可见, 就将它导出为了一个weak symbol. 而当我们启用-fvisibility=hidden后, Clang就不再导出这个weak symbol了, 因而主程序和动态库会各自使用自己内部的any_ref_helper, 测试结果失败.
而对于使用了特殊符号"u"来实现inline唯一性的GCC来说, visibility设置就没有影响了, 因为这种symbol只是用于确保唯一性, 不应受visibility影响.
Windows
Windows的PE可执行格式中, COMDAT用于合并多个编译单元内的同一inline对象, 保证它们的唯一性, 即COMDAT folding.
但是, 这个合并仅作用于组成单个PE文件的多个编译单元, 而DLL文件算作一个单独的module, 在编译链接DLL时, 它内部的COMDAT区域就已经固定. 运行时动态链接该DLL时, 不会再将它的COMDAT与调用程序的COMDAT合并.
因此, 对于我们的测试程序, mylib.dll中的any_ref_helper和主程序中的并不是同一个对象, 而是属于各自内部, 具有不同的地址, 所以测试失败.
导出符号
如果我们需要确保唯一性的inline变量只是一个普通变量, 并不是一个会以任意类型单态化的模板, 那么解决方法就是仅在一个DLL导出这个变量(声明为__declspec(dllexport)), 其它地方, 包括调用这个DLL的程序或其它DLL, 均声明为__declspec(dllimport).
但是, AnyRef中使用的inline变量模板无法提供所有可能类型的单态化, 并不适用这个做法.
结论
简而言之,
any_ref的非RTTI实现需要依赖inline对象的地址唯一性, 但这个唯一性只对单个"程序"保证.- 一般而言, 动态链接库是一个单独的可执行image.
- 但是, 在一些平台/编译器上, 存在一些机制能够跨动态链接边界"打洞", 提供一些额外的保证.