【问题标题】:C++ wrapper for C-API: Exploring best options for passing `char*`C-API 的 C++ 包装器:探索传递 `char*` 的最佳选项
【发布时间】:2026-01-11 01:20:05
【问题描述】:

有很多questions on similar topics,但我发现没有一个以这种方式探索选项。

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

包装器的用例是我们想方便地调用具有各种“字符串”属性名称和值的C++包装器,因此字符串字面量、字符串、const字符串、string_views等。我们要调用在设置过程中单独在其中性能不重要和在内部循环中,100M+次,其中性能很重要。 (底部的基准代码)

将“字符串”传递给函数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 '\0' 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 '\0' 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
// '\0' 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++ 客户端中不存在。在完整的应用程序中,我知道我可以在

#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

【问题讨论】:

  • 复制一份。传递一个指向副本的指针..
  • @JesperJuhl 那是选项 4?或选项3?你读过cmets吗?
  • string_view 保证以空值结尾,这与string::c_str() 不同,并且在实践中没有(它通常用于低开销的字符串切片)
  • @Oliver 什么 cmets?我没有看到。
  • @parktomatomi 是的,我已经介绍过了

标签: c++ c string wrapper


【解决方案1】:

先修正,再根据需要进行优化。

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

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

  • wrapper3 不依赖任何 UB(据我所知)。

  • wrapper4 与 wrapper3 类似,但会公开您尝试封装的细节。

从做最正确的事情开始,就是复制字符串,并传递一个指向副本的指针,也就是wrapper3。

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

替代解决方案可能涉及缓存以减少复制的数量,充分调查底层库以进行一些战略性更改(例如将底层库更改为尽可能使用 const),或者通过进行暴露 @987654321 的重载@ 并直接传递它(这将负担转移给调用者知道什么是正确的)。

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

【讨论】:

  • 这都是合理的建议,我也会给出。但我们同意吗?只有妥协的答案。没有其他有意义的选择吗?我现在做了上面的基准测试结果,你看到了吗?这是 1 和 3 之间 15 倍的差异,这对我的内部循环有 10-20% 的影响。
  • 你说得对,这些接口大约有 20 个,而紧密循环恰好使用其中一个。所以这可能是我的妥协。我可以为紧凑的 lop 使用的 wrapper4 提供重载(因为在紧凑循环中我们在调用者中有 std::string)和所有它们的 wrapper3(包括在内循环中使用的那个),因为 wrapper3 是一个干净、易于使用、安全和健全的 API。对吗?
  • 为 wrapper4 添加了基准测试。它比 wrapper1 慢 3 倍,比 wrapper3 快 5 倍。因此,我认为这将其置于“对内循环没有重大影响”的领域。所以 wrapper3 为一切。然后一个 wrapper4 重载用于紧密循环。同意吗?
  • 很高兴报告 wrapper4 的特殊重载只会在内部循环中产生无关紧要的差异......所以在其他地方都使用了“干净”的 wrapper3。不幸的是,这里似乎没有灵丹妙药,对于将 C++ 连接到 C char* 的通用解决方案,我们不得不妥协。接受您的回答,因为它让我朝着正确的方向思考,即专门“只对您需要的确切一次调用”,并尽可能保持 API 的其余部分“方便和干净”。
【解决方案2】:

字符串视图 '\0' 是否终止?

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

旧版 API 会修改吗? ..我们不知道。

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


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

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

【讨论】:

  • 是的。 sv.data() 在所有当前用例中都终止了'\0',但是如果我使用选项1,那么有人会在某个时候传递一个未终止的东西吗?我很欣赏将 char* 保留在 C++ 中的想法。我当然想过。除了必须将// NOLINT 放在所有内容上之外,它还需要是non-const char*,这意味着我无法将颜色存储在.text...。在我看来,“这里没有真正正确或好的选择” ...只有妥协?目前我有选项 1,但打算在精确测量性能影响后更改为选项 3。