C 语言

关注公众号 jb51net

关闭
首页 > 软件编程 > C 语言 > C++整数类型

C++中整数类型(Integer Types)的避雷指南与正确使用姿势详解

作者:PAK向日葵

在实际开发中,如果对C++内建整数类型的机制不熟悉,或者不遵循一定的使用规范,则非常容易引入难以排查和调试的Bug,下面小编就和大家详细讲解一下C++中整数类型的正确使用姿势吧

背景

C++继承自C语言。作为一门以零开销抽象为主要特征的底层语言,不同于Python或JavaScript等高抽象层次的语言,C++拥有一套较为完整、但又包含有一定历史包袱的内建整数类型。

在实际开发中,如果对C++内建整数类型的机制不熟悉,或者不遵循一定的使用规范,则非常容易引入难以排查和调试的Bug。因此学习了解C++中内建整数类型的特性,以及一套行之有效的使用规范,是非常有必要的。

内建整数类型的坑 or 历史包袱

C++ 标准没有规定具体位数

虽然在实际实践中,我们知道在x64平台,对绝大多数编译器来说:

但坑爹的地方在于,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时,ab中的值会被隐式提升成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>的固定宽度类型

既然shortlong longunsigned long long等类型的位宽是不确定的,那干脆我们就不要去用了。

取而代之,我们使用固定宽度类型,比如int16_tuint32_tint64_t

注意,这些类型直接使用即可,不必加std::前缀!

int类型的正确使用姿势

1.在 C++ 内置整数类型中,唯一推荐经常使用的是 int。比如在数据范围适用的前提下,在以下场景:

2.如果一个值可能 ≥ 2³¹(约 21 亿),就应使用 64 位类型(int64_t)。

特别的,如果某个值/变量本身不大,但在中间计算过程中可能溢出,也应当使用int64_t

3.如果程序明确需要特定大小的整数类型,应使用int16_tint32_tint64_t精确宽度类型

比如,TCP协议规范中端口字段明确为32bit,那么你就应该明确地用int32_t而不是int

强烈抵制无符号(unsigned)类型

原则

既然混用signed和unsigned容易翻车(比如刚才提到的隐式类型提升),Google 的做法非常简单粗暴——绝大多数业务代码里直接禁用 unsigned,全部用有符号整型。这直接消灭了混用的可能性。

特别强调,不要为了“保证非负”而用 unsigned。

错误示范:

unsigned int age; // ❌ 只是想让 age >= 0

正确做法:

int age;
// 如果你想保证它不能为负数,在代码里写断言。
assert(age >= 0);

豁免

只有当你明确在以下场景时,才能使用无符号类型:

例子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_t and ptrdiff_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

容器大小要谨慎

针对表示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。

以上就是C++中整数类型(Integer Types)的避雷指南与正确使用姿势详解的详细内容,更多关于C++整数类型的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文