不用动态内存分配如何实现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.

这里quote一段cppreference上的内容:

Every object has an effective type, which determines which lvalue accesses are valid and which violate the strict aliasing rules.

If the object was created by a declaration, the declared type of that object is the object’s effective type.

If the object was created by an allocation function (including realloc), it has no declared type. Such object acquires an effective type as follows:

  • The first write to that object through an lvalue that has a type other than character type, at which time the type of that lvalue becomes this object’s effective type for that write and all subsequent reads.
  • memcpy or memmove copy another object into that object, or copy another object into that object as an array of character type, at which time the effective type of the source object (if it had one) becomes the effective type of this object for that write and all subsequent reads.
  • Any other access to the object with no declared type, the effective type is the type of the lvalue used for the access.

也就是说, 用memcpy设置effective type仅可用于malloc/realloc出来的buffer, 这样的buffer刚分配出来时没有effective type(在C++当中, 这叫做implicit lifetime). 如果是一块事先声明的unsigned char buf[], 那么它的effective type就一直是unsigned char[], 并不会被memcpy改变. memcpy本身是合法的, 但将buf之后作为FooCtx访问则是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