在这篇文章中,我将阐述我对整数类型的看法,以及我是如何形成这些看法的。 希望你一开始会不同意这些观点,但最终会被说服!

现状

让我们从我认为当今流行编程语言中的现状开始。 但首先,有一些注意事项: 出于显而易见的原因,我在本文中不会提及脚本语言。 此外,我将 Java 排除在本节之外,因为它在整数方面有一些奇怪之处(intInteger 的区别,以及没有独立的无符号类型)。

我们从经典的系统编程语言 C 开始。 除了传统的 C 整数类型 intcharshortlonglong long 等之外,C99 还添加了固定大小的类型,名称如 int8_tuint64_t 等。 在现代 64 位平台上,int 始终是有符号的且宽度为 32 位。 此外,C 还有一系列令人困惑的机器相关类型,如 ptrdiff_tsize_t。 通常,索引和大小使用 size_t 类型,但 C 的隐式转换意味着你经常会看到使用 int 或其他类型。 当 C 中的有符号整数溢出时,会引发未定义行为,这意味着编译器假设溢出不会发生。 而无符号整数的溢出则被定义为回绕。

接下来是 C#,我认为它是一种典型的“应用程序编程”语言,在不需要底层控制时使用。 C# 的类型命名与 C 类似:intshortlongbyteint 的宽度为 32 位。 无符号版本通过在类型前添加 u 前缀获得。 索引和大小使用普通的 int,默认情况下整数溢出时会回绕。

Go 是与 C# 相同领域的一种当代替代语言。 在这里我们可以看到一种趋势的开始: Go 没有使用不直观的 C 风格整数类型名,而是直接在每个类型后附加位宽:int8uint64 等。 另一个有趣的区别出现在 intuint 的大小上: 它们与目标平台上的地址大小匹配,而不是硬编码为特定宽度。 同样,索引和大小使用 int。 Go 将整数溢出定义为回绕。

Swift 是这个“编译型静态类型非底层商业软件”领域中的另一种近期语言,其整数类型在多个方面与 Go 类似。 类型名称与 Go 相同,只是大小写不同:Int8UInt64 等。 IntUInt 的大小行为与 Go 相同,索引和大小都使用 Int。 有趣的是,Swift 默认在整数溢出时触发 panic,即使在发布版本中也是如此。

Rust:更好的方法

你是否注意到上面列出的所有例子都有一个共同点? 所有这些语言都有某种默认的 int 类型。 它们需要额外的输入才能使用无符号类型而不是有符号类型,以及明确大小的类型而不是默认大小的 int。 我认为这鼓励了 int 相对于其他类型的使用。 毕竟:你更愿意盯着满屏的 int 还是 uint32_t? 这不仅仅是美观问题。 输入 int 而不思考是如此容易的懒惰方式,而不是仔细审视用例以确定最适合该工作的类型。

Rust 消除了所有这些问题: 所有整数类型都有明确的大小,所有类型都以 iu 为前缀,分别表示有符号和无符号。 没有对特定大小或符号的偏向。 你被迫考虑具体情况并判断哪种类型最适合该场景。 也许通过缩小整数可以节省内存,并且通过——引用 Rust 社区经常引用的教条——使无效值(负数)不可表示来防止错误。

namesigned?size in bits
u8no8
u16no16
u32no32u64no64usizenoaddress-sizedi8yes8i16yes16i32yes32i64yes64isizeyesaddress-sized

关于这个表格,你可能会注意到 usizeisize 的存在。 usize 用于索引和大小,就像 C 中的 size_t 一样。 与 C 不同,Rust 没有隐式整数转换,所以你确实必须使用 usize 作为数组索引(除非你想淹没在 as 转换中)。 使用 usize 而不是 32 位 int 意味着数组可以有超过二十亿个元素,而使用无符号类型意味着永远不会出现负数组索引。

Rust 在调试版本中对溢出触发 panic 以捕获错误,但在发布版本中使用回绕以提高性能。 请注意,溢出时没有未定义行为。

关于 Rust 不偏向特定整数类型,我需要说明一点:

fn demo() {
	let x = 1; // x: i32
}

当 Rust 对整数字面量没有类型信息时,默认使用 i32,因此存在(尽管很小)对有符号 32 位整数的偏向。 不过,根据我使用 Rust 的经验,我发现我很少使用有符号整数,以至于我几乎主张将无符号整数设为默认值。 如果 Rust 没有将无符号和有符号整数置于平等地位,我可能永远不会得出这个结论。

对我来说,所有这些变化似乎都是显而易见的改进。

一个 Rust 程序员尝试其他语言

当我开始尝试 C#、Swift 和 Go 时,我很快变得沮丧。 以我选择适合任务的正确整数类型的态度,我尝试在所有地方使用 uint 作为数组索引,而不是这些语言默认使用的有符号 int。 由于需要大量的转换,这是不切实际的,所以我沮丧地放弃了。 这让我更加确信: 为什么不能每种语言都有像 Rust 这样的整数类型?

为什么 C 使有符号整数溢出成为未定义行为?

最初的原因是硬件行为的差异,但今天你最常听到的论点是性能。 但难道不是大多数 CPU 在算术运算溢出时都会执行回绕吗? 为什么不直接将溢出定义为回绕(没有额外成本,因为 CPU “免费”提供),然后就完事了呢?

我观看了 Chandler Carruth 关于未定义行为的演讲,其中包含了一个启发性的例子。 (我们假设我们运行在地址为 64 位的架构上。1

bool mainGtU(int32_t i1, int32_t i2, uint8_t *block)
{
	uint8_t c1, c2;

	c1 = block[i1]; c2 = block[i2];
	if (c1 != c2) return c1 > c2;
	i1++; i2++;

	c1 = block[i1]; c2 = block[i2];
	if (c1 != c2) return c1 > c2;
	i1++; i2++;

	// 重复几次
}

编译器足够智能,可以消除 i1i2 的修改。 字节直接从 block + i1block + i2 加载,然后是 block + i1 + 1block + i2 + 1,然后是 block + i1 + 2block + i2 + 2,以此类推。 这些地址计算是作为加载指令本身的一部分完成的,所以它们发生在 64 位整数的空间内。

假设我们强制编译器在溢出时回绕。 当 i1++;i2++; 达到 32 位整数的限制时,它们会回绕。 为了确保运行时行为忠实于这种回绕,编译器会生成额外的指令,使 block + i1 + ni1 + n 溢出 32 位时回绕到 block + 0。 换句话说,“CPU 免费提供溢出回绕”在这种情况下不成立,我们因此得到了臃肿的代码。

这不是假设的;使 i1i2 为无符号也会导致这些额外指令被生成。 在他的演讲中,Chandler 使用这段代码作为示例,说明从无符号整数切换到有符号整数可以提高性能,因为它允许编译器利用溢出时的未定义行为。 他甚至说

如果你有一个整数,你想将其视为算术而不是在某个模空间(某个二的幂)中处理, 就让它成为有符号的。 如果需要更多位,就获取更多位。 保持有符号。

这对我来说很有趣。 你不应该使用类型系统来防止错误吗? 如果一个数字永远不可能为负,为什么要让它成为有符号的? Chandler 对这个问题的看法让我感到不安。

进一步探索 C

当我沉浸在使用 C 进行系统编程时,我遇到了几个人,他们一直说即使索引不可能为负,也应该使用有符号整数。 特别是,我阅读了 Chris Wellons 的博客上的不同文章,都支持这一观点:

近年来,我被说服无符号大小是一个严重的错误,可能甚至是早期计算的重大错误之一,并且大小和索引应该是 有符号的。 不仅如此,pkg-config 没有理由处理巨大的对象! 我们谈论的是短字符串和小文件。 如果它最终得到一个大对象,那么某个地方就存在缺陷——要么在它本身,要么在系统中——它应该中止。 因此,大小和索引自然是 int

看到引文中的那个链接吗? 它链接到一篇由 Bjarne Stroustrop 撰写的简短文档,他也说大小和索引应该是 有符号的。 带着怀疑的态度和我基于 Rust 的先入之见,我没有发现他的论点很有说服力。 不过,脑海中有一个挥之不去的感觉:所有这些人一定都有道理。

看看一些 C 替代语言

Odin 是新兴的 C 替代语言之一,专注于简单性和“编程的乐趣”。 在对另一个 C 替代语言 Zig 进行了一些失败的尝试后,我决定更仔细地看看 Odin。

再次出现了熟悉的“现代语言”整数系统,但有一些面向底层编程的调整:

  • intuint 是地址大小的
  • i8i128 是有符号的
  • u8u128 是无符号的
  • 明确大小的类型可以后缀 lebe 以使其内存表示为小端或大端
  • 索引和大小使用 int
  • 溢出时回绕

这一次,我决定保持开放的心态,更仔细地看看这种设置可能有什么好处。 下面的一些论点不是我的,但我记不清哪些是我从哪里学到的,哪些是我自己的。

地址大小是完美的默认大小

正如我们之前看到的,如果我们不愿意让溢出成为未定义行为,混合使用指针和 32 位整数可能会导致性能问题。 如果我们让 i1i2 的类型为 ptrdiff_t 或类似类型,那么溢出时的回绕就可以正常工作。 此外,A64 等 ISA 只有 32 位和 64 位算术指令,所以对 8 位和 16 位整数进行操作需要额外的 and 指令。 在地址大小的整数上执行所有算术通常是最好的选择。

对此的一个反驳是,现代 CPU 通常受内存限制,所以尽可能打包数据结构是个好主意。 使整数为地址大小而不是默认 32 位,与这个目标背道而驰。 无论如何,我认为能够为数组索引、大小、算术和作为默认整数类型使用普通 int 的简单性, outweigh 了需要记住在哪里使用 int32 的成本。

在发布版本中,溢出时回绕是一个不错的权衡

如果所有操作的整数都是地址大小的,那么使溢出成为未定义行为没有好处2。 我宁愿在函数顶部进行几次转换,也不愿有更多未定义行为定时炸弹等待爆炸。

考虑到溢出检查的大约 30% 开销,我认为将它们限制在调试版本中是一个合理的权衡。 我也可以看到一个论点:即使在调试版本中也使用溢出回绕,以保持一致性和简单性。

无符号整数是不直观的

假设你想从一个数字循环到零。 使用有符号整数这很简单:

void demo(int32_t top)
{
	for (int32_t i = top; i >= 0; i--) {
		printf("%d\n", i);
	}
}

正如你所期望的,运行 demo(3) 会打印

3
2
1
0

不过这里的 itop 从来不是负数,所以在这种情况下无符号整数肯定更好?

void demo(uint32_t top)
{
	for (uint32_t i = top; i >= 0; i--) {
		printf("%u\n", i);
	}
}

这是一个无限循环。 对于无符号整数 nn >= 0 始终为真。 由于无符号整数在溢出时回绕,当 i 达到零时,我们递减它并达到 4,294,967,295。 要修复这个问题,我们可以将条件改为 i <= top

void demo(uint32_t top)
{
	for (uint32_t i = top; i <= top; i--) {
		printf("%u\n", i);
	}
}

一旦 i 回绕,它将大于 top,因此会跳出循环。 太糟糕了。

有符号整数会使边界检查变慢吗?

在使用有符号索引时执行边界检查需要同时检查 0 <= ii < count。 这两个检查是相互独立的,因此当代 CPU 可以并行运行它们。 CPU 的 ALU 很少会饱和,所以移除其中第一个检查可能不会改变边界检查的执行时间。

无符号整数更容易出错

假设我们有两个数组索引 ij。 还假设对于我们的用例,溢出时触发 panic 的开销是不可接受的,所以我们使用溢出回绕。 我们想找出 ij 之间有多少个元素,并想将它们复制到一个新数组中。 很简单,对吧? j - i 就是确定元素数量所需的全部,然后我们可以创建该大小的新数组并执行复制。

想象一下,在我们的测试数据中 j 总是大于或等于 i,但在生产中 j 却小于 i。 如果 ij 是无符号的,j - i 将回绕到一个可能是非常大的数字。 我们最终会创建那个非常大的大小的数组;如果幸运的话,复制有边界检查并导致 panic。

关键是无符号下溢很难捕获: 没有办法判断一个大数字是由回绕错误导致的,还是它的存在是故意的。 在这里使用有符号整数意味着无效的 j - i 减法会立即产生一个“中毒的”(负数)结果。 这个结果可以在用于创建数组时立即捕获,而不是通过巨大数字的不可预测的后续影响稍后捕获。

有符号整数不需要更多的断言吗?

确实,要捕获那个负数组大小,我们的数组分配代码需要一个 assert(count > 0)。 然而,如果我们使用无符号整数,要捕获它需要的远不止一个 assert——我们需要为整个程序启用全面的溢出检查。

你可以看到在使用有符号整数时需要添加的断言是溢出检查的一种轻量级形式。 我们不必在每一个算术操作之后测试溢出,而可以在每个函数的顶部放置一个等效检查。 换句话说,大量的溢出检查已被合并为 API 边界处的单个检查。

当然,单个 assert(count > 0) 并不等同于彻底检查各处的溢出。 例如,它不会捕获像 create_array(1000 + j - i) 这样的情况。 这里在性能和安全之间存在权衡,我认为有符号整数加上大量使用断言和溢出回绕是一个愉快的中间地带。

有符号整数的最大值更低,所以你需要使用更多位吗?

我不同意这个观点。 鉴于我们在 64 位平台上,将数组中的元素限制在 63 位真的无关紧要,如果有什么的话,可能是一件好事。 例如,Rust 将所有分配的大小限制为有符号地址大小整数的最大值,以便 ptr::offset(isize) 等 API 可以正常工作。

对于较小的位宽,比如如果我们使用 32 位索引到数组以实现紧凑性,丢失一位不会产生太大影响。 我不知道有任何应用程序,其中最大两亿个元素不足够,但四十亿个元素足够。

Luna Razzaghipour
2023 年 9 月 14 日