C语言中无法解析整数 (2022)
摘要
文章批评了C标准库中用于解析整数的函数(atol、strtol、strtoul、sscanf),解释了为什么大部分函数存在缺陷,只有strtol在仔细进行错误处理的情况下才能正确使用。
暂无内容
查看缓存全文
缓存时间: 2026/05/20 14:27
# 在 C 语言中无法正确解析整数
来源:https://blog.habets.se/2022/10/No-way-to-parse-integers-in-C.html
C 标准库中有几种尝试将字符串解析为数字的方法。它们全都**有问题**。
底部更新:实际上 C++ 的 `std::from_chars()` 看起来很有用。
暂时忽略宽字符版本,只关注 `long`(跳过 `int`、`long long` 或 `intmax_t`,这些变体都有相同的问题),我能想到三种方法:
1. `atol()`
2. `strtol()` / `strtoul()`
3. `sscanf()`
它们全都有问题。
## 那么,正确的行为应该是什么?
我先声明一个常识性的“我看到就知道”。我用眼睛在字符串中看到的数字,必须作为相应的数据类型存储其数值。"123" 必须变成数字 `123`。
另一个标准是**整个**数字都必须被解析。在遇到第一个问题时就停止并返回一个可能正确的值是不可以的。"123timmy" 不是数字,空字符串也不是。
如果未能提供上述行为,必须是一个**错误**。或者至少,作为解析器的用户,我必须**有办法**知道是否发生了错误。
## 首先:`atol()`
| 输入 | 输出 |
|------|------|
| 123timmy | 123 |
| 99999999999999999999999999999999 | LONG_MAX |
| timmy | 0 |
| 空字符串 | 0 |
| `" "` | 0 |
不行。全都不对。而且调用者无法知道发生了什么。对于 `LONG_MAX` 溢出情况,手册没有明确说明是应该这样做,还是返回尽可能多的 9,但在 Linux 上实际就是如此。POSIX 和 C 都说“如果值无法表示,行为未定义”。搞什么?这使得 `atol()` 无法用于不可信输入。在实践中,我还没见过编译器和 libc 因为坏输入而触发 UB 的可怕部分,但从理论上讲,`atol()` 可以在收到坏输入时擦除你的硬盘。太棒了。如果没有办法检查错误,我怎么能知道这个值是否可以被表示?所以如果你给 `atol()` 传递一个字符串,你基本上得到一个随机值,只是大多数时候偏向于正确。
我有点能原谅 `atol()`。它来自一个更简单的时代,一个 `gets()`(https://en.cppreference.com/w/c/io/gets)看起来是个好主意的时代。`gets()` 以**无法**被正确使用而闻名。`atol()` 也一样。
## 下一个:`strtol()`
我现在要反驳本文的标题。`strtol()` 实际上可以被正确使用。`strtoul()` 不能,但如果你只处理有符号类型,那么这实际上能工作。但需要小心。手册中有示例代码,但以函数形式写出来是这样的:
```c
bool parse_long(const char* in, long* out) {
// 检测空字符串。
if (!*in) {
fprintf(stderr, "empty string\n");
return false;
}
// 解析数字。
char* endp = NULL; // 指向字符串末尾。
errno = 0; // 预设 errno 为 0。
*out = strtol(in, &endp, 0);
// 范围错误通过 errno 传递。
// 例如,在 amd64 Linux 上,值必须在 -2^63 和 2^63-1 之间。
if (errno) {
fprintf(stderr, "error parsing: %s\n", strerror(errno));
return false;
}
// 检查字符串末尾是否有垃圾数据。
if (*endp) {
fprintf(stderr, "incomplete parsing\n");
return false;
}
return true;
}
```
这取决于 API 是否允许在错误情况下修改 `*out`,但这只是一个小细节。耶,有符号数字可以解析了!
## 那 `strtoul()/strtoull()` 呢?
与它的兄弟不同,这个函数无法被正确使用。
```
strtoul() 函数返回转换的结果,或者,如果存在前导减号,则以无符号值形式返回转换结果的相反数。
```
在 amd64 Linux 上的示例输出:
| 输入 | 输出 |
|------|------|
| -1 | 18446744073709551615 (2^64-1) |
| -9223372036854775808 | 9223372036854775808 (2^63) |
| -9223372036854775809 | 9223372036854775807 (2^63-1) |
| `" "` (仅有空格) | 错误:endp 不为 null |
| -18446744073709551614 | 2 |
| -18446744073709551615 | 1 |
| -18446744073709551616 | 错误 ERANGE |
终于,终于报告了一个错误。这完全没用。或者应该说:也许某些用例下有用,但它绝对不是一个能返回我所请求数字的函数。
Linux 手册的标题是“将字符串转换为 unsigned long integer”。它确实这样做了。技术上,它转换成了**一个** unsigned long integer。但不是明显正确的那一个,但它确实返回了一个 unsigned long。
有趣的是,一个非空的、只有空格的输入可以被检测为错误。这显然是正确的做法,但不清楚这是否是故意的。所以检查一下你的实现:如果输入全是 `isspace()` 字符,它能否被正确检测为错误?如果不能,那么 `strtol()` 可能也有问题。
## 也许 `sscanf()` 呢?
代码少一些,不错:
```c
bool parse_ulong(const char* in, unsigned long* out) {
char ch;
int len;
if (1 != sscanf(in, "%lu%n%c", out, &len, &ch)) {
fprintf(stderr, "Failed to parse\n");
return false;
}
// 这从未触发过,所以 sscanf() 似乎不会在溢出时停止解析。
// 因此跳过长度检查也是安全的。
if (len != (int)strlen(in)) {
fprintf(stderr, "Did not parse full string\n");
return false;
}
return true;
}
```
| 输入 | 输出 |
|------|------|
| `" "` (仅有空格) | Failed to parse |
| -1 | 18446744073709551615 (2^64-1) |
| -9223372036854775808 | 9223372036854775808 (2^63) |
| -9223372036854775809 | 9223372036854775807 (2^63-1) |
| -18446744073709551614 | 2 |
| -18446744073709551615 | 1 |
| -18446744073709551616 | 18446744073709551615 (2^64-1) |
正如我们所见,这当然是胡说八道(除了第一个)。最后一个特别有趣。根据前两个,你可能期望是 `0`,或者至少是偶数。但不是。最后一个数字只是“超出范围”,它被报告为 `ULONG_MAX`。但你无法知道这一点。得到 `ULONG_MAX` 作为你的值可能有以下几种情况:
1. 输入恰好就是这个值。
2. 输入是 `-1`。
3. 输入超出范围,要么大于 `ULONG_MAX`,要么小于负的 `ULONG_MAX` 加一。
无法区分这些情况。所以 `sscanf()` 也不行了。
## 这为什么重要?
垃圾进垃圾出,对吧?如果有人给你 `-18446744073709551615`,并知道你会把它解析成 `1`,为什么重要呢?也许是个有趣的把戏,比如 `ping 0`。
首先,因为这是错误的,所以很重要。这实际上不是提供的数字。也许你在解析文件中的一堆数据。你真的应该遇到错误就停止,或者至少跳过坏数据。但错误的解析会让你继续处理,就好像数据是正确的。
也许某个 ACL 只允许你提供负数,你利用这个技巧让它在某些上下文中(例如 Python)被解析为负数,但在其他上下文中(`strtoul()`)被解析为正数。
我甚至看到一条评论说“当你有像这样具体的要求时”(https://softwareengineering.stackexchange.com/questions/251508/is-there-a-stricter-strtoull-in-any-ubiquitous-c-library)。像“正确解析数字”这样具体?程序应该对任何给定的输入做正确的事情。API 应该能被正确使用。刀应该有刀柄。刀锋利没问题,但没有一把刀应该没有安全握持的地方。应该可以检查错误。
## 我能绕过它吗?
你甚至无法把这些片段拼凑成一个能用的 unsigned long 解析器。也许你认为可以过滤掉不正确的情况,解析剩下的。但不行。
你可以用 `strtol()` 检测负数,进行范围检查,丢弃所有这些。但是你无法区分低端超出范围的值(-2^64...-2^63)和完全有效的 unsigned long 上半部分(2^63-1...2^64-1)。使用更大整数类型也不是解决方案。`long` 在我的系统上就是 `long long` 和 `intmax_t`。
## 实践中我该怎么做?
你需要解析 `unsigned long` 的上半部分吗?如果不需要,那么:
1. 使用 `strtol()`
2. 检查是否小于零
3. 强制转换为 `unsigned long`
如果你只需要 unsigned int,那么也许在你的系统上 `sizeof(int) > sizeof(long)`?相同的问题。
## 结论
为什么一切都坏了?我认为把字符串转换成数字并不是过分的要求。在我的日常工作中,我处理复杂的系统,需要复杂的权衡。解析数字并不涉及权衡,也不复杂。在 Python 中,只需 `int("123")`,它就会做显而易见的事情。但只有有符号版本。
也许 Google 说的基本上永远不要使用无符号类型(https://google.github.io/styleguide/cppguide.html#Integer_Types)是对的。我知道那里列出的理由,但我之前没有意识到 C 和 C++ 标准库的字符串到整数的解析器对于无符号类型也是基本上有致命缺陷的。
但即使遵循这个建议,有时你也需要解析整数形式的位字段。那你完了。
## 更新:`std::from_chars` 似乎可行
```cpp
template<typename T>
std::optional<T> parse_int(std::string_view sv) {
T i;
const auto [ptr, ec] = std::from_chars(sv.begin(), sv.end(), i);
if (ec != std::errc()) {
std::cerr << "Parse error\n";
return {};
}
if (ptr != sv.end()) {
std::cerr << "Trailing data\n";
return {};
}
return i;
}
```
标准规定:
```
模式是给定非零基数的 "C" 语言环境中主题序列的预期形式,如同 strtol 所描述,除了如果基数为 16 则不应出现 "0x" 或 "0X" 前缀,并且只能出现负号,且仅当值具有有符号类型时。
```
听起来没错。没有“即使是无符号类型也允许负号”的 BS。
## 更新 2023-12-11:Alejandro Colomar 来拯救!
读完这篇文章后,Alejandro 提出了一个我现在看来很明显的方法;只需检查那些负数值!于是我们有了这个 C 版本:
```c
inline bool strtoul_noneg(unsigned long* out, const char *nptr, char **restrict endptr, int base) {
// 禁止空字符串。
if (!*nptr) {
return false;
}
// strtoul() 接受前导空格。我们不允许。
if (isspace(*nptr)) {
return false;
}
// 排除负数,包括低端超出范围的值。
if (strtol(nptr, endptr, base) < 0) {
return false;
}
// 我们已经手动排除了边缘情况。
// 现在可以正常使用 strtoul()。
errno = 0;
*out = strtoul(nptr, endptr, base);
if (**endptr) {
return false;
}
if (errno) {
return false;
}
return true;
}
```
万岁!他还将其添加为该 stackexchange 问题(https://softwareengineering.stackexchange.com/questions/251508/is-there-a-stricter-strtoull-in-any-ubiquitous-c-library)的候选答案。
disqus 开始显示广告了。:-\(
显示(可能不完整的)评论,以静态只读视图显示。点击按钮才能发表评论。
相似文章
关于整数的思考 (2023)
一篇博客文章,讨论了各种编程语言中整数类型的设计,认为 Rust 强制要求显式指定大小和符号的做法优于那些有默认 `int` 类型的语言。
sizeof 在 C 中解析出乎意料地困难
本文探讨了解析 C 语言 `sizeof` 运算符所面临的挑战,该运算符可以接受表达式或带括号的类型,而复合字面量和后缀运算符又带来了额外的复杂性。文章讨论了解析策略,并指出 C2y 新引入的 `_Countof` 运算符也面临类似问题。
John Regehr 的 C 语言整数测验
这是 John Regehr 的 C 语言整数测验的存档版本,使用 JavaScript 封装以实现浏览器兼容性,重点介绍了 C 代码中棘手的未定义行为和整数问题。
用C语言搞怪,第&((int*)-8)[3]部分
一篇幽默的教育性文章,涵盖C语言基础知识,如前向声明、运算符优先级、无条件跳转和基本算术运算,并附带有意搞怪的代码示例。
解析C语言中类型推断声明之险
这篇博文探讨了C23中涉及`auto`作为类型推断说明符或存储类说明符的解析歧义,展示了当`x`是typedef时,GCC和Clang在解析如`auto x = 67;`这样的声明上的分歧,以及属性如何使情况复杂化。