C语言编程中一个常见的practice是将某个struct的definition隐藏, 仅通过指针使用它, 而具体的定义放在一个单独的TU之中, 叫opaque struct. 这种做法的目的一般是为了隐藏struct中的成员, 作为加速编译的隔离手段, 并且还能保证ABI稳定性(因为API只有指针).
在C++中, 当然也可以用C风格的opaque struct, C++并不阻止你这么做, 但通常的做法是用PIMPL, 方法也是类似的.
例如, 我们有一个C风格的库libfoo:
| |
使用者只需要拿着这个FooCtx*的handle, 不需要关心它内部是什么.
避免动态内存分配
常见的opaque struct或者PIML做法, 免不了动态内存分配, 因为FooCtx的size在头文件里未知.
有没有什么办法可以绕过这个要求? 作为C/C++程序员, 总是对动态内存分配比较敏感. 有些场景, 比如某些嵌入式平台还没有一般的动态分配可用.
答案是可以, 其实就是把FooCtx放在栈上, 用一段栈上的buffer提供存储空间.
| |
显然, FooCtxStorage::buf需要能够装下FooCtx, 并满足内存对齐要求.
这里用了alignas, 如果没有C11, 也可以把FooCtxStorage换成一个union, 加入一个member, 用于确保buf的对齐.
foo_init_storage用于在已有的一段缓存上创建一个FooCtx, 并且返回所创建对象的指针.
其它函数只接收FooCtx*作为参数.
foo_finish用于释放FooCtx可能管理的资源, 但不需要释放它本身占用的存储, 因为存储是由外部提供.
这个方法使用时需要先在栈上声明一个FooCtxStorage(当然实际上放在哪里都行), 然后用一个初始化函数获得创建好的FooCtx*, 有两个变量, 需要一致.
而且, 如果对象被move, 还需要保持指针和storage对象同步. (当然, 可以提供专门的move/copy函数避免错误)
编译期确保大小和对齐
在对应的foo.c的TU里, 我们可以用一些静态assert确保FOO_CTX_SIZE和FOO_CTX_ALIGNMENT确实符合FooCtx的定义.
Strict aliasing
很遗憾, 实际上这个方法仍然是违反strict aliasing rule的, 因为buf的实际类型是char数组, 且永远不会改变, 把一个FooCtx给memcpy进去也没用.
foo_init_storage必定会把buf这片区域当做FooCtx来访问, 就算传void*也没区别, 因此这实际是UB.
(注意, 用char*访问FooCtx可以, 但反过来不行)
Inline PIMPL
A.K.A. fast PIMPL, 也就是不需要经过动态内存分配的PIMPL.
原理上和上面的方法类似, 但由于C++提供了placement new和std::launder, 可以非UB地实现.
(这里假设读者已经知晓PIMPL的一般用法.)
| |
创建Foo时, 我们用placement new在静态的buf上创建Impl对象. 这样会隐式地结束原本unsigned char的生命周期,
并开始Impl对象的生命周期.
placement new返回的指针可以记录着, 以后就可以通过它访问Impl了.
但这里为了省掉这个额外的Foo::handle成员变量, 我们额外定义一个Foo::get_impl()函数, 帮我们安全地从buf获取Impl对象.
这里需要用到std::launder, 否则从原本的buf指针直接转换得到的Impl*指针并不能用于安全地访问新构建的Impl对象.
(也就是说, 如果没有std::launder可用, 就需要用一个成员变量记录placement new产生的指针.)
析构函数中, 需要我们显式地调用Impl上的析构函数.
ABI稳定性
需要注意, 这种做法也部分舍弃了普通PIMPL带来的ABI稳定性的好处, 因为Foo::buf的大小和对齐可能随着Foo::Impl变化.
一种workaround是把impl_alignment调整得足够大, 并且给impl_size预留足够的空间, 这样Impl增长并不总需要改变buf.
源文件里有static_asset检查, 所以不用担心大小或对齐不够的情况.