定义一个抽象类是否足以解决所有 ABI 问题?
简答:
没有。
我不建议将 C++ 用于插件 API(请参阅下面的更长答案),但如果您决定坚持使用 C++,那么:
- 不要在插件 API 中使用任何标准库类型。
例如,
Character::name() 返回一个std::string。如果std::string 的实现发生变化(and it has in the past in GCC),那么这将导致未定义的行为。实际上,任何您无法控制的东西(任何第三方库)都不应该在 API 中使用。
- 不要跨插件边界使用异常或 RTTI。在 Linux 上,如果您使用
RTLD_GLOBAL 加载插件(对于插件来说不是一个好主意)并且主机和插件都使用相同的运行时,则 RTTI 可能会起作用。但一般来说,您要么无法从另一个模块捕获异常,要么它们甚至可能导致堆损坏(如果它们是由不同的运行时分配的)。
- 只在抽象类的末尾添加函数,否则一切都会因为 vtable 布局的变化而静默中断(这很难诊断)。
- 始终从同一模块分配和解除分配对象。我注意到您没有
destroyCharacter() 函数(main() 实际上泄漏了字符,但这是另一个问题)。始终为不同模块(共享库或插件)创建的资源提供对称的 create 和 destroy 函数。
我相信在带有 GCC 的 Linux 上,主机应用程序的 operator new 和 operator delete 会正确传播到加载的插件(通过弱符号),但如果你希望它在 Windows 上工作,那么不要假设 operator new 和 @987654339 @ 在宿主应用程序和插件中是相同的。静态链接的运行时,尤其是使用 LTO 构建的运行时,也可能会出现这种情况。
更长的答案:
从插件导出 C++ API 时可能会出现很多问题。
一般来说,如果关于用于构建主机应用程序的工具链和插件的任何内容不同,则无法保证它可以正常工作。这可以包括(但不限于)编译器、语言版本、编译器标志、预处理器定义等。
关于插件的普遍看法是使用纯 C89 API,因为所有常见平台上的 C ABI非常稳定。
保持 C89 和 C++ 的公共子集意味着主机和插件可以使用不同的语言标准、标准库等。除非主机或插件是用一些奇怪的(并且可能不符合标准的)API 构建的,否则这应该是相当安全的。显然,您仍然必须小心数据结构布局。
然后,您可以为处理生命周期和错误代码/异常转换等的 C API 提供丰富的仅包含 C++ 标头的包装器。
作为一个很好的奖励,大多数语言都可以生产和使用 C API,这可以让插件作者不仅仅使用 C++。
实际上,即使在 C API 中也存在不少缺陷。如果我们是迂腐的,那么唯一安全的东西是具有固定大小参数和返回类型(指针,size_t,[u]intN_t)的函数——甚至不一定是内置类型(short,int,@ 987654344@, ...) 或枚举。例如。 in GCC: -fshort-enums 可以改变枚举的大小,-fpack-struct[=n] 可以改变结构内的填充。
因此,如果您真的想要安全,请不要使用枚举,也不要打包所有结构或不直接公开它们(而是公开访问器函数)。
其他注意事项:
这些与问题并不严格相关,但绝对应该在提交特定样式的 API 之前加以考虑。
错误处理:无论您是否使用 C++,您都需要异常的替代方法。
这可能是某种形式的错误代码。只要你在 C++ 领域,C++ 中的std::error_code 就可以用来包装原始枚举/int,如果 API 使用 C++,那么 std::expected-like 或 Boost.Outcome-like 类型具有稳定的 ABI可以用。
加载插件和导入符号: 使用抽象类很容易——只需一个简单的工厂函数即可。使用传统的 C API,您最终可能需要导入数百个符号。一种处理方法是在 C 中模拟 vtable。使每个具有关联函数的对象都以指向调度表的指针开头,例如
typedef struct game_string_view { const char *data; size_t size; } game_string_view;
typedef enum game_plugin_error_code { game_plugin_success = 0, /* ... */ } game_plugin_error_code;
typedef struct game_plugin_character_impl *GamePluginCharacter; // handle to a Character
typedef struct game_plugin_character_dispatch_table { // basically a vtable
void (*destroy)(GamePluginCharacter character); // you could even put destroy() here
game_string_view (*name)(GamePluginCharacter character);
void (*update)(GamePluginCharacter character, /*...*/, game_plugin_error_code *ec); // might fail
} game_plugin_character_dispatch_table;
typedef struct game_plugin_character_impl {
// every call goes through this table and takes GamePluginCharacter as it's first argument
const game_plugin_character_dispatch_table *dispatch;
} game_plugin_character_impl;
未来的可扩展性和兼容性:您应该设计 API,知道您将来会想要更改它并保持兼容性。 IMO,C API 非常适合这一点,因为它迫使您在公开的内容中非常精确。该插件应该能够以向前和向后兼容的方式向主机公开其 API 版本。
在设计每个函数签名时考虑可扩展性是个好主意。例如。如果一个结构是通过指针(而不是值)传递的,那么它的大小可以在不破坏兼容性的情况下扩展(只要在运行时调用者和被调用者都同意它的大小)。
可见性:也许可以在 Linux 和其他平台上查看visibility。这实际上不是 API 设计的问题,只是有助于清理从共享库导出的符号。
以上所有内容绝不是广泛的。
我建议将谈话"Hourglass Interfaces for C++ APIs" 作为进一步的“阅读”。
当然还有其他关于此事的好演讲和文章(我不记得了)。