size_t 与 int 混用陷阱:有符号无符号比较与整数提升

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,自然为真。

官方文档:Implicit conversions --- usual arithmetic conversions

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_* 要解决的歧义)

官方文档:Usual arithmetic conversions (isocpp / cppreference)

最容易记错的是最后两行: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 位平台)。如果你拿这个值当下标或循环条件,轻则越界、重则死循环。

官方文档:std::vector<T,Allocator>::size --- 返回 size_type(无符号)

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,符合直觉,不会下溢。

官方文档:std::ssize (C++20) | std::cmp_less (C++20)

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. 延伸阅读

9. 一句话总结

-1 > 0u 为 true 的根因是「有符号数在比较时被转成无符号,补码被原样解读成超大正数」;日常写循环别用 int i 去比 size(),优先用范围 for / 迭代器,需要索引时要么显式 std::size_t、要么上 C++20 的 std::ssize 和 std::cmp_*,把「符号不一致」从根上消灭。

相关推荐
.道阻且长.1 小时前
C++ 11:可变参数模板
前端·c++·算法
m0_380743871 小时前
Qt实现调用相机进行拍照并进行图像处理
开发语言·c++
励志不掉头发的内向程序员3 小时前
【从零写一个CAD 03】三个 double 值得单独一个类吗:把视图变换抽成 View
开发语言·c++·qt·学习·系统架构
蒸蒸yyyyzwd10 小时前
秋招学习笔记 day46
c++·八股
码匠许师傅13 小时前
【C++三方组件】cpp-httplib:一个头文件起 HTTP 服务
开发语言·c++·http
无名猿14 小时前
new/delete 与 malloc/free:为什么绝对不能混用
c++·内存管理·现代c++·踩坑记录
2601_9622186116 小时前
C++中decltype关键字的实现
开发语言·c++
繁华的地方不一定留下你的脚印16 小时前
C++ std::variant 与 std::visit:安全保存多种类型,写清每个处理分支
开发语言·c++
程序员老陆17 小时前
WIN32_LEAN_AND_MEAN:Windows 头文件里的“精简编译开关”
c++·windows