Thoughts On Integers (2023)

Thoughts On Integers (2023)

关于整数的思考 (2023)

In this post I’ll lay out my views on integer types, and how I’ve come to those views. Hopefully you’ll disagree initially and be convinced by the end! 在这篇文章中,我将阐述我对整数类型的看法,以及我是如何形成这些观点的。希望你起初会持反对意见,但在读完后被我说服!

The status quo

现状

Let’s begin with what I’d characterize as the status quo in popular programming languages today. But first, some caveats: I won’t mention scripting languages throughout this article for obvious reasons. Moreover, I’m leaving Java out of this section because it has some weirdness surrounding integers (int vs Integer and no distinct unsigned types). 让我们从当今流行编程语言的现状开始谈起。首先说明一点:出于显而易见的原因,本文不会涉及脚本语言。此外,我将把 Java 排除在本节之外,因为它在整数处理上存在一些怪异之处(如 intInteger 的区别,且没有明确的无符号类型)。

We’ll start with a classic systems programming language, C. In addition to the traditional C integer types int, char, short, long, long long, etc, C99 added fixed size types with names like int8_t, uint64_t, and so on. On modern 64-bit platforms int is always signed and 32 bits wide. Additionally, C has a confusing array of machine-dependent types such as ptrdiff_t and size_t. In general, subscripts and sizes are of type size_t, but C’s implicit conversions mean you’ll often see int or something else used instead. When signed integers overflow in C, they invoke undefined behavior, meaning the compiler assumes the overflow never occurs. Overflow for unsigned integers, on the other hand, is defined to wrap. 我们先从经典的系统编程语言 C 开始。除了传统的 C 整数类型 intcharshortlonglong long 等,C99 标准增加了诸如 int8_tuint64_t 等固定大小的类型。在现代 64 位平台上,int 始终是有符号的且宽度为 32 位。此外,C 语言还有一系列令人困惑的、依赖于机器的类型,如 ptrdiff_tsize_t。通常情况下,下标和大小使用 size_t 类型,但由于 C 语言的隐式转换,你经常会看到代码中使用 int 或其他类型来代替。在 C 语言中,有符号整数溢出会导致未定义行为(UB),这意味着编译器会假设溢出永远不会发生。而对于无符号整数,溢出则被定义为回绕(wrap)。

Next up, C#, which I’d characterize as a typical “application programming” language that’s used when low level control isn’t necessary. C#’s types are named similarly to C’s: int, short, long and byte. int is 32 bits wide. Unsigned versions are obtained by prefixing the type with u. Subscripts and sizes use a plain int, and by default integers wrap on overflow. 接下来是 C#,我将其归类为典型的“应用编程”语言,适用于不需要底层控制的场景。C# 的类型命名与 C 类似:intshortlongbyteint 的宽度为 32 位。无符号版本通过在类型前加 u 前缀获得。下标和大小使用普通的 int,默认情况下整数溢出会回绕。

Go is a contemporary alternative to languages in the same domain as C#. Here we see the beginning of a trend: rather than using unintuitive C-style integer type names, Go just appends the bitwidth of each type: int8, uint64, etc. Another interesting difference crops up in the size of int and uint: they match the size of an address on the target platform, rather than being hardcoded to a particular width. Again, sizes and subscripts use int. Go defines integer overflow to wrap. Go 是与 C# 同一领域的一种现代替代语言。在这里,我们看到了一个趋势的开端:Go 没有使用不直观的 C 风格整数类型名称,而是直接在每个类型后附加了位宽:int8uint64 等。另一个有趣的差异在于 intuint 的大小:它们匹配目标平台上的地址大小,而不是硬编码为特定的宽度。同样,大小和下标使用 int。Go 将整数溢出定义为回绕。

Swift is yet another recent language in this “compiled statically-typed non-low-level commercial software” niche, and its integer types are similar to Go in several ways. Type names are identical to Go except for capitalization: Int8, UInt64, etc. Int and UInt have the same sizing behavior as Go, and subscripts & sizes both use Int. Curiously, Swift defaults to panicking on integer overflow, even in release builds. Swift 是这一“编译型、静态类型、非底层商业软件”领域中另一种新兴语言,其整数类型在多个方面与 Go 相似。类型名称除了首字母大写外与 Go 完全相同:Int8UInt64 等。IntUInt 的大小行为与 Go 一致,下标和大小也都使用 Int。奇怪的是,Swift 默认在整数溢出时触发恐慌(panic),即使在发布版本中也是如此。

Rust: a better approach

Rust:一种更好的方法

Did you notice a commonality in all the examples I listed above? All those languages have some kind of default int type. They require extra typing to use unsigned types instead of signed ones and explicitly-sized types instead of the default-sized int. I’d say this encourages the use of int over other types. After all: would you rather stare at a screenful of ints or uint32_ts? This goes beyond aesthetics, too. It’s so easy to take the lazy way out and type int without thinking, rather than scrutinize the use-case to identify the best type for the job. 你注意到我上面列出的所有例子中的共同点了吗?所有这些语言都有某种默认的 int 类型。它们需要额外的输入才能使用无符号类型代替有符号类型,以及使用显式大小的类型代替默认大小的 int。我认为这鼓励了人们优先使用 int 而非其他类型。毕竟,你更愿意盯着满屏的 int 还是 uint32_t?这不仅仅是审美问题。人们很容易偷懒,不假思索地输入 int,而不是仔细审视用例以确定最适合该工作的类型。

Rust eliminates each of these points: all integer types have an explicit size, and all types are prefixed with either i or u for signed and unsigned respectively. There’s no bias towards a certain size or signedness. You’re forced to consider the circumstances and make a judgement on what type is most appropriate to the situation. Maybe memory is conserved as integers are narrowed, and bugs are prevented as – to invoke a dogma oft-cited by the Rust community – invalid values (negative numbers) become unrepresentable. Rust 消除了所有这些问题:所有整数类型都有显式的大小,并且所有类型都分别以 iu 为前缀,分别表示有符号和无符号。它对特定大小或符号位没有偏见。你被迫考虑具体情况,并判断哪种类型最适合当前场景。也许内存会因为整数范围的缩小而得到节省,并且正如 Rust 社区常引用的教条所言,通过使无效值(负数)变得不可表示,从而预防了错误。

namesigned?size in bits
u8no8
u16no16
u32no32
u64no64
usizenoaddress-sized
i8yes8
i16yes16
i32yes32
i64yes64
isizeyesaddress-sized

One thing you might notice about this table is the presence of usize and isize. usize is used for subscripts and sizes, just like size_t in C. Contrary to C, Rust doesn’t have implicit integer casting, so you really do have to use usize for your array indexes (unless you want to drown in as-casts). Using usize rather than a 32-bit int means arrays can have more than two billion elements, and using an unsigned type means negative array indexes can never occur. 你可能会注意到表中出现了 usizeisizeusize 用于下标和大小,就像 C 语言中的 size_t 一样。与 C 不同,Rust 没有隐式整数转换,所以你确实必须在数组索引中使用 usize(除非你想淹没在 as 转换中)。使用 usize 而不是 32 位的 int 意味着数组可以拥有超过 20 亿个元素,而使用无符号类型则意味着永远不会出现负数数组索引。

Rust panics on overflow in debug builds to catch bugs, but uses wrapping in release builds for performance. Note the lack of UB on overflow. Rust 在调试版本中通过溢出触发恐慌来捕获错误,但在发布版本中为了性能使用回绕。请注意,溢出时不会产生未定义行为(UB)。

There is a caveat to what I said about Rust not being biased to a particular integer type: 关于我所说的“Rust 对特定整数类型没有偏见”,有一个例外:

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

Rust defaults to i32 when it has no type information for an integer literal, so there is an (albeit minor) bias to signed 32-bit integers. In my experience of Rust, though, I found I used signed integers so rarely that I’d almost advocate for making unsigned integers the default. If Rust didn’t put unsigned and signed integers on equal footing I’d likely have never reached this conclusion. 当 Rust 没有整数文字的类型信息时,它默认使用 i32,因此(尽管很小)它对有符号 32 位整数确实存在偏见。然而,根据我在 Rust 中的经验,我发现我很少使用有符号整数,以至于我几乎想提倡将无符号整数作为默认值。如果 Rust 没有将无符号和有符号整数置于同等地位,我可能永远不会得出这个结论。

To me, all of these changes seem like obvious improvements. 对我来说,所有这些改变似乎都是显而易见的改进。

A Rust programmer tries other languages

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

When I started experimenting with C#, Swift and Go I quickly became frustrated. With my attitude of picking the right integer type for the task, I attempted to use uint for array indexes everywhere instead of the signed int these languages default to. It was impractical due to the amount of casting required, so I gave up in frustration. This just made me more certain: why can’t every language have integer types like Rust? 当我开始尝试 C#、Swift 和 Go 时,我很快感到沮丧。秉持着为任务选择正确整数类型的态度,我尝试在所有地方使用 uint 作为数组索引,而不是这些语言默认的有符号 int。由于需要大量的类型转换,这变得不切实际,所以我沮丧地放弃了。这反而让我更加确信:为什么不是每种语言都能拥有像 Rust 那样的整数类型呢?

Why does C make signed integer overflow UB, anyway? 话说回来,为什么 C 语言要将有符号整数溢出定义为未定义行为(UB)呢?

Originally the reason was differences in hardware behavior, but today the argument you’ll hear most often is performance. But don’t most CPUs perform wrapping when an arithmetic operation overflows, anyway? Why not just define overflow to wrap (at no extra cost, since it comes with the CPU “for free”) and be done with it? 最初的原因是硬件行为的差异,但今天你最常听到的论点是性能。然而,大多数 CPU 在算术运算溢出时难道不是都会执行回绕吗?为什么不直接将溢出定义为回绕(无需额外成本,因为这是 CPU “免费”提供的功能),然后一了百了呢?

I watched a talk by Chandler Carruth on the topic of UB, in which he includes an enlightening example. (We’ll assume we’re running on an architecture with 64-bit addresses.) 我观看了 Chandler Carruth 关于 UB 主题的演讲,其中他包含了一个富有启发性的例子。(我们假设运行在 64 位地址架构上。)

bool mainGtU(int32_t i1, int32_t i2, uint8_t *block) {
    uint8_t c1, c2;
    c1 = block[i1];
    c2 = bloc...