C++17 特性:使用 std::string_view 时小心踩坑

C++17 特性:使用 std::string_view 时小心踩坑

关于 std::string_view

使用 std::string_view 的原因是为了避免无意义的 std::string 临时对象。

比如说,有某个函数,需要支持 C++-Style 的字符串,即 std::string 和 C-Style 的字符串,即 const char* 两种风格的字符串。最省事的写法就是只写一个 C++-Style 的版本,当传入 C-Style 字符串时,编译器会调用 std::string 的构造函数,自动创建一个 std::string 的临时对象。

1
2
3
4
5
void func(const string& str);

string text { "Hello" };
func(text); // works!
func("Hello"); // works! 相当于: func(std::string("Hello"));

那么,如果这个函数会被经常调用,而你很在意这个临时对象,最好的方法是再写一个 C-Style 版本。

1
2
3
4
5
6
7
8
// C++-Style version:
void func(const std::string& str);

// C-Style version:
template<size_t N>
void func(const char str[N]);

func("Hello"); // 将会调用 C-Style version

写两个版本,临时对象的问题是解决了,但是这种解决方案并不那么优雅,这样做带来了更多的问题。

比如说,现在你需要维护两个版本的代码,它们的代码几乎一样。这个一般可以通过再实现一个 func_impl 函数来解决,也就是把具体实现挪到另一个函数。

又比如说,因为 func 的 C-Style 版本是模板函数,所以它的具体实现只能放到头文件里了。

那么 std::string_view 要如何“优雅地”解决问题呢?下面的代码使用 std::string_view 将 func 函数重写。

1
2
3
4
5
// string_view version
void func(std::string_view str);

func(text); // works!
func("Hello"); // works! 而且没有临时的 std::string 对象产生!

很多教程或者书籍都会推荐这样一个做法,一个函数有 std::string 类型的参数,如果这个参数它不会被修改,那么应该以 const-reference 的方式传递。这也就是前面 C++-Style version 的写法: 使用 const string& str 而不是 string str
这样做是因为以值类型传参比以引用类型传参会多一次复制,这种复制的成本可能是高昂的,需要尽量避免。

但是也有例外,如果复制的成本很小,比如 int、double 这种简单的类型,复制的成本极低,使用引用传参甚至可能拖慢速度(比如可能阻止编译器做优化)。

这里的 std::string_view 就是这种复制成本很小的对象。所以虽然我们已经习惯了使用 const string&,但是对于 std::string_view,最好不要使用引用传参,因为 std::string_view 的本质就是一个引用,使用引用的引用并不会带来更多的好处。

容易踩坑的地方

标准库生态不佳

虽然 std::string_view 有着那么好的优点,但是想用 std::string_view 完全替代 const string&const char[N] 是不会顺利的。并不是简单地把 const string& 替换成 std::string_view 就可以了。

比如说,标准库的正则表达式库 std::regex 对 std::string_view 的支持就不够好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// https://gist.github.com/WojciechMula/78f7b579abe77ebcfe38beae8d037e88
std::vector<std::string_view> correct()
{
std::vector<std::string_view> result;
// 没有 std::svmatch 这种东西,所以必须写全类型名称
std::match_results<std::string_view::const_iterator> match;

if (std::regex_match(input.cbegin(), input.cend(), match, re)) {
for (size_t i=1; i < match.size(); i++) {
const char* first = match[i].first;
const char* last = match[i].second;
// 必须根据长度自己构建 string_view 对象
result.push_back({first, static_cast<std::size_t>(last - first)});
// *不要*写成:result.push_back(match[i].str());
// match[i].str() 生成了临时的 string 对象
// 而 string_view 的本质是引用
// 一个指向临时对象的引用会导致程序出错崩溃的!
}
}

return result;
}

又比如说常用的 string to int/long long 函数,std::stoi 和 std::stoll,它们并没有提供 string_view 版本。如果你一定要将字符串转换成数字,那么只能做出修改,使用 std::from_chars 替换 std::stoi/std::stoll。

由字符串的本质引起的问题

我有一个踩坑的案例可以分享。我有一个 string_view 对象,它的内容是一个 URL,我打算使用 std::regex 从中取出 hostname 和 port,这个过程没有什么问题,而且前面也分享了如何正确地对 string_view 对象使用 std::regex。

问题在于,我得到的两个对象:std::string_view hostname;std::string_view port; 它们实际储存的并不是字符串片段!

字符串的本质就是以 ‘\0’ 结尾的 char 数组(或者宽字节 wchar 数组)。string_view 在内部也就是储存了这么一条数组的指针和一个长度。

2023-01-08 Update。 我看到一种说法,觉得挺合适的:可以把 std::string_view 理解为不以 ‘\0’ 结尾的字符串,但所有的 C-Style API 都需要以 ‘\0’ 为结尾的字符串,这就是我出错的原因。

那么当你调用某些需要 C-Style 字符串的 API 时,你可以没有任何开销的将 string_view 转换成 const char*,这么做的时候你不会有任何多余的想法,因为这确实是可行的,而且没有代价。

那么如果使用 C 语言的 printf 来打印 hostname 和 port,得到的结果将会是:

1
2
3
4
5
6
std::string url { "ws://localhost:8080/chat" };
std::string_view hostname = getHostname(url);

printf("%s", hostname.data());
// 实际运行结果: localhost:8080/chat
// 我期待的结果:localhost

这就导致了我调用的 C API 一直出错,我还一头雾水,一时间反应不过来为什么出错。

作者

uint128.com

发布于

2022-02-16

更新于

2023-01-08

许可协议

评论