在库的公共 API 中从 std::string、std::ostream 等转换

Transitioning away from std::string, std::ostream, etc. in a library's public API

本文关键字:std string ostream 转换 中从 API      更新时间:2023-10-16

为了在具有相同二进制文件的许多工具链之间实现 API/ABI 兼容性,众所周知,STL 容器、std::string和其他标准库类(如 iostreams)在公共标头中是冗长的。(例外情况是,如果一个人为支持的工具链的每个版本分发一个构建;一个提供没有用于最终用户编译的二进制文件的源代码,这在目前的情况下不是首选选项;或者一个转换为其他容器内联,以便不同的 std 实现不会被库摄取。

如果已经有一个不遵循此规则的已发布库 API(请求朋友),那么在保持尽可能多的向后兼容性并在我不能的地方支持编译时中断的同时,最好的前进道路是什么?我需要支持Windows和Linux。

我正在寻找的 ABI 兼容性水平:我不需要它疯狂地面向未来。我主要希望为每个版本为多个流行的 Linux 发行版做一个库二进制文件。(目前,我为每个编译器发布一个,有时为特殊发行版(RHEL vs Debian)发布特殊版本。MSVC 版本也有同样的顾虑 - 一个 DLL 用于所有受支持的 MSVC 版本将是理想的。其次,如果我在错误修复版本中不破坏 API,我希望它与 ABI 兼容并且是直接的 DLL/SO 替换,而无需重建客户端应用程序。

我有三个案例,有一些初步的建议,在某种程度上模仿了Qt。

旧的公共 API:

// Case 1: Non-virtual functions with containers
void Foo( const char* );
void Foo( const std::string& );
// Case 2: Virtual functions
class Bar
{
public:
virtual ~Bar() = default;
virtual void VirtFn( const std::string& );
};
// Case 3: Serialization
std::ostream& operator << ( std::ostream& os, const Bar& bar );

案例 1:具有容器的非虚拟函数

理论上,我们可以将std::string用途转换为非常类似于std::string_view但在我们库的 API/ABI 控制下的类。它将在我们的库标头中从std::string转换,以便编译的库仍然接受但独立于std::string实现并且向后兼容:

新接口:

class MyStringView
{
public:
MyStringView( const std::string& ) // Implicit and inline
{
// Convert, possibly copying
}
MyStringView( const char* ); // Implicit
// ...   
};
void Foo( MyStringView ); // Ok! Mostly backwards compatible

大多数不执行异常操作(如获取Foo地址)的客户端代码无需修改即可工作。同样,我们可以创建自己的std::vector替换,尽管在某些情况下可能会受到复制处罚。

Abseil 的 ToW #1 建议从 util 代码开始,而不是从 API 开始。这里还有其他提示或陷阱吗?

案例 2:虚拟函数

但是虚拟功能呢?如果我们更改签名,则会破坏向后兼容性。我想我们可以将旧的留在原地,并final强制破损:

// Introduce base class for functions that need to be final
class BarBase
{
public:
virtual ~BarBase() = default;
virtual void VirtFn( const std::string& ) = 0;
};
class Bar : public BarBase
{
public:
void VirtFn( const std::string& str ) final
{
VirtFn( MyStringView( str ) );
}
// Add new overload, also virtual
virtual void VirtFn( MyStringView );
};

现在,旧虚拟函数的覆盖将在编译时中断,但带有std::string的调用将自动转换。重写应改用新版本,并将在编译时中断。

这里有什么提示或陷阱吗?

案例 3:序列化

我不确定如何处理iostreams。一种选择是,冒着效率低下的风险,以内联方式定义它们并通过字符串重新路由它们:

MyString ToString( const Bar& ); // I control this, could be a virtual function in Bar if needed
// Here I publicly interact with a std object, so it must be inline in the header
inline std::ostream& operator << ( std::ostream& os, const Bar& bar )
{
return os << ToString( bar );
}

如果我ToString()做一个虚函数,那么我可以遍历所有 Bar 对象并调用用户的覆盖,因为它只依赖于 MyString 对象,这些对象在标头中定义,它们与 std 对象(如流)交互。

想法,陷阱?

第 1 层

使用良好的字符串视图。

不要使用std::string const&虚拟重载;没有理由这样做。 无论如何,您正在破坏 ABI。 重新编译后,他们将看到新的基于字符串视图的重载,除非他们获取并存储指向虚函数的指针。

若要在不转到中间字符串的情况下进行流式传输,请使用继续传递样式:

void CPS_to_string( Bar const& bar, MyFunctionView< void( MyStringView ) > cps );

其中,cps使用部分缓冲区重复调用,直到对象序列化出来。 在此基础上写入<<(内联在标头中)。 函数指针间接寻址会产生一些不可避免的开销。

现在只在接口中使用虚拟方法,从不重载虚拟方法,并且始终在 vtable 末尾添加新方法。 所以不要暴露复杂的层次结构。 扩展 vtable 是 ABI 安全的;添加到中间不是。

FunctionView 是一个简单的手动滚动的非拥有 std 函数克隆,其状态是void*R(*)(void*,args&&...),它应该是 ABI 稳定的,可以跨库边界传递。

template<class Sig>
struct FunctionView;
template<class R, class...Args>
struct FunctionView<R(Args...)> {
FunctionView()=default;
FunctionView(FunctionView const&)=default;
FunctionView& operator=(FunctionView const&)=default;
template<class F,
std::enable_if_t<!std::is_same< std::decay_t<F>, FunctionView >{}, bool> = true,
std::enable_if_t<std::is_convertible< std::result_of_t<F&(Args&&...)>, R>, bool> = true
>
FunctionView( F&& f ):
ptr( std::addressof(f) ),
f( [](void* ptr, Args&&...args)->R {
return (*static_cast< std::remove_reference_t<F>* >(ptr))(std::forward<Args>(args)...);
} )
{}
private:
void* ptr = 0;
R(*f)(void*, Args&&...args) = 0;
};
template<class...Args>
struct FunctionView<void(Args...)> {
FunctionView()=default;
FunctionView(FunctionView const&)=default;
FunctionView& operator=(FunctionView const&)=default;
template<class F,
std::enable_if_t<!std::is_same< std::decay_t<F>, FunctionView >{}, bool> = true
>
FunctionView( F&& f ):
ptr( std::addressof(f) ),
f( [](void* ptr, Args&&...args)->void {
(*static_cast< std::remove_reference_t<F>* >(ptr))(std::forward<Args>(args)...);
} )
{}
private:
void* ptr = 0;
void(*f)(void*, Args&&...args) = 0;
};

这允许您通过 API 屏障传递通用回调。

// f can be called more than once, be prepared:
void ToString_CPS( Bar const& bar, FunctionView< void(MyStringView) > f );
inline std::ostream& operator<<( std::ostream& os, const Bar& bar )
{
ToString_CPS( bar, [&](MyStringView str) {
return os << str;
});
return os;
}

并在标头中实现ostream& << MyStringView const&


第 2 层

将标头中C++ API 的每个操作转发到extern "C"pure-C 函数(即将 StringView 作为一对char const*ptrs 传递)。 仅导出一组extern "C"符号。 现在,符号重整更改不再破坏 ypur ABI。

C ABI比C++更稳定,通过强制您将库调用分解为"C"调用,可以使 ABI 重大更改变得明显。 使用C++头胶使东西干净,C使ABI坚如磐石。

如果你愿意冒险,你可以保留你的纯虚拟接口;使用与上面相同的规则(简单的层次结构,没有重载,只添加到最后),你会得到不错的ABI稳定性。

容器

要接受字符串和数组作为函数参数,请分别使用std::string_viewgsl::span,或者使用具有稳定 ABI 的您自己的等效项。非连续容器可以作为any_iterator的范围传入。

对于通过引用返回,您可以再次使用这些类。

要按值返回刺痛,您可以将std::string_view返回到线程本地全局对象,该对象在下一次 API 调用(如std::ctime函数)之前有效。如有必要,用户必须制作深层副本。

要按值返回容器,可以使用基于回调的 API。您的 API 将为要返回的容器的每个元素调用用户回调。

std::string_viewgsl::spanany_iterator或其等效项必须在库附带给用户的头文件中实现。

虚拟功能

您可以在库的 API 中使用 Pimpl 习语代替具有虚函数的类。

序列化

可以在头文件中实现为函数,这些函数使用库的公共 API 并使用 IOStreams 进行序列化/反序列化。