11. 字符串转换
# 11. 字符串转换
string_view
并非C++17中与字符串相关的唯一特性。视图(views)能够减少临时副本的数量,同时C++17还有另一个便捷特性:转换工具。在新的C++标准中,有两组函数from_chars
和to_chars
,它们属于底层函数,有望显著提升性能。
在本章中,你将学到:
- 为什么我们需要底层的字符串转换例程?
- 为什么标准库中现有的选项可能不够用?
- 如何使用C++17的转换例程?
- 新的例程能带来哪些性能提升?
# 基础字符串转换
像JSON或XML这类数据格式越来越多,这就需要高效的字符串处理和操作。当这些数据格式用于网络通信时,最高性能尤为关键,因为高吞吐量是其中的关键因素。
例如,你从网络数据包中获取字符,对其进行反序列化(将字符串转换为数字),然后处理数据,最后再将数据序列化为相同的文件格式(将数字转换为字符串)并作为响应通过网络发送出去。
标准库在这些方面表现不佳。人们通常认为它对于这种高级字符串处理来说速度太慢。开发人员往往更倾向于自定义解决方案或第三方库。
随着C++17的出现,这种情况可能会改变,因为我们有了两组函数:from_chars
和to_chars
,它们支持底层的字符串转换。
在原始论文(P0067 (opens new window))中有一个很有用的表格,总结了当前所有的解决方案:
工具 | 缺点 |
---|---|
sprintf | 格式字符串、区域设置、缓冲区溢出 |
snprintf | 格式字符串、区域设置 |
sscanf | 格式字符串、区域设置 |
atol | 区域设置、不报告错误 |
strtol | 区域设置、忽略空白字符和0x 前缀 |
strstream | 区域设置、忽略空白字符 |
stringstream | 区域设置、忽略空白字符、内存分配 |
num_put / num_get 方面 | 区域设置、虚函数 |
to_string | 区域设置、内存分配 |
stoi 等 | 区域设置、内存分配、忽略空白字符和0x 前缀、抛出异常 |
从上面的表格可以看出,有时转换函数做了过多工作,这使得整个处理过程变慢。通常,这些额外的功能并没有必要。
首先,所有这些函数都使用 “区域设置”。即使你处理的是与语言无关的字符串,也得为本地化支持付出一点代价。例如,在解析XML或JSON中的数字时,无需应用当前系统语言,因为这些格式具有通用性。
下一个问题是错误报告。有些函数可能会抛出异常,而其他函数仅返回转换后的值。抛出异常不仅成本高昂(因为抛出异常可能涉及额外的内存分配),而且通常解析错误并非异常情况。像atoi
返回0、atof
返回0.0这种简单返回值的方式也不尽人意,因为这样你无法判断解析是否成功。
第三个问题,尤其与C风格的API相关,就是你必须提供某种形式的 “格式字符串”。解析这样的字符串可能会带来一些额外开销。
还有 “空白字符” 处理的问题。像strtol
或stringstream
这类函数可能会跳过字符串开头的空白字符。这可能很方便,但有时你并不想为这个额外功能付出代价。
另外一个关键因素是安全性。简单的函数没有提供任何防止缓冲区溢出的解决方案,并且它们仅适用于以空字符结尾的字符串。在这种情况下,你无法使用string_view
来传递数据。
新的C++17 API解决了上述所有问题。它们并非提供众多功能,而是专注于提供底层支持。这样一来,你可以获得最高的速度,并根据自己的需求进行定制。
新函数具有以下保证:
- 不抛出异常——在出现错误的情况下,它们不会抛出异常(与
stoi
不同)。 - 不分配内存——整个处理过程在原地完成,无需任何额外的内存分配。
- 不支持区域设置——字符串的解析就像使用默认(“C”)区域设置一样。
- 内存安全——指定了输入和输出范围,以便进行缓冲区溢出检查。
- 无需传递数字的字符串格式。
- 错误报告——你将获得有关转换结果的信息。
总之,在C++17中,你有两组函数:
from_chars
——用于将字符串转换为数字,包括整数和浮点数。to_chars
——用于将数字转换为字符串。接下来让我们更详细地了解这些函数。
# 从字符转换为数字:from_chars
from_chars
是一组重载函数:用于整数类型和浮点类型。对于整数类型,我们有以下函数:
std::from_chars_result from_chars(const char* first,
const char* last,
TYPE &value,
int base = 10);
2
3
4
其中TYPE
可以是所有可用的有符号和无符号整数类型以及char
类型。base
的取值范围可以是2到36。还有浮点型版本:
std::from_chars_result from_chars(const char* first,
const char* last,
FLOAT_TYPE& value,
std::chars_format fmt = std::chars_format::general);
2
3
4
FLOAT_TYPE
可以是float
、double
或long double
。chars_format
是一个枚举类型,具有以下值:
enum class chars_format {
scientific = /*unspecified*/,
fixed = /*unspecified*/,
hex = /*unspecified*/,
general = fixed | scientific
};
2
3
4
5
6
它是一种位掩码类型,所以枚举值是由实现定义的。默认情况下,格式设置为general
,因此输入字符串既可以使用 “普通” 浮点格式,也可以使用科学计数法格式。
所有这些函数(对于整数和浮点数)的返回值都是from_chars_result
:
struct from_chars_result {
const char* ptr;
std::errc ec;
};
2
3
4
from_chars_result
包含有关转换过程的重要信息。总结如下:
- 转换成功时,
from_chars_result::ptr
指向第一个不匹配模式的字符,如果所有字符都匹配,则其值等于last
,并且from_chars_result::ec
为值初始化状态。 - 转换无效时,
from_chars_result::ptr
等于first
,from_chars_result::ec
等于std::errc::invalid_argument
,value
未被修改。 - 数值超出范围时——数字太大,无法存储在目标值类型中。
from_chars_result::ec
等于std::errc::result_out_of_range
,from_chars_result::ptr
指向第一个不匹配模式的字符,value
未被修改 。
# 示例
为了总结本节内容,下面给出两个使用from_chars
将字符串转换为数字的示例。第一个示例将字符串转换为int
类型,第二个示例将其转换为浮点型。
- 整数类型:在
String Conversions/from_chars_basic.cpp
文件中:
#include <charconv>
#include <iostream>
#include <string>
int main() {
const std::string str { "12345678901234" };
int value = 0;
const auto res = std::from_chars(str.data(),
str.data() + str.size(),
value);
if (res.ec == std::errc()) {
std::cout << "value: " << value
<< ", distance: " << res.ptr - str.data() << '\n ';
}
else if (res.ec == std::errc::invalid_argument) {
std::cout << "invalid argument!\n ";
}
else if (res.ec == std::errc::result_out_of_range) {
std::cout << "out of range! res.ptr distance: "
<< res.ptr - str.data() << '\n ';
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
这个示例很直观。它将字符串str
传递给from_chars
,然后尽可能显示结果以及附加信息。
以下是不同str
值的输出结果:
str 值 | 输出 |
---|---|
12345 | value: 12345, distance 5 |
-123456 | value: -123456, distance: 7 |
12345678901234 | out of range! res.ptr distance: 14 |
hfhfyt | invalid argument! |
在12345678901234
这个例子中,转换例程能够解析这个数字(所有14个字符都被检查了),但它太大,无法存储在int
类型中,因此我们得到了out_of_range
错误。
2. 浮点类型:为了进行浮点型测试,我们可以将上一个示例的开头几行替换为:在String Conversions/from_chars_basic_float.cpp
文件中:
const std::string str { "16.78" };
double value = 0;
const auto format = std::chars_format::general;
const auto res = std::from_chars(str.data(),
str.data() + str.size(),
value,
format);
2
3
4
5
6
7
主要的区别在于最后一个参数:format
。下面是得到的示例输出:
str 值 | format 值 | 输出 |
---|---|---|
1.01 | fixed | value: 1.01, distance 4 |
-67.90000 | fixed | value: -67.9, distance: 9 |
1e+10 | fixed | value: 1, distance: 1 - scientific notation not supported |
1e+10 | fixed | value: 1, distance: 1 - scientific notation not supported |
20.9 | scientific | invalid argument!, res.p distance: 0 |
20.9e+0 | scientific | value: 20.9, distance: 7 |
-20.9e+1 | scientific | value: -209, distance: 8 |
F.F | hex | value: 15.9375, distance: 3 |
-10.1 | hex | value: -16.0625, distance: 5 |
general
格式是fixed
和scientific
的组合,因此它既可以处理常规的浮点数字符串,也支持e+num
语法。
你已经对将字符串转换为数字有了基本的了解,接下来让我们看看如何进行相反的操作。
# 解析命令行
在std::variant
章节中,有一个解析命令行参数的示例。该示例使用from_chars
来匹配最合适的类型:int
、float
或std::string
,然后将其存储在std::variant
中。
你可以在std::variant
章节的 “解析命令行” 部分找到这个示例。
# 将数字转换为字符:to_chars
to_chars
是一组针对整数和浮点类型的重载函数。对于整数类型,有如下声明:
std::to_chars_result to_chars(char* first, char* last,
TYPE value, int base = 10);
2
其中TYPE
可以是所有可用的有符号和无符号整数类型以及char
类型。
由于base
的取值范围是2到36,大于9的输出数字会用小写字母a
到z
表示。
对于浮点数,有更多选项。首先是一个基本函数:
std::to_chars_result to_chars(char* first, char* last, FLOAT_TYPE value);
FLOAT_TYPE
可以是float
、double
或long double
。
这种转换方式与printf
相同,并且使用默认(“C”)区域设置,它会使用%f
或%e
格式说明符,优先选择最短的表示形式。
下一个函数添加了std::chars_format fmt
参数,用于指定输出格式:
std::to_chars_result to_chars(char* first, char* last,
FLOAT_TYPE value,
std::chars_format fmt);
2
3
还有一个 “完整” 版本,允许指定精度:
std::to_chars_result to_chars(char* first, char* last,
FLOAT_TYPE value,
std::chars_format fmt, int precision);
2
3
当转换成功时,[first, last)
范围内会被填充转换后的字符串。
所有函数(包括整数和浮点类型支持的函数)的返回值都是to_chars_result
,其定义如下:
struct to_chars_result {
char* ptr;
std::errc ec;
};
2
3
4
该类型包含有关转换过程的信息:
- 转换成功时,
ec
等于值初始化的std::errc
,ptr
指向写入字符的末尾的下一个位置。注意,字符串没有以NULL
结尾。 - 发生错误时,
ptr
等于first
,ec
等于std::errc::invalid_argument
,value
未被修改。 - 数值超出范围时,
ec
等于std::errc::value_too_large
,[first, last)
处于未指定状态。
# 示例
总之,下面是一个to_chars
的基本示例。
在撰写本文时,还不支持浮点型的重载,所以这个示例仅使用整数。
在String Conversions/to_chars_basic.cpp
文件中:
#include <iostream>
#include <charconv> // from_chars, to_chars
#include <string>
int main() {
std::string str { "xxxxxxxx" };
const int value = 1986;
const auto res = std::to_chars(str.data(), str.data() + str.size(), value);
if (res.ec == std::errc()) {
std::cout << str << ", filled: " << res.ptr - str.data() << " characters\n";
} else {
std::cout << "value too large!\n";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
下面是一组数字的示例输出:
value | 输出 |
---|---|
1986 | 1986xxxx, filled: 4 characters |
-1986 | -1986xxx, filled: 5 characters |
19861986 | 19861986, filled: 8 characters |
-19861986 | value too large! (the buffer is only 8 characters) |
# 基准测试
到目前为止,本章提到了新例程巨大的性能潜力。那么,最好能看看实际的数据!
本节介绍一个基准测试,用于衡量from_chars
和to_chars
与其他转换方法的性能。
基准测试的工作方式如下:
- 生成大小为
VECSIZE
的随机整数向量。 - 每对转换方法将整数输入向量转换为字符串向量,然后再转换回另一个整数向量。这个往返转换过程会进行验证,确保输出向量与输入向量相同。
- 转换过程执行
ITER
次。 - 不检查转换函数的错误。
- 代码测试以下转换方法:
from_char/to_chars
to_string/stoi
sprintf/atoi
ostringstream/istringstream
你可以在String Conversions/conversion_benchmark.cpp
文件中找到完整的基准测试代码。下面是from_chars/to_chars
的代码:
const auto numIntVec = GenRandVecOfNumbers(vecSize);
std::vector<std::string> numStrVec(numIntVec.size());
std::vector<int> numBackIntVec(numIntVec.size());
std::string strTmp(15, ' ');
RunAndMeasure("to_chars", [&]() {
for (size_t iter = 0; iter < ITERS; ++iter) {
for (size_t i = 0; i < numIntVec.size(); ++i) {
const auto res = std::to_chars(strTmp.data(),
strTmp.data() + strTmp.size(),
numIntVec[i]);
numStrVec[i] = std::string_view(strTmp.data(),
res.ptr - strTmp.data());
}
}
return numStrVec.size();
});
RunAndMeasure("from_chars", [&]() {
for (size_t iter = 0; iter < ITERS; ++iter) {
for (size_t i = 0; i < numStrVec.size(); ++i) {
std::from_chars(numStrVec[i].data(),
numStrVec[i].data() + numStrVec[i].size(),
numBackIntVec[i]);
}
}
return numBackIntVec.size();
});
CheckVectors(numIntVec, numBackIntVec);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
CheckVectors
函数用于检查两个输入的整数向量是否包含相同的值,如果有错误则打印出不匹配的地方。
该基准测试将
vector<int>
转换为vector<string>
,我们测量的是整个转换过程,其中也包括字符串对象的创建。
以下是在一个包含1000个元素的向量上运行1000次迭代的结果(时间单位为毫秒):
方法 | GCC 8.2 | Clang 7.0 Win | VS 2017 15.8 x64 |
---|---|---|---|
to_chars | 21.94 | 18.15 | 24.81 |
from_chars | 15.96 | 12.74 | 13.43 |
to_string | 61.84 | 16.62 | 20.91 |
stoi | 70.81 | 45.75 | 42.40 |
sprintf | 56.85 | 124.72 | 131.03 |
atoi | 35.90 | 34.81 | 32.50 |
ostringstream | 264.29 | 681.29 | 575.95 |
stringstream | 306.17 | 789.04 | 664.90 |
测试机器配置:Windows 10 x64,i7 8700,基础频率3.2 GHz,6核/12线程(尽管基准测试仅使用一个线程进行处理)。
- GCC 8.2——使用
-O2 -Wall -pedantic
编译,MinGW发行版 (opens new window) - Clang 7.0——使用
-O2 -Wall -pedantic
编译,适用于Windows的Clang (opens new window) - Visual Studio 2017 15.8——发布模式,x64
一些说明:
- 在GCC上,
to_chars
比to_string
快近3倍,比sprintf
快2.6倍,比ostringstream
快12倍! - 在Clang上,
to_chars
比to_string
稍慢,但比sprintf
快约7倍,令人惊讶的是,比ostringstream
快近40倍! - 在MSVC上,与
to_string
相比性能也较慢,但to_chars
比sprintf
快约5倍,比ostringstream
快约23倍。
现在来看from_chars
:
- 在GCC上,它比
stoi
快约4.5倍,比atoi
快2.2倍,比istringstream
快近20倍。 - 在Clang上,它比
stoi
快约3.5倍,比atoi
快2.7倍,比istringstream
快60倍! - 在MSVC上,它比
stoi
快约3倍,比atoi
快约2.5倍,比istringstream
快近50倍!
如前所述,该基准测试也包含了字符串对象创建的开销。这就是为什么to_string
(针对字符串进行了优化)可能比to_chars
表现稍好一些。如果你已经有一个字符缓冲区,并且不需要创建字符串对象,那么to_chars
应该会更快。
下面是根据上述表格生成的两个图表。
图 11.1 将字符串向量转换为整数向量(时间,单位:毫秒)
图 11.2 将整数向量转换为字符串向量(时间,单位:毫秒)
一如既往,在做出最终判断之前,建议你自己运行这些基准测试。在你的环境中,可能会因为使用不同的编译器或STL库实现而得到不同的结果。
# 总结
本章展示了如何使用两组函数:from_chars
用于将字符串转换为数字,to_chars
用于将数字转换为文本表示形式。
这些函数可能看起来非常底层,甚至带有C语言风格。这是为获得底层支持、性能、安全性和灵活性所付出的 “代价”。好处是你可以提供一个简单的包装器,只暴露你需要的部分。
(此处应有一张图片,但文档中未提供图片相关信息,故无法准确翻译。)
额外信息
相关变更提案在:P0067 (opens new window)。
# 编译器支持情况
特性 | GCC | Clang | MSVC |
---|---|---|---|
基础字符串转换 | 8.0⁵ | 7.0⁶ | VS 2017 15.7/15.8⁷ |
批注:
⁵ 开发中,仅支持整数类型 ⁶ 开发中,仅支持整数类型 ⁷ 在15.7版本中,
from_chars/to_chars
支持整数类型;在15.8版本中,from_chars
支持浮点类型。15.9版本中to_chars
对浮点类型的支持应该会完成。详见《Visual C++团队博客:VS 2017 15.8中的STL特性和修复 (opens new window)》。