C-API 的 C++ 包装器:探索传递 `char*` 的最佳选项

C++ wrapper for C-API: Exploring best options for passing `char*`

我发现有很多 但 none 以这种方式探索选项。

通常我们需要用 C++ 包装遗留的 C-API 以使用它的非常好的功能,同时保护我们免受变幻莫测的影响。在这里,我们将只关注一个元素。如何包装接受 char* 参数的遗留 C 函数。具体示例是 API (the graphviz lib),它接受许多参数作为 char*,而不指定是 const 还是 non-const。似乎没有尝试修改,但我们不能 100% 确定。

包装器的用例是我们想方便地调用具有各种 "stringy" 属性名称和值的 C++ 包装器,因此字符串文字、字符串、const 字符串、string_views、等。我们想在设置期间单独调用 ,其中性能是非关键的 ,并且在内部循环 中调用 100M+ 次,其中性能确实很重要。 (基准代码在底部)

将 "strings" 传递给函数 have been explained elsewhere 的多种方式。

下面的代码被大量注释为 cpp_wrapper() 函数的 4 个选项被 5 种不同的调用方式。

哪个是最好/最安全/最快的选择?是Pick 2的情况吗?

#include <array>
#include <cassert>
#include <cstdio>
#include <string>
#include <string_view>

void legacy_c_api(char* s) {
  // just for demo, we don't really know what's here.
  // specifically we are not 100% sure if the code attempts to write
  // to char*. It seems not, but the API is not `const char*` eventhough C
  // supports that
  std::puts(s);
}

// the "modern but hairy" option
void cpp_wrapper1(std::string_view sv) {
  // 1. nasty const_cast. Does the legacy API modifY? It appears not but we
  // don't know.

  // 2. Is the string view '[=11=]' terminated? our wrapper api can't tell
  // so maybe an "assert" for debug build checks? nasty too?!
  // our use cases below are all fine, but the API is "not safe": UB?!
  assert((int)*(sv.data() + sv.size()) == 0);

  legacy_c_api(const_cast<char*>(sv.data()));
}

void cpp_wrapper2(const std::string& str) {
  // 1. nasty const_cast. Does the legacy API modifY? It appears not but we
  //    don't know. note that using .data() would not save the const_cast if the
  //    string is const

  // 2. The standard says this is safe and null terminated std::string.c_str();
  //    we can pass a string literal but we can't pass a string_view to it =>
  //    logical!

  legacy_c_api(const_cast<char*>(str.c_str()));
}

void cpp_wrapper3(std::string_view sv) {
  // the slow and safe way. Guaranteed be '[=11=]' terminated.
  // is non-const so the legacy can modfify if it wishes => no const_cast
  // slow copy?  not necessarily if sv.size() < 16bytes => SBO on stack
  auto str = std::string{sv};
  legacy_c_api(str.data());
}

void cpp_wrapper4(std::string& str) {
  // efficient api by making the proper strings in calling code
  // but communicates the wrong thing altogether => effectively leaks the c-api
  // to c++
  legacy_c_api(str.data());
}

// std::array<std::string_view, N> is a good modern way to "store" a large array
// of "stringy" constants? they end up in .text of elf file (or equiv). They ARE
// '[=11=]' terminated. Although the sv loses that info. Used in inner loop => 100M+
// lookups and calls to legacy_c_api;
static constexpr const auto sv_colours =
    std::array<std::string_view, 3>{"color0", "color1", "color2"};

// instantiating these non-const strings seems wrong / a waste (there are about
// 500 small constants) potenial heap allocation in during static storage init?
// => exceptions cannot be caught... just the wrong model?
static auto str_colours =
    std::array<std::string, 3>{"color0", "color1", "color2"};

int main() {
  auto my_sv_colour  = std::string_view{"my_sv_colour"};
  auto my_str_colour = std::string{"my_str_colour"};

  cpp_wrapper1(my_sv_colour);
  cpp_wrapper1(my_str_colour);
  cpp_wrapper1("literal_colour");
  cpp_wrapper1(sv_colours[1]);
  cpp_wrapper1(str_colours[2]);

  // cpp_wrapper2(my_sv_colour); // compile error
  cpp_wrapper2(my_str_colour);
  cpp_wrapper2("literal_colour");
  // cpp_wrapper2(colours[1]); // compile error
  cpp_wrapper2(str_colours[2]);

  cpp_wrapper3(my_sv_colour);
  cpp_wrapper3(my_str_colour);
  cpp_wrapper3("literal_colour");
  cpp_wrapper3(sv_colours[1]);
  cpp_wrapper3(str_colours[2]);

  // cpp_wrapper4(my_sv_colour);  // compile error
  cpp_wrapper4(my_str_colour);
  // cpp_wrapper4("literal_colour"); // compile error
  // cpp_wrapper4(sv_colours[1]); // compile error
  cpp_wrapper4(str_colours[2]);
}

基准代码

还不完全现实,因为在 C-API 中的工作很少,并且在 C++ 客户端中不存在。在完整的应用程序中,我知道我可以在 <1 秒内完成 10M。所以只是在这 2 API 抽象样式之间进行更改看起来可能是 10% 的更改?早期......需要更多的工作。注意:这是一个适合 SBO 的短字符串。较长的堆分配完全耗尽了它。

#include <benchmark/benchmark.h>

static void do_not_optimize_away(void* p) {
    asm volatile("" : : "g"(p) : "memory");
}

void legacy_c_api(char* s) {
  // do at least something with the string
  auto sum = std::accumulate(s, s+6, 0);
  do_not_optimize_away(&sum);
}

// ... wrapper functions as above: I focused on 1&3 which seem 
// "the best compromise". 
// Then I added wrapper4 because there is an opportunity to use a 
// different signature when in main app's tight loop. 

void bench_cpp_wrapper1(benchmark::State& state) {
  for (auto _: state) {
    for (int i = 0; i< 100'000'000; ++i) cpp_wrapper1(sv_colours[1]);
  }
}
BENCHMARK(bench_cpp_wrapper1);

void bench_cpp_wrapper3(benchmark::State& state) {
  for (auto _: state) {
    for (int i = 0; i< 100'000'000; ++i) cpp_wrapper3(sv_colours[1]);
  }
}
BENCHMARK(bench_cpp_wrapper3);

void bench_cpp_wrapper4(benchmark::State& state) {
  auto colour = std::string{"color1"};
  for (auto _: state) {
    for (int i = 0; i< 100'000'000; ++i) cpp_wrapper4(colour);
  }
}
BENCHMARK(bench_cpp_wrapper4);

结果

-------------------------------------------------------------
Benchmark                   Time             CPU   Iterations
-------------------------------------------------------------
bench_cpp_wrapper1   58281636 ns     58264637 ns           11
bench_cpp_wrapper3  811620281 ns    811632488 ns            1
bench_cpp_wrapper4  147299439 ns    147300931 ns            5

Is the string view '[=18=]' terminated?

如果恰好指向以空字符结尾的字符串,那么 sv.data() 可能以空字符结尾。但是字符串视图不需要以 null 终止,因此不应假定它是。因此 cpp_wrapper1 是一个糟糕的选择。

Does the legacy API modifY? .. we don't know.

如果你不知道 API 是否修改了字符串,那么你就不能使用 const,所以 cpp_wrapper2 不是一个选项。


需要考虑的一件事是是否需要包装器。最有效的解决方案是传递 char*,这在 C++ 中很好。如果使用 const strings 是一个典型的操作,那么 cpp_wrapper3 可能会有用 - 但考虑到操作可能会修改字符串,它是典型的吗? cpp_wrapper4 比 3 更有效,但如果您还没有 std::string.

,则不如普通 char* 有效

您可以提供上述所有选项作为重载。

先纠正,然后根据需要进行优化。

  • wrapper1 至少有两个潜在的未定义行为实例:可疑的 const_cast 和(在调试版本中)可能访问数组末尾之后的元素。 (您可以创建一个指向最后一个元素之后的指针,但您无法访问它。)

  • wrapper2 也有一个可疑的 const_case,可能会调用未定义的行为。

  • wrapper3 不依赖任何 UB(我看到的)。

  • wrapper4 与 wrapper3 类似,但会公开您要封装的详细信息。

从做最正确的事情开始,即复制字符串并将指针传递给副本,即wrapper3。

如果在紧密循环中性能不可接受,您可以考虑替代方案。紧密循环可能只使用接口的一个子集。紧环可能严重偏向短弦或长弦。编译器可能会在紧密循环中内联足够多的包装器,这实际上是一个空操作。这些因素将影响您如何(以及是否)解决性能问题。

替代解决方案可能涉及缓存以减少制作的副本数量,充分调查底层库以进行一些战略性更改(例如更改底层库以在可能的情况下使用 const),或者进行重载以暴露char * 并直接通过(这将负担转移给调用者以了解什么是正确的)。

但所有这些都是实现细节:设计 API 以供调用者使用。