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
检查, 所以不用担心大小或对齐不够的情况.