不用动态内存分配如何实现opaque struct和PIMPL?

C语言编程中一个常见的practice是将某个struct的definition隐藏, 仅通过指针使用它, 而具体的定义放在一个单独的TU之中, 叫opaque struct. 这种做法的目的一般是为了隐藏struct中的成员, 作为加速编译的隔离手段, 并且还能保证ABI稳定性(因为API只有指针).

在C++中, 当然也可以用C风格的opaque struct, C++并不阻止你这么做, 但通常的做法是用PIMPL, 方法也是类似的.

例如, 我们有一个C风格的库libfoo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// foo.h
typedef struct FooCtx FooCtx;

FooCtx* foo_create_context();
void foo_destroy_context(FooCtx*);

int foo_some_function(FooCtx*);

// foo.c
// 包含FooCtx和上述函数的定义

使用者只需要拿着这个FooCtx*的handle, 不需要关心它内部是什么.

避免动态内存分配

常见的opaque struct或者PIML做法, 免不了动态内存分配, 因为FooCtx的size在头文件里未知. 有没有什么办法可以绕过这个要求? 作为C/C++程序员, 总是对动态内存分配比较敏感. 有些场景, 比如某些嵌入式平台还没有一般的动态分配可用.

答案是可以, 其实就是把FooCtx放在栈上, 用一段栈上的buffer提供存储空间.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#define FOO_CTX_SIZE /*...*/
#define FOO_CTX_ALIGNMENT /*...*/

typedef struct FooCtxStorage {
    alignas(FOO_CTX_ALIGNMENT) unsigned char buf[FOO_CTX_SIZE];
} FooCtxStorage;

FooCtx* foo_init_storage(FooCtxStorage* buf);
void foo_finish(FooCtx* buf); // cleans up additional resources held by FooCtx, no need to free memory.

int foo_some_function(FooCtx*);

显然, 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_SIZEFOO_CTX_ALIGNMENT确实符合FooCtx的定义.

Strict aliasing

很遗憾, 实际上这个方法仍然是违反strict aliasing rule的, 因为buf的实际类型是char数组, 且永远不会改变, 把一个FooCtxmemcpy进去也没用. foo_init_storage必定会把buf这片区域当做FooCtx来访问, 就算传void*也没区别, 因此这实际是UB.

(注意, 用char*访问FooCtx可以, 但反过来不行)

Inline PIMPL

A.K.A. fast PIMPL, 也就是不需要经过动态内存分配的PIMPL. 原理上和上面的方法类似, 但由于C++提供了placement newstd::launder, 可以非UB地实现. (这里假设读者已经知晓PIMPL的一般用法.)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// foo.h
class Foo {
public:
    Foo();
    ~Foo() noexcept;
    // 有需要的话, 声明(或delete)copy和move

    // 其它method...

private:
    struct Impl;
    static constexpr int impl_alignment = /* write it down, or just use max_align_t if no extended alignment */;
    static constexpr int impl_size = /* .. */
    alignas(impl_alignment) unsigned char buf[impl_size];

    //Impl* handle; //这一项可以不要, 省掉一点空间, 换成下面的函数.
    Impl* get_impl() noexcept;
}

// foo.cpp中的内容

struct Foo::Impl {
    //...
}

static_assert(Foo::impl_size >= sizeof(Foo::Impl));
static_assert(Foo::impl_alignment >= alignof(Foo::Impl));

Foo::Foo() {
    // 如果有handle, 就这时候记录它.
    // handle = new(buf) Impl{};
    // 没有的话, 直接new, 丢掉指针, 一会用std::launder找回来.
    new(&buf[0]) Impl{};
}

Foo::Impl* Foo::get_impl() noexcept {
    return std::launder(reinterpret_cast<Impl*>(&buf[0]));
}

Foo::~Foo() noexcept {
    get_impl()->~Impl();
}

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

Licensed under CC BY-NC-SA 4.0