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_ref
cast时的类型验证.
这里直接给出一个简单的实现:
|
|
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
.
而主程序会管理被引用的对象, 把它用AnyRef
wrap起来, 传给库的函数.
|
|
注意头文件中使用了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.
- 但是, 在一些平台/编译器上, 存在一些机制能够跨动态链接边界"打洞", 提供一些额外的保证.