1. 从一次静默的错值说起:为什么要自己写 strtoi
配置文件里写了port = 808O,最后一个字符是大写字母 O 而不是数字 0。程序启动,atoi返回 0,端口传进监听接口,系统随机分配了一个端口,服务看着是起来了,客户端连不上,日志里一行报错都没有。那次排查花了两个多小时,最后把配置文件的十六进制编辑器打开才发现是一个字符的问题。从那以后我在所有需要字符串转数字的地方都不再用atoi,而是统一走一个自己写的bool strtoi(const char *s, int &out)。这个函数干的事情很朴素:把字符串按十进制解析成整数,成功返回 true 并把结果写进out,失败返回 false,out一个字节都不动。它解决的问题只有一个——让“转换失败”这件事变得无法被忽略。
适合谁看?写过 C 或 C++ 的人都能看懂,但真正有价值的读者是那些在解析配置文件、命令行参数、通信协议字段、日志文本的人。如果你曾经被atoi返回的 0 骗过,或者被strtol的errno坑过,这篇内容基本就是给你准备的。下面我会从接口设计、实现细节、溢出判断的数学过程、和标准库方案的对比、以及实际踩坑这几个角度完整拆一遍,代码可以直接拿走用。
1.1 atoi 家族的三处硬伤
atoi的问题不在于它慢,而在于它的错误处理策略是“静默吞掉”。第一处硬伤是没有错误通道:输入"abc"返回 0,输入"0"也返回 0,输入空串还是返回 0,调用方拿到的信息量是零。你没法区分“用户就是想填 0”和“用户填错了”。
第二处硬伤是溢出时的行为。atoi对超出int范围的输入,行为是未定义的。也就是说atoi("99999999999999")在某个编译器上可能返回一个巨大的负数,在另一个编译器上可能返回INT_MAX,换个优化等级结果还可能变。这种代码在测试环境里看着正常,上线换个编译选项就炸,属于最难查的一类问题。
第三处硬伤是它不告诉你停在哪儿了。"123abc"这种输入,atoi会把 123 解析出来然后把abc悄悄丢掉。如果你的业务里这个字段本该是纯数字,那abc的出现就意味着上游数据有问题,应该立刻报错,而不是被无声截断。所以后来大家转向strtol,但strtol又带来了新的复杂度,这个我们在第 4 节细说。
1.2 用 bool 返回值把“成功”变成一等公民
把成功与否放在返回值里,最直接的好处是调用点写起来非常自然:
int port = 0; if (!strtoi(cfg.get("port").c_str(), port)) { log_error("port 字段非法: %s", cfg.get("port").c_str()); return false; }这里if的存在本身就是一道强制检查。编译器不会提醒你忽略atoi的返回值,因为它是int;但bool返回值配上[[nodiscard]](C++17 起可用),忽略它就会产生警告。这个设计上的小动作,比写十页编码规范都管用。
另一个考虑是把结果放进int &out而不是塞进返回值。返回值只有一个位置,要么放结果要么放状态,二选一。用int &接结果、bool接状态,两者各归各位,调用方读代码的时候不需要在脑子里做解包。而且失败时我可以约定“不修改out”,调用方即使忘了检查返回值,拿到的也只是调用前的旧值而不是 0,配合日志有时候能更快定位问题,虽然这只能算一层兜底。
1.3 先定契约:这个函数到底承诺了什么
写任何解析函数之前,我都会先把契约写清楚,因为后面所有的实现细节都是从契约推出来的。这个strtoi的契约我定成这样:
- 允许前导空白字符,包括空格、
\t、\n、\v、\f、\r,数量不限。 - 允许一个可选的
+或-符号,且符号后必须紧跟至少一位十进制数字。 - 只接受十进制数字
0到9,不接受十六进制前缀0x、不接受千分位分隔符、不接受小数点、不接受科学计数法。 - 数字序列结束后必须立刻遇到字符串结束符
\0,中间出现任何其它字符(包括空格)都算失败。 - 数值必须落在
[INT_MIN, INT_MAX]闭区间内,超出任何一个方向都算失败,不做饱和截断。 - 失败时
out保持调用前的值不变。 - 空指针、空字符串、只有空白、只有符号、只有数字以外字符的输入,一律返回 false。
- 函数不修改输入缓冲区,不分配内存,不加锁,可重入。
这个契约里最值得讨论的是“尾部必须干净”这一条。宽松派会认为"123 "也应该成功,因为末尾空格很常见。我的选择是严格,因为宽松判断一旦默认开启就没法关掉,而严格判断在需要的时候可以在调用方trim一次。而且真实场景里,"123 abc"这种输入往往意味着配置文件写串行了或者协议分包出了问题,放过去反而更危险。如果确实有场景需要容忍尾部空白,加一个bool allow_trailing_space参数,让调用方显式选择,不要在默认行为里偷偷放宽。
2. 签名拆解:每个字符都在传递设计意图
bool strtoi(const char *, int &)这个签名短得像一行注释,但每一部分都有明确的取舍在里面。我见过不少人在看完这段之后会问“为什么不用std::string_view”“为什么不用optional<int>”,这些问题都值得回答,因为选型背后是使用场景的约束。
2.1 const char * 不加长度参数意味着什么
没有长度参数,意味着这个函数要求输入以\0结尾。这看起来是个限制,实际上适配面非常广:命令行argv天然是\0结尾,std::string::c_str()是\0结尾,fgets读出来的缓冲区是\0结尾,从网络收到的定长报文如果手动补了\0也能用。
代价当然也有。如果你的输入是一段不带\0的定长报文,就必须先memcpy到一个多留一个字节的缓冲区里再补零,多一次拷贝。跨网络的协议解析里这个成本值得考虑,那种场景下我会额外提供一个bool strtoi(const char *s, size_t n, int &out)的重载,内部逻辑完全一样,只是循环条件从*p != '\0'换成p != s + n,代码复用度很高,这也是把核心逻辑抽成模板或者宏的原因。
const那一撇也不能省。它约束的是函数内部不写这块内存,同时也让调用方能放心地把字符串字面量直接传进来。副作用是如果你需要在函数内部做trim这类原地修改,就得绕一下,这也是我选择“内部用指针游标推进、不修改原串”的原因之一。整个函数对输入缓冲区是只读的,这在多线程环境下少了一层心理负担。
2.2 int & 输出参数与 bool 返回值的分工
输出参数在 C++ 圈子里名声不太好,Google 的编码规范和很多团队的内部规范都倾向“输出用返回值”。但在这个场景里,int &反而是更合适的选择,理由在于返回值已经被状态占用了,如果硬要把结果和状态塞进同一个返回类型,就得上std::optional<int>或者std::pair<bool, int>,前者需要 C++17,后者调用点写起来更啰嗦。
还有一个很实际的原因:int &允许调用方预置一个默认值。比如端口字段允许缺省,调用方先写int port = 8080;,再调用strtoi,字段不存在时直接跳过调用,字段存在但非法时函数返回 false 且不改port,两种情况下port都是可用的。这种“预置初值 + 条件覆盖”的写法在配置解析里非常顺手,用返回值方案也能写,但要多一行显式赋值。
要提醒一点:int &要求传入的是左值,strtoi(s, 42)这种写法编译不过,这其实是好事,避免了往临时对象里写入结果这种无意义操作。另外如果调用方手上是一个long或者size_t变量,不能直接传进来,得先声明一个int中转,虽然多一行但类型边界更清晰,也避免了隐式窄化带来的范围问题。
2.3 为什么我选裸指针而不是 string_view / optional
std::string_view是 C++17 的东西,语义上比裸指针好——它自带长度,不用依赖\0。但它引入了一个新的坑:string_view不保证指向的内存有效,从临时std::string构造出来的string_view会在语句结束后悬空。我见过太多人写出parse_int(std::string(buf), out)这种代码,构造的临时对象在函数返回前就析构了。用裸指针就没这个问题,因为字符串字面量和c_str()的生存期是调用方自己负责的,责任边界清楚。
std::optional<int>的返回类型确实优雅,但它在嵌入式或者老编译器环境下不一定可用,而且optional的has_value()检查在调用点写起来不如if (!strtoi(...))直观。更实际的问题是:如果统一用optional,那些已经有int变量的调用点就得改成auto r = strtoi(s); if (r) port = *r;,代码量不降反增。接口设计要看调用点的平均成本,而不是看单个函数声明的漂亮程度。
至于命名,还有个小细节值得一提:某些平台的标准库里已经存在一个叫strtoi的符号(签名还不一样),为了避免潜在的重定义冲突,我通常把它放在自己的命名空间里,或者声明成static限定在当前编译单元。这一点移植到别的平台时很容易踩到,链接期报重复定义,排查起来要翻半天头文件。
3. 手写实现的完整代码与逐段拆解
这一节是全文的核心。我先把完整的实现贴出来,然后逐段解释每个判断存在的理由,特别是溢出判断那一段,很多人在那里写错。
3.1 完整实现
#include <climits> #include <cstddef> namespace numparse { // 解析十进制整数。成功返回 true 并写入 out; // 失败返回 false,out 保持不变。 [[nodiscard]] bool strtoi(const char *s, int &out) { if (s == nullptr) { return false; } const char *p = s; // 1. 跳过前导空白(手写白名单,不用 isspace) while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\v' || *p == '\f' || *p == '\r') { ++p; } // 2. 处理可选符号位 bool neg = false; if (*p == '+' || *p == '-') { neg = (*p == '-'); ++p; } // 3. 符号之后必须至少有一位数字 if (*p < '0' || *p > '9') { return false; } // 4. 以无符号量累加,上限按符号分岔 const unsigned limit = neg ? static_cast<unsigned>(INT_MAX) + 1u : static_cast<unsigned>(INT_MAX); unsigned acc = 0; while (*p >= '0' && *p <= '9') { const unsigned digit = static_cast<unsigned>(*p - '0'); if (acc > (limit - digit) / 10u) { return false; // 越界,提前退出 } acc = acc * 10u + digit; ++p; } // 5. 尾部必须干净 if (*p != '\0') { return false; } // 6. 回写到 out if (!neg) { out = static_cast<int>(acc); } else if (acc == static_cast<unsigned>(INT_MAX) + 1u) { out = INT_MIN; // 单独处理下界,避免 -(int)acc } else { out = -static_cast<int>(acc); } return true; } } // namespace numparse代码不长,四十行左右,但每一段都有讲究。下面按解析顺序拆。
3.2 前导空白与符号位的处理
第 1 段的空白判断我故意没用isspace。原因有两个:一是isspace的行为受当前 locale 影响,切换 locale 之后某些字符的判定会变,而配置解析这种场景不该有 locale 依赖;二是isspace的参数是int,如果把一个char直接传进去,而这个char是有符号类型且值为负(比如 UTF-8 中文的首字节0xE4),那么传进去的就是一个负数,标准规定这个值是EOF之外的值时行为未定义。这个坑非常经典,正确写法是isspace(static_cast<unsigned char>(*p)),但既然手写白名单更可控,索性就不用它了。
第 2 段的符号处理有个容易忽略的细节:符号只能出现一次,且必须紧跟数字。"--1"会在处理掉第一个-之后,看到第二个-不是数字,直接在第 3 段返回 false,符合预期。"- 1"同理,因为符号后面是空格不是数字。而"+-1"在吃掉+之后看到-也不是数字,同样失败。这些都不需要额外的状态机,靠着“符号只有一次”和“紧跟数字”两个约束自然就排除了。
第 3 段的数字检查我写成了字符区间比较而不是isdigit,这其实省掉了前面提到的有符号问题。*p是char,可能是负数,但'0'到'9'这一段在 ASCII 里是 0x30 到 0x39,所有负值都小于'0',比较结果是安全的,不会出现负值落进区间里的情况。这一点很多人担心,其实不用担心,只要你的目标平台用 ASCII 或者 ASCII 兼容编码(EBCDIC 不在讨论范围内)。
3.3 溢出判定的数学推导与临界值
第 4 段是整个函数里最容易写错的地方。核心问题是这样:我每一步都在做acc = acc * 10 + digit,如果不在乘法之前检查,unsigned一旦超过 4294967295 就会回绕,那你算出来的结果是个毫无意义的数,即使后来发现越界也只能靠运气。
正确做法是“先判断这一步加上去会不会超,再决定要不要加”。设当前已经累积的值是acc,接下来要加的位是digit,允许的最大值是limit。要保证acc * 10 + digit <= limit,做一下等价变形:
acc * 10 + digit <= limit acc * 10 <= limit - digit acc <= (limit - digit) / 10所以越界的条件就是acc > (limit - digit) / 10。这个式子里limit - digit会不会下溢?对于正数,limit = 2147483647,digit最大是 9,limit - digit最小是 2147483638,远大于 0;对于负数,limit = 2147483648,同样安全。整数除法会向下取整,这里取整的方向恰好让判断偏向保守,不会漏判,可以放心用。
这里的limit为什么要按符号分岔?因为int的正负范围不对称:INT_MAX是 2147483647,INT_MIN是 -2147483648,负方向多一个值。如果统一按 2147483647 判断,那么"-2147483648"这个合法输入会被错误地判为越界。所以负数时的上限要设成|INT_MIN|,也就是2147483648,注意这个值已经超出INT_MAX了,不能直接写成int字面量,必须用static_cast<unsigned>(INT_MAX) + 1u这种形式绕出来。
顺带说一个实际收益:因为判断的是“值有没有超”而不是“位数有没有超”,所以"000000000000000000000042"这种一大串前导零的输入能正确解析成 42,不会因为位数多就误判溢出。这个用例在我的测试集里是必测项,很多手写实现会栽在这里。
3.4 负数回写这一段为什么最容易写错
第 6 段是第二个容易翻车的地方。如果累加出来的是acc = 2147483648且neg == true,最直觉的写法是out = -static_cast<int>(acc)。问题在于static_cast<int>(2147483648u)这一步:这个值超出了int的表示范围。
这里要分标准版本说。C++20 之前,无符号转有符号且值超出范围,结果是实现定义的(implementation-defined),不是未定义,但也没保证。主流编译器在补码平台上通常做模 2 的 32 次方截断,结果是 -2147483648,取负之后变成 2147483648,又溢出了,于是得到一个错误的正数。C++20 起标准规定这种转换是模运算,结果确定,但依赖标准版本的行为写代码不是好习惯。
稳妥的写法就是显式分支:如果acc恰好等于2147483648,直接赋INT_MIN,否则再走取负路径。多这三行,能免掉一类只在边界值上出现、测试还很容易覆盖不到的隐蔽 bug。写解析代码的经验是:所有INT_MIN相关的分支都要单独拿出来想一遍,因为它是整个类型里唯一一个“取负会溢出”的值。
3.5 用一张表把边界值钉死
下面这张表是我自测时固定跑的一组用例,建议你在自己实现完第一版之后原样跑一遍。表格里“out 是否变化”这一列,靠的是函数只写成功路径这个约定。
| 输入 | 返回值 | out 结果 | 考察点 |
|---|---|---|---|
"0" | true | 0 | 最基础的零值 |
"" | false | 不变 | 空串不能当成 0 |
" " | false | 不变 | 只有空白 |
"42" | true | 42 | 普通正数 |
" +42" | true | 42 | 前导空白 + 正号 |
"-42" | true | -42 | 普通负数 |
"-0" | true | 0 | 负零应归零 |
"2147483647" | true | INT_MAX | 上界 |
"2147483648" | false | 不变 | 上溢一位 |
"-2147483648" | true | INT_MIN | 下界 |
"-2147483649" | false | 不变 | 下溢一位 |
"0000000000000000000042" | true | 42 | 大量前导零不该误判 |
"123abc" | false | 不变 | 尾部脏数据 |
"12 3" | false | 不变 | 中间有空格 |
"123 " | false | 不变 | 尾部空格(严格模式) |
"0x10" | false | 不变 | 不支持十六进制 |
"1.5" | false | 不变 | 不支持小数 |
"+" | false | 不变 | 只有符号 |
"-" | false | 不变 | 只有符号 |
"+-1" | false | 不变 | 重复符号 |
nullptr | false | 不变 | 空指针保护 |
4. 与标准库方案的逐项对位
“有标准库为什么还要自己写”这个问题我听过很多次。答案不是标准库不好,而是这几个方案的契约和我们想要的契约不完全一致。下面把三条主流路线摆在一起对比。
4.1 strtol + errno:能用但坑多
strtol是解析整数最常用的标准方案。它的签名是long strtol(const char *nptr, char **endptr, int base),错误通过两个通道传出:endptr告诉你停在哪里,errno告诉你有没有溢出。
bool strtoi_strtol(const char *s, int &out) { if (s == nullptr) return false; errno = 0; // 必须手动清零 char *end = nullptr; const long v = std::strtol(s, &end, 10); if (end == s) return false; // 一个字符都没吃掉 if (errno == ERANGE) return false; if (*end != '\0') return false; // 尾部脏 if (v < INT_MIN || v > INT_MAX) return false; // long 到 int 的收窄 out = static_cast<int>(v); return true; }这段代码能跑,但有几个地方让人不放心。第一,errno必须在调用前清零,忘了这一步,前一次某个库函数留下的ERANGE会让你这次的合法输入被判为溢出,这类 bug 出现概率不高但复现极难。第二,end == s这个判断能识别“一个字符都没解析”,但它同时也会把" abc"判为失败——实际上strtol会先跳过前导空白,end指向a而不是字符串开头,所以end != s但也没有解析出数字,这种情况得额外判断end是不是指向一个数字,代码就更长了。
第三,也就是最麻烦的一点,long的宽度是平台相关的。在 64 位 Linux 上long是 8 字节,strtol能表示的范围远大于int,所以必须额外做一次v < INT_MIN || v > INT_MAX的收窄检查,否则"3000000000"会被strtol正常解析成 3000000000,然后static_cast<int>静默变成一个负数。而在 32 位平台上long就是 4 字节,strtol自己就会报ERANGE。同一份代码在不同平台上走的是不同分支,这种可移植性差异是做跨平台项目时最头疼的。
4.2 std::from_chars:快,但契约更严
C++17 引入的std::from_chars是这一代标准库里最实用的工具之一,它不分配内存、不受 locale 影响、不需要errno,性能通常也优于strtol。
#include <charconv> bool strtoi_from_chars(const char *s, int &out) { if (s == nullptr) return false; const char *end = s + std::strlen(s); int value = 0; const auto r = std::from_chars(s, end, value, 10); if (r.ec == std::errc::invalid_argument) return false; // 没有数字 if (r.ec == std::errc::result_out_of_range) return false; // 溢出 if (r.ptr != end) return false; // 尾部有剩余字符 out = value; return true; }它的行为差异在于契约更严格:from_chars不跳过前导空白,也不接受+号(只接受-)。这跟我们要的契约正好差一截,所以在调用它之前得自己先做一遍空白跳过和符号处理,或者先手工剥掉+再交给它。另外它在解析过程中会“吃掉”符号并把结果写到value里,即使中途失败也可能已经改写了value,所以不能直接把out传进去,必须用临时变量中转,这也是上面代码里多了一个int value的原因。
from_chars还有一个场景限制:它在某些较老的编译器或标准库实现里是缺失的,尤其是嵌入式工具链和几年前发布的编译器版本。如果项目需要兼容这些环境,就得自己在编译期做条件编译,兜底到strtol或者手写实现,代码复杂度反而上去了。
4.3 三种方案横向对比表
| 维度 | 手写strtoi | strtol+errno | std::from_chars |
|---|---|---|---|
| 前导空白 | 可自定义(默认接受) | 接受 | 不接受,需预处理 |
正号+ | 接受 | 接受 | 不接受,需预处理 |
| 尾部脏数据 | 可检测 | 需查endptr | 需比对ptr |
| 溢出处理 | 显式返回 false | 依赖errno,需清零 | 返回errc枚举 |
| 平台一致性 | 完全一致 | long宽度随平台变 | 完全一致 |
| 失败时是否改动输出 | 不改动 | 改动 | 改动 |
| 标准版本要求 | C++98 即可 | C++98 | C++17 |
| locale 依赖 | 无 | 有(空白分类) | 无 |
| 性能(相对) | 快 | 中等 | 最快 |
| 代码体积 | 约 40 行 | 约 15 行 | 约 12 行 |
这张表里的“性能”一项是我在 x86-64 上用百万次随机合法输入测出的相对关系,from_chars和手写版本处在同一量级,strtol因为要处理 locale 和errno略慢一些。差异大概在 2 到 3 倍的量级,对绝大多数应用来说完全可以忽略,除非你在做高频协议解析。所以选型的真正依据不是性能,而是契约匹配度:如果你的项目已经是 C++17 以上、输入格式干净(没有前导空白和正号),from_chars是首选;如果你需要宽松的前置处理、或者要给老代码兜底,手写版本的确定性更强。
5. 踩坑实录与排查速查表
写这类函数,代码写完只是第一步,真正花时间的是后面调 bug 和写测试。下面这几条都是我在实际项目里踩过或者见别人踩过的。
5.1 字符分类函数的有符号陷阱
前面提过isspace和isdigit的有符号问题,这里展开说一下,因为它的表现形式很有迷惑性。假设你的输入是 UTF-8 编码的中文,第一个字节是0xE4,赋给char之后在 x86 平台上是 -28。这时候isdigit(*p)就是把 -28 传进去,标准说参数必须是unsigned char能表示的值或者EOF,负数属于未定义行为。
在 glibc 的某些实现里,这会变成一个越界数组访问,恰好读到内存里某个非零的 flag,于是isdigit返回真,你的解析函数就把这个中文字节当成数字开始算,最后得出一个莫名其妙的整数。而在另一个平台上可能直接崩溃。这类问题之所以难查,是因为它只在输入含非 ASCII 字符时触发,纯英文的测试用例永远测不出来。规避方式就一条:要么用static_cast<unsigned char>强转,要么像我们上面那样用字符区间比较,彻底不用分类函数。
5.2 调用方常见的四种误用
函数写得再稳,调用方用错一样出事。我在代码评审里见过最多的四种误用,列出来供你对照自查。
第一种是把strtoi的返回值当成解析出的数字用。比如int n = strtoi(s, v);,这里n拿到的是 0 或 1,而不是数字本身,看着编译通过但语义完全错了。这个错误在从atoi迁移过来的时候特别容易发生,因为atoi的返回值就是数字。迁移的时候一定要把调用点全部过一遍,不能靠搜索替换。
第二种是忽略了失败分支后继续用out。如果out是局部变量且没有初始化,失败之后它就是个随机值,后面拿去做数组下标或者循环次数,轻则逻辑错误重则越界。所以我在契约里强调失败时不修改out,同时建议调用方始终给out一个合理的初始值,双重保险。
第三种是把int &传给一个临时对象。比如strtoi(s, get_default())这种写法如果get_default返回的是值而不是引用,编译直接报错,这个还算好的。更隐蔽的是传了一个从别处借来的引用,解析成功后原对象被悄悄改了,而调用方以为只是“校验一下格式”。要校验不修改,得单独提供一个bool is_valid_int(const char *)这样的只读接口。
第四种是在循环里对同一个out反复解析且不清零。如果某次解析失败,out保留的是上一次成功的结果,而你忘了检查返回值,就会以为这次也解析成功了。这种 bug 在批量解析日志的代码里很常见,表现是“偶发地多出一条记录”。解决办法是在循环体开头重新初始化,或者干脆每次用一个新变量。
5.3 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 合法输入被判失败 | 尾部有多余空格或换行 | 检查数据源是否带\r\n,考虑先 trim |
| 负数解析成正数 | 累加用了int而不是unsigned | 检查溢出前后的符号 |
INT_MIN解析失败 | limit没有按符号分岔 | 负数上限应为INT_MAX + 1 |
INT_MIN解析成错误值 | 直接-(int)acc走了实现定义转换 | 单独分支赋INT_MIN |
| 含中文的输入偶尔成功 | isdigit传入了负值char | 检查是否用了字符分类函数 |
| 大量前导零被判溢出 | 按位数判断而不是按值判断 | 改成acc > (limit - digit) / 10 |
| 链接时报重复定义 | 与平台自带同名符号冲突 | 加命名空间或static限定 |
| 多线程下偶发结果错乱 | 混用了带全局状态的解析方案 | 检查是否依赖errno或 locale |
6. 落地场景与几个实用的扩展方向
这个函数本身很小,但它在项目里的位置很关键,通常会扩散出几个变体。下面说说我实际用到的几个方向。
6.1 配置文件与命令行参数解析
配置解析是最典型的使用场景。我一般的做法是写一个薄薄的一层包装,把“取值 + 校验 + 报错”三件事串起来:
bool get_int_field(const KeyValueMap &cfg, const char *key, int &out) { auto it = cfg.find(key); if (it == cfg.end()) { return false; // 字段缺失,由调用方决定是否用默认值 } if (!numparse::strtoi(it->second.c_str(), out)) { log_error("配置项 %s 的值 '%s' 不是合法整数", key, it->second.c_str()); return false; } return true; }这层包装的价值在于把错误信息集中在一处。strtoi本身不打印任何东西,保持纯粹;日志、错误码、默认值策略这些“业务决策”放在包装层。这样单元测试的时候可以直接测strtoi,不用去 mock 日志。命令行参数解析同理,只是数据源从 map 换成了argc/argv,而且还要多处理一层“参数可能带--port=8080前缀”的切分逻辑。
6.2 从 32 位扩到 64 位与模板化
项目里一旦出现这个函数,很快就有人要long long版本。最直接的写法是复制一份改类型,但这会带来两份几乎一样的代码,改一处忘另一处。我的做法是先把核心逻辑抽成一个模板,把类型参数化:
template <typename T> [[nodiscard]] bool parse_int(const char *s, T &out) { static_assert(std::is_integral<T>::value, "只支持整型"); static_assert(std::is_signed<T>::value, "只支持有符号类型"); if (s == nullptr) return false; const char *p = s; while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\v' || *p == '\f' || *p == '\r') ++p; bool neg = false; if (*p == '+' || *p == '-') { neg = (*p == '-'); ++p; } if (*p < '0' || *p > '9') return false; using U = typename std::make_unsigned<T>::type; const U limit = neg ? static_cast<U>(std::numeric_limits<T>::max()) + static_cast<U>(1) : static_cast<U>(std::numeric_limits<T>::max()); U acc = 0; while (*p >= '0' && *p <= '9') { const U digit = static_cast<U>(*p - '0'); if (acc > (limit - digit) / 10) return false; acc = static_cast<U>(acc * 10 + digit); ++p; } if (*p != '\0') return false; if (!neg) { out = static_cast<T>(acc); } else if (acc == static_cast<U>(std::numeric_limits<T>::max()) + 1) { out = std::numeric_limits<T>::min(); } else { out = static_cast<T>(-static_cast<T>(acc)); } return true; }模板化之后,int和long long共用一份逻辑,边界值全部由numeric_limits推导,不会出现“改了int版本忘了改long long版本”的情况。static_assert把误用(比如传unsigned进来)挡在编译期,比运行时报错好得多。代价是编译期开销和错误信息可读性,不过就这个复杂度来说完全可控。
6.3 单元测试用例清单
最后说说测试。这类边界密集的函数,测试用例的价值远高于代码本身。我通常按四组来组织:形态用例覆盖空白、符号、前导零、空串;边界用例覆盖INT_MAX、INT_MIN及其上下各一位;脏数据用例覆盖尾部垃圾、中间空格、十六进制、小数、中文;契约用例验证失败时out确实没被改动。
契约用例最容易被忽略,写法是把out初始化为一个哨兵值(比如0x5A5A5A5A),调用失败后再断言它还是那个值。这条测试能挡住后面某次重构不小心把写out的操作挪到失败分支之前的情况。另外建议对nullptr单独测一条,有些实现会把空指针检查和空串检查混在一起写,重构的时候容易漏。
我个人在项目里的体会是,这类解析函数真正的成本不在写,而在“想清楚契约”和“把边界钉死”这两步。第一版通常二十分钟就写完了,但把上面那张边界表跑通,往往要花掉一两个小时,尤其INT_MIN那一行,我见过不止一个人在那里改了三遍。但这一两个小时是值得的,因为在它之上构建的所有配置读取、协议解析、参数处理,都会因为这一个函数变稳而跟着变稳。后来我干脆把这个函数和配套的测试一起放进项目的基础工具目录,谁要用直接 include,省得每个模块各写一版,各踩一遍坑。