背景
C++继承自C语言。作为一门以零开销抽象为主要特征的底层语言,不同于Python或JavaScript等高抽象层次的语言,C++拥有一套较为完整、但又包含有一定历史包袱的内建整数类型。
在实际开发中,如果对C++内建整数类型的机制不熟悉,或者不遵循一定的使用规范,则非常容易引入难以排查和调试的Bug。因此学习了解C++中内建整数类型的特性,以及一套行之有效的使用规范,是非常有必要的。
内建整数类型的坑 or 历史包袱
C++ 标准没有规定具体位数
虽然在实际实践中,我们知道在x64平台,对绝大多数编译器来说:
- short => 16 bit
- int => 32 bit
- long => 32 bit(Windows)或64 bit(Linux)
- long long => 64 bit
但坑爹的地方在于,C++ 标准没有规定 int、long 等类型的具体位数🤣。
C++ 标准只规定了最小宽度(比如int的最小宽度是16 bit)和相对大小(比如sizeof(short) <= sizeof(int) <= sizeof(long))。
这意味着如果我们要追求代码的严谨性和在未来的可移植性,就不能在使用时假定这些内建类型的具体位数。
坑爹的 unsigned 类型
人类直觉认为“大小、长度、年龄等不可能为负数”,所以很自然地在这种场景下倾向于用 unsigned。但 C++ 规定,无符号整数的溢出是合法的模运算(Modulo Arithmetic)。
这意味着,无符号数永远不可能为负,当它为 0 时再减 1,不会变成 -1,而是会回绕成该类型的最大值(如 32 位下变成 2³² - 1,即 4294967295)。
一旦掉进这个坑里,会导致以下几种致命 Bug:
- 死循环
// 灾难:如果用 unsigned 表示数组下标,执行倒序遍历
for (unsigned int i = vec.size() - 1; i >= 0; --i) {
// i 为 0 时,--i 变成 4294967295,依然 >= 0。
// 死循环 !!!
}
- 差值计算灾难
usigned int a = 2020;
usigned int b = 2026;
if (a - b < 0) {
std::cout << "a < b" << std::endl;
} else {
std::cout << "a >= b" << std::endl;
}
结果会输出a >= b,因为a-b的结果是一个极大的正数,导致逻辑判断完全相反。
坑爹的隐式类型提升
混合运算引发的类型提升
C++ 为了让不同类型的数字能在一起做数学运算,制定了一套极其复杂的整型提升规则(Integer Promotion Rules) 。最反直觉的一条是:当有符号数和无符号数混合运算时,有符号数会被隐式强制转换为无符号数。
这会引发类似下面的Bug:
int a = -1;
unsigned int b = 1;
if (a < b) {
// 你以为会执行这里?错!
} else {
// 实际会执行这里!
// 因为 a 被偷偷转换成了 unsigned,-1 变成了 4294967295
// 4294967295 < 1 显然是 false。
}
这种错误如果出现在缓冲区检查、长度验证、边界判断等敏感地带,就非常容易被攻击者设计绕过检查,从而引发更严重的安全问题。
算术运算引发的类型提升
例子:
uint8_t a = 254;
mov byte ptr [a],0FEh
uint8_t b = 255;
mov byte ptr [b],0FFh
uint8_t c = a + b;
movzx eax,byte ptr [a] // 隐式提升a为uint32_t
movzx ecx,byte ptr [b] // 隐式提升b为uint32_t
add eax,ecx // 计算(uint32_t)a+(uint32_t)b
mov byte ptr [c],al // 将eax当中的计算结果强行截断成8bit,然后写回c变量
分析汇编代码,可知计算a+b时,a和b中的值会被隐式提升成32bit。
尽管如此,但写回计算结果时仍然会发生截断。
c中的计算结果仍然是错误的。
有符号溢出 = 未定义行为(UB)
不同于刚才聊的无符号类型溢出会引发"回绕"现象,在C++中,有符号整型的溢出被视作一种UB行为🤣。
举个例子:
int x = std::numeric_limit<int>::max();
x += 1; // UB
理论上编译器可能会直接决定将这行UB代码优化掉,或者引发其他异常现象。
一个更极端的例子:
int f(int x) {
if (x + 1 > x)
return 1;
else
return 0;
}
在高优化编译模式(比如release)下,编译器可能会认为:既然 signed 溢出是 UB,那么我直接忽略处理 UB 的情况,即假设 x+1 一定不溢出。因此我直接将f优化成永远return 1😂。
那么此时你调用f(std::numeric_limits<int>::max())就会得到错误的结果。
在实践中,如果编译器优化掉的恰好是重要的安全检查,那么就可能引发更严重的安全漏洞。
坑爹的静默截断
大类型 → 小类型 => 静默截断
比如:
int64_t n = 5000000000;
int x = n;
int limit = 800000000;
if (x < limit) {
std::cout << x << std::endl; // 得到垃圾值705032704
}
在你的编译器没有经过特殊设置的情况下,以上代码会通过编译。并且尽管n远大于limit,if块中的代码仍然被执行了。
在实践中,如果
x被用于表示文件大小、网络长度或用户输入长度,那么攻击者可以通过构造一个超大数字n并依靠静默截断来绕过检查if (x < limit)
标准库的世纪失误
早期 C++ 标准委员会为了让容器(如 std::vector)能容纳尽可能多的元素,利用了无符号数比有符号数正向范围大一倍的特点,将容器的 size() 返回值和 operator[] 的参数硬性规定为 size_t(一个无符号类型)。
坑爹的地方就在这儿,由于size_t是一个无符号类型,因此你一旦调用STL库容器的size(),就必须警惕掉进前述的任何与无符号整型有关的坑。
为了让你加深印象,这里再强调一遍。
- 逆向迭代陷阱(Underflow)
for (size_t i = v.size() - 1; i >= 0; --i) { // 永远不会停止! // 当 i 为 0 时,--i 会变成一个巨大的正数(溢出/绕回) } - 隐式类型转换与比较错误
std::vector<int> v; int x = -1; if (x < v.size()) { // 如果 v 为空(size 为 0),这个条件居然是 FALSE! // 因为 -1 被转换成了 18446744073709551615 (2^64-1) }
C++ 之父 Bjarne Stroustrup 和多位委员会成员后来公开承认:这是一个巨大的错误(A Historical Mistake)。但为了 ABI 兼容,永远无法修改了。
Google C++ Style Guide 规范是怎么说的?
为了避雷前述的C++内建整型类型的各种坑或历史包袱,Google 制定了一系列可实操的工程规范。
下面我对这部分规范进行了梳理和拓展。平时开发中遵循这些规范,就能避免掉一大部分的坑~
推荐使用<stdint.h>或<cstdint>的固定宽度类型
既然short、long long、unsigned long long等类型的位宽是不确定的,那干脆我们就不要去用了。
取而代之,我们使用固定宽度类型,比如int16_t、uint32_t、int64_t。
注意,这些类型直接使用即可,不必加
std::前缀!
int类型的正确使用姿势
-
在 C++ 内置整数类型中,唯一推荐经常使用的是
int。比如在数据范围适用的前提下,在以下场景:- 循环计数器
- 一般的小整数
- 下标
-
如果一个值可能 ≥ 2³¹(约 21 亿),就应使用 64 位类型(
int64_t)。- 特别的,如果某个值/变量本身不大,但在中间计算过程中可能溢出,也应当使用
int64_t。
- 特别的,如果某个值/变量本身不大,但在中间计算过程中可能溢出,也应当使用
-
如果程序明确需要特定大小的整数类型,应使用
int16_t、int32_t、int64_t等精确宽度类型。- 比如,TCP协议规范中端口字段明确为32bit,那么你就应该明确地用
int32_t而不是int。
- 比如,TCP协议规范中端口字段明确为32bit,那么你就应该明确地用
强烈抵制无符号(unsigned)类型
原则
既然混用signed和unsigned容易翻车(比如刚才提到的隐式类型提升),Google 的做法非常简单粗暴——绝大多数业务代码里直接禁用 unsigned,全部用有符号整型。这直接消灭了混用的可能性。
特别强调,不要为了“保证非负”而用 unsigned。
错误示范:
unsigned int age; // ❌ 只是想让 age >= 0
正确做法:
int age;
// 如果你想保证它不能为负数,在代码里写断言。
assert(age >= 0);
豁免
只有当你明确在以下场景时,才能使用无符号类型:
- 需要进行位操作(如移位、按位逻辑操作)
- 对于有符号整数(如 int),进行右移操作(>>)时,到底是“逻辑右移(补0)”还是“算术右移(补符号位)”在 C++20 以前不确定的(通常是算术右移,会补符号位)
- 这会带来跨平台的不确定性。
- 而无符号类型进行位运算(&, |, ^, <<, >>)有着绝对一致的跨平台表现。
- 表示位掩码(bitmask)或位域(bitfields)
- 需要利用无符号类型的溢出回绕特性(比如密码学或哈希算法)
- 用于表示单个二进制字节的值
- 当我们在进行网络编程、文件 IO、序列化时,处理的基础单位是“字节”。一个字节就是 8 个比特,它没有正负之分。
- 如果你用
char(有符号),当读取到大于 127 的字节时,它会被解释为负数,这在作为数组索引或进行宽类型转换时会引发严重的 Bug。
例子1:
LevelDB 使用了自定义的 MurmurHash 变种。你看这里清一色使用的是 uint32_t。
// 来源:google/leveldb
// 这里的 seed, m, r 以及 h 都在进行位操作和故意的溢出计算
uint32_t Hash(const char* data, size_t n, uint32_t seed) {
// 常量 m 充当乘法因子,利用无符号乘法溢出截断的特性
const uint32_t m = 0xc6a4a793;
const uint32_t r = 24;
const char* limit = data + n;
// seed 和 (n * m) 进行异或,n*m 极可能溢出,但 uint32_t 保证了其安全性
uint32_t h = seed ^ (n * m);
// 一段典型的每次处理 4 字节的哈希混合过程
while (data + 4 <= limit) {
uint32_t w = DecodeFixed32(data); // 读取 4 个原始字节
data += 4;
h += w; // 这里的加法依赖模 2^32 运算
h *= m; // 乘法依赖模 2^32 运算
h ^= (h >> 16); // 位移与异或,打乱比特位
}
// ...
return h;
}
例子2:
在 Protobuf 的底层序列化格式中,一个字段的标签(Tag)由“字段编号(Field Number)”和“数据类型(Wire Type)”压缩进同一个整数中。
// 来源:google/protobuf
// 使用无符号整数来进行左移、按位或、按位与等操作
inline uint32_t WireFormatLite::MakeTag(int field_number, WireType type) {
// 将 field_number 左移 3 位,然后与低 3 位的 type 进行按位或 (|)
// uint32_t 保证了移位操作绝对不会受符号位影响
return (static_cast<uint32_t>(field_number) << 3) | static_cast<uint32_t>(type);
}
inline WireFormatLite::WireType WireFormatLite::GetTagWireType(uint32_t tag) {
// 使用按位与 (&) 提取低 3 位的数据
return static_cast<WireType>(tag & 7);
}
例子3:
Base64 编码解码时,针对的是原始字节流。
// 来源:google/abseil (absl)
// 使用 uint8_t 数组来表示原始的字节流序列
static const uint8_t kBase64DecoderRules[256] = {
// ... 大量解析规则状态码 ...
};
bool Base64UnescapeInternal(const char* src_param, size_t szsrc,
char* dest, size_t* szdest) {
// 将输入的字符指针强制转换为无符号的字节流指针
// 因为 Base64 处理过程中,我们只关心这 8 个 bit 是什么,不关心它代表什么字符或正负数
const uint8_t* src = reinterpret_cast<const uint8_t*>(src_param);
const uint8_t* src_end = src + szsrc;
while (src < src_end) {
// 作为数组索引时,如果是 signed char 遇到大于 127 的值会变成负数而越界崩溃
// uint8_t 完美避免了这个问题
uint8_t rule = kBase64DecoderRules[*src++];
// ...
}
}
size_t正确使用姿势
虽然前文中我们细数了size_t作为无符号整型的一系列罪状,但由于其较为明确的语义(比如用于表示内存数据块的字节数、偏移量),Google规范中也没有一棍子打死它,而是允许在适当的情况下使用。
原文:When appropriate, you are welcome to use standard type aliases like
size_tandptrdiff_t.
举例:TensorFlow 代码中 size_t 与 int64_t 并存
来源于 TensorFlow 官方 C++ API 文档示例:
size_t TotalBytes() const // returns memory usage
int64_t dim_size(int d) const // returns shape dimension
TotalBytes()用的是size_t,很自然用于表示内存 尺寸/字节数(不可能为负)。dim_size()返回int64_t,用于表示 tensor 的 逻辑维度大小/形状,因为:- TensorFlow 的维度整数可能参与算术计算
- 需要 signed 类型有助于防止 signed/unsigned 隐式转换问题
容器大小要谨慎
针对表示STL容器大小的size_t存在的缺陷,Google建议:尽量使用迭代器(iterators)和容器(containers),而不是指针(pointers)和大小(sizes)。
// ✅ Good
for (auto it = v.begin(); it != v.end(); ++it);
// ✅ Good
for (auto& x : v);
// ❌ Bad(混用signed和unsigned)
for (int i = 0; i < v.size(); i++);
另外,需要尽量避免无意义的 unsigned 扩散到业务代码。
以下是一个 Good case:
size_t size = container.size(); // 与 STL 兼容
int64_t count = static_cast<int64_t>(size); // 内部转换防止 signed/unsigned 混用
for (int64_t i = 0; i < count; ++i) { ... } // 内部循环
这种方式既兼容了容器接口的 size_t,又避免了 signed/unsigned 混用引发的 bug。