size_t 与 int 混用陷阱:有符号无符号比较与整数提升
size_t和int混在一起比较大小是 C 里最容易被忽略、又最容易出线上 bug 的坑。先看一个反直觉的事实-1 0u在 C 里的结果是true。它不是语言 bug而是「有符号数与无符号数比较时有符号数会被转成无符号数」这条规则的直接后果。本文用真实编译输出把这条规则讲透并整理成可查的规则表最后给出几种规避写法及其取舍。1. 引子-1 竟然大于 0直接跑一段最小的代码不靠记忆看编译器怎么说#includeiostreamintmain(){std::coutstd::boolalpha;std::cout(-10u)\n;// 有符号 -1 比较无符号 0intx-1;unsignedinty0;std::cout(xy)\n;// 同样的规则std::coutstatic_castunsigned(-1) static_castunsigned(-1)\n;// 看 -1 被当成多大的正数}true true static_castunsigned(-1) 4294967295两个true已经够反直觉第三行更关键-1一旦被当作unsigned int它的二进制补码two’s complement被原样解释成一个接近 40 亿的正数4294967295。所以-1 0u比较的其实是两个无符号数4294967295 0自然为真。官方文档Implicit conversions — usual arithmetic conversions2. 核心机制比较前发生了什么C 的比较运算符、、、、以及大部分二元算术运算符在两侧操作数类型不一致时会先做「寻常算术转换usual arithmetic conversions」。其中一条硬规则是如果一边是unsigned、另一边是对应宽度的signed且有符号类型所有值都能被无符号类型表示对于同宽度的int/unsigned无符号能表示的范围更大那么 signed 一方会被转换成 unsigned 一方。于是-1补码0xFFFFFFFF被重新解读为无符号数值变成4294967295。整个比较在无符号世界里进行结果和直觉完全相反。int -1 的 32 位补码 11111111 11111111 11111111 11111111 │ ▼ 被当作 unsigned int 原样解读 unsigned 4294967295 11111111 11111111 11111111 11111111 比较变成了 4294967295u 0u → true注意这不是「把 -1 变成 1」那种取整而是位模式原样搬运、按无符号语义解释。所以任何负数在和有符号→无符号转换时都会变成极大的正数。3. 整数提升与寻常算术转换规则表要系统避免这类坑得先分清两件事整数提升integral promotion和寻常算术转换usual arithmetic conversions。整数提升比int窄的整数类型bool、char、short、位域在参与运算前会先提升为int若int放得下该类型的所有值或unsigned int。这一步在「单操作数」场景也会发生比如char a; ~a。寻常算术转换当二元运算符两侧类型不同时在整数提升之后再按「同符号比宽度、无符号优先」的规则统一成一种类型。两侧类型提升后转换结果同类型不变同符号不同宽度窄的 → 宽的int与unsigned intint→unsigned int本文的坑所在long与unsigned intlong更宽unsigned int→long两者都能表示对方全部值时保留有符号long long与unsigned long longlong long→unsigned long long有符号与无符号同宽、且无法互全覆盖一律转成无符号这是 C20std::cmp_*要解决的歧义官方文档Usual arithmetic conversions (isocpp / cppreference)最容易记错的是最后两行C 的规则是「只要无符号类型能表示有符号类型的所有值就转无符号」如果不能比如unsigned int和更宽的long则反过来转有符号。4. 经典翻车现场size()与int istd::vector::size()返回的是std::size_t无符号而我们用int当循环下标是肌肉记忆。这就触发了第 2 节的规则。#includeiostream#includevectorintmain(){std::vectorintv{5,6,7};for(inti0;iv.size();i){// 注意int 与 size_t 比较std::coutv[i] ;}std::cout\n;}5 6 7这段代码能跑、结果也对所以隐患极深。编译时加-Wall -Wextra会报warning: comparison of integer expressions of type int and std::vectorint::size_type {aka long unsigned int} [-Wsign-compare] for (int i 0; i v.size(); i) { ~~^~~~~~~~~~~更危险的不是这个循环本身而是「反向循环」和「空容器减一」#includeiostream#includevectorintmain(){std::vectorintv{};// 空容器conststd::size_t nv.size();std::coutsize() n\n;std::coutsize()-1 (n-1)\n;// 无符号下溢}size() 0 size()-1 184467440737095516150u - 1在无符号算术里不会变成-1而是绕回最大的size_t184467440737095516152^64 - 164 位平台。如果你拿这个值当下标或循环条件轻则越界、重则死循环。官方文档std::vectorT,Allocator::size — 返回 size_type无符号5. 怎么规避四种写法对比既然坑在「混用有符号/无符号」思路就是「统一类型」或「用工具做安全比较」。写法示例优点缺点 / 注意显式用std::size_tfor (std::size_t i 0; i v.size(); i)C17 即可零开销反向循环i 0永远是 true仍会出事迭代器for (auto it v.begin(); it ! v.end(); it)完全不碰下标类型最安全语法略长C11 起可用范围 for 更短范围 forfor (int x : v)最简洁根本不出现下标拿不到索引std::ssizeC20for (int i 0; i std::ssize(v); i)返回有符号ptrdiff_tsize()-1不会下溢需要 C20std::cmp_lessC20std::cmp_less(a, b)按「数学意义」比大小自动处理符号需要 C20调用稍长最推荐的现代写法是范围 for和迭代器——它们让下标类型从根上消失。需要索引且用 C17 时就老老实实写std::size_t。下面给出 C20 的两个安全工具的真实输出// verify: stdc20#includecompare#includeiostream#includeutility#includevectorintmain(){std::coutstd::boolalpha;std::coutcmp_less(-1, 0u) std::cmp_less(-1,0u)\n;std::coutcmp_less(0u, -1) std::cmp_less(0u,-1)\n;std::cout(-1 0u) 原始 (-10u)\n;}cmp_less(-1, 0u) true cmp_less(0u, -1) false (-1 0u) 原始 falsestd::cmp_less按数学上的整数大小比较不受无符号转换影响-1 0当然是true。对照第三行原生却因类型转换给出false——这正是为什么在泛型 / 边界代码里要优先用std::cmp_*。再来看std::ssize如何消除下溢// verify: stdc20#includeiostream#includeiterator#includevectorintmain(){std::vectorintv{5,6,7};std::coutssize std::ssize(v)\n;std::coutssize-1 (std::ssize(v)-1)\n;std::vectorintempty{};std::coutempty ssize-1 (std::ssize(empty)-1)\n;// 仍是 -1安全}ssize 3 ssize-1 2 empty ssize-1 -1std::ssize返回有符号的std::ptrdiff_t所以0 - 1 -1符合直觉不会下溢。官方文档std::ssize (C20) std::cmp_less (C20)6. Core Guidelines 怎么看这件事C Core Guidelines 给出了直接对应的规则ES.100Don’t mix signed and unsigned arithmetic.不要混用有符号和无符号算术。ES.101Use unsigned types for bit manipulation only; don’t use them for arithmetic.无符号类型只用于位操作别拿来做普通算术。ES.102Use signed types for arithmetic.做算术用有符号类型。P.5 / Per 系列别耍聪明用最直接、最不会出错的写法。换句话说指南的倾向是「默认用有符号类型做算术」无符号留给位操作和「必须非负且不会下溢」的场合如size_t这种代表大小的语义。当我们不可避免要和size()返回的无符号值打交道时要么用std::ssize把它变回有符号要么直接用迭代器/范围 for 绕开类型问题。7. 完整示例C17可直接编译下面这段把「经典坑 三种 C17 安全写法」放在一个程序里覆盖前面所有要点。注意循环里我没有用std::endl而是用\n不需要每次刷缓冲见 SL.io.50。// demo.cpp — 编译: g -stdc17 -Wall -Wextra demo.cpp -o demo#includeiostream#includevectorintmain(){conststd::vectorintdata{10,20,30};// 写法 Aint 与 size() 比较 —— 触发 -Wsign-compare反例不要这么写// for (int i 0; i data.size(); i) { std::cout data[i] ; }// 写法 B显式 std::size_t语义正确for(std::size_t i0;idata.size();i){std::coutdata[i] ;}std::cout\n;// 写法 C迭代器根本不碰下标类型for(autoitdata.cbegin();it!data.cend();it){std::cout*it ;}std::cout\n;// 写法 D范围 for最简洁for(constintx:data){std::coutx ;}std::cout\n;// 空容器的 size()-1 下溢提醒无符号算术会绕回最大值conststd::vectorintempty{};std::coutempty.size()-1 (empty.size()-1)\n;}10 20 30 10 20 30 10 20 30 empty.size()-1 184467440737095516158. 延伸阅读cppreference — Usual arithmetic conversions转换规则的权威出处写泛型代码前值得翻一遍。cppreference — std::ssize (C20)把无符号大小变成有符号消除下溢。cppreference — std::cmp_less 等 (C20)按数学意义做安全比较。C Core Guidelines — ES.100/101/102混用有/无符号的官方告诫。9. 一句话总结-1 0u为true的根因是「有符号数在比较时被转成无符号补码被原样解读成超大正数」日常写循环别用int i去比size()优先用范围 for / 迭代器需要索引时要么显式std::size_t、要么上 C20 的std::ssize和std::cmp_*把「符号不一致」从根上消灭。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →