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