跳转到内容

Rust 整数溢出

Rust 的每种整数类型都有固定的大小。u8 只能存放 0 到 255 之间的数字,别的都放不下。那么,255 再加 1 会怎样?答案放不进去了。这就叫整数溢出(integer overflow)。

想象一下老式汽车上只有三位数的里程表:跑满 999 公里后,它会变回 000。对玩具来说这无所谓,可要是银行余额也这样,那就糟透了。Rust 在这件事上从两方面帮你:开发时,它会大声地把问题揪出来;同时,它还提供了简单的方法,让你明确说出这时应该怎么处理。别担心,这样的方法只有四类,而且用法都是一个套路。这一课建立在 Rust 数据类型和 Rust 运算符的基础上。

每种类型都有最小值(MIN)和最大值(MAX)。可以用 u8::MAX、i32::MIN 这样的常量把它们打印出来:

fn main() {
println!("u8: {} 到 {}", u8::MIN, u8::MAX);
println!("i8: {} 到 {}", i8::MIN, i8::MAX);
println!("u16: {} 到 {}", u16::MIN, u16::MAX);
println!("i32: {} 到 {}", i32::MIN, i32::MAX);
println!("u32: {} 到 {}", u32::MIN, u32::MAX);
println!("i64: {} 到 {}", i64::MIN, i64::MAX);
println!("u64: {} 到 {}", u64::MIN, u64::MAX);
println!("u128: {} 到 {}", u128::MIN, u128::MAX);
}

输出:

u8: 0 到 255
i8: -128 到 127
u16: 0 到 65535
i32: -2147483648 到 2147483647
u32: 0 到 4294967295
i64: -9223372036854775808 到 9223372036854775807
u64: 0 到 18446744073709551615
u128: 0 到 340282366920938463463374607431768211455
▶ 动手试一试

下面这张表列出了整个家族:

类型 位数 最小值 最大值
i8 / u8 8 -128 / 0 127 / 255
i16 / u16 16 -32,768 / 0 32,767 / 65,535
i32 / u32 32 约 -21 亿 / 0 约 21 亿 / 43 亿
i64 / u64 64 约 -9.2 × 10^18 / 0 约 9.2 × 10^18 / 1.8 × 10^19
i128 / u128 128 约 -1.7 × 10^38 / 0 约 1.7 × 10^38 / 3.4 × 10^38
isize / usize 大多数电脑上是 64 同 i64 / 0 同 i64 / u64

有符号类型(i)把一半的范围用在了负数上。无符号类型(u)从 0 开始,所以它的最大值大约是有符号类型的两倍。超出这些范围就是溢出,低于 MIN 也算(比如在 u8 上算 0 - 1)。后一种情况有时叫“下溢”(underflow),但 Rust 对它们一视同仁。

用 cargo run 运行程序(debug 构建)时,Rust 会检查每一次 +、- 和 *。如果结果放不下,程序就会停下来,并给出清楚的提示。下面这个游戏把奖励分加到一个 u8 类型的分数上:

fn add_points(score: u8, bonus: u8) -> u8 {
score + bonus
}
fn main() {
let total = add_points(250, 10);
println!("总分:{total}");
}

运行后会看到:

thread 'main' (12345) panicked at src/main.rs:2:5:
attempt to add with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
▶ 动手试一试

这条英文信息的意思是:“第 2 行第 5 列尝试做加法时溢出了”。这里特意让数值经过函数参数传进去。如果把数字直接写在 + 两边,编译器在程序运行之前就能发现问题(马上就会看到)。程序崩溃看起来有点狠,其实是一种善意:你在自己的电脑上就发现了 bug,还拿到了准确的行号,而不是等到上线后才得到一个错误的分数。关于 panic 的更多内容,请看 Rust panic 一课。

用 cargo run --release 构建时,为了让代码快一点,这些检查会被关掉。数字会像里程表一样绕回(wrap around):在 u8 上,250 + 10 变成了 4(因为 260 - 256 = 4)。

Terminal window
cargo run --release
总分:4

没有 panic,没有警告,只有一个悄悄算错的数字。所以不要依赖“碰巧”发生的溢出。如果你确实想要绕回,就用 wrapping_add(下面会讲)明确地说出来。本站的“动手试一试”按钮运行的是 debug 构建,所以在那里要看到这种行为,也得用 wrapping_add:

fn main() {
let score: u8 = 250;
println!("{}", score.wrapping_add(10));
}

输出:

4
▶ 动手试一试

如果希望 release 构建也 panic,可以在 Cargo.toml 里把检查重新打开:

[profile.release]
overflow-checks = true

如果编译器能看到完整的计算过程,它会拒绝编译:

fn main() {
let level: u8 = 255 + 1;
println!("{level}");
}
error: this arithmetic operation will overflow
--> src/main.rs:2:21
|
2 | let level: u8 = 255 + 1;
| ^^^^^^^ attempt to compute `u8::MAX + 1_u8`, which would overflow
|
= note: `#[deny(arithmetic_overflow)]` on by default

编译器告诉你:这个算术运算会溢出,u8::MAX + 1_u8 放不进 u8。如果字面量本身就太大,比如 let level: u8 = 256;,也是一样,会得到 literal out of range for u8(字面量超出了 u8 的范围)。

与其祈祷溢出永远不会发生,不如自己决定溢出时该怎么办。每种整数类型都为此准备了四类方法。它们在 u8、i32、u64 等所有整数类型上用法都一样。

checked_add、checked_sub 和 checked_mul 返回一个 Option:放得下就是 Some(结果),放不下就是 None。只要算错数字会造成真正的麻烦(比如涉及钱),这就是最稳妥的选择。银行余额不能低于 0,所以用 u64 加上 checked_sub,它会替你拒绝这笔取款:

fn withdraw(balance: u64, amount: u64) -> Option<u64> {
balance.checked_sub(amount)
}
fn order_total(price: u32, quantity: u32, shipping: u32) -> Option<u32> {
price.checked_mul(quantity)?.checked_add(shipping)
}
fn main() {
let balance: u64 = 100;
for amount in [30, 250] {
match withdraw(balance, amount) {
Some(left) => println!("取出 {amount},余额还剩 {left}"),
None => println!("无法取出 {amount}:余额不足"),
}
}
println!("{:?}", order_total(20, 3, 5));
println!("{:?}", order_total(2_000_000_000, 3, 5));
}

输出:

取出 30,余额还剩 70
无法取出 250:余额不足
Some(65)
None
▶ 动手试一试

因为结果是 Option,所以 ? 运算符也能用。在 order_total 里,只要乘法溢出,函数就会立刻返回 None。

saturating_add 和 saturating_sub 永远不会越过 MIN 或 MAX,到了边界就停住,就像一杯倒满的水。这非常适合那些天然有下限或上限的东西:血条、音量旋钮、库存数量。

fn main() {
let health: u8 = 20;
let after_hit = health.saturating_sub(35);
println!("受到重击后的血量:{after_hit}");
let volume: u8 = 90;
let louder = volume.saturating_add(25).min(100);
println!("调大后的音量:{louder}");
let stock: u32 = 3;
let left = stock.saturating_sub(5);
println!("剩余库存:{left}");
}

输出:

受到重击后的血量:0
调大后的音量:100
剩余库存:0
▶ 动手试一试

音量那一行多了一步。saturating_add 只会停在类型本身的上限,对 u8 来说是 255。而你的旋钮最多到 100,所以要用 .min(100) 把它限制在你自己的上限。

有时候,绕回正是你想要的。比如一个号码机,号码依次是 254、255、0、1。又比如一个简单的哈希函数,数字本来就该被打乱。wrapping_add 和 wrapping_mul 在任何构建模式下(debug 或 release)都会这样做:

fn simple_hash(text: &str) -> u32 {
let mut hash: u32 = 5381;
for byte in text.bytes() {
hash = hash.wrapping_mul(33).wrapping_add(byte as u32);
}
hash
}
fn main() {
let mut ticket: u8 = 254;
for _ in 0..4 {
println!("号码 {ticket}");
ticket = ticket.wrapping_add(1);
}
println!("\"hello\" 的哈希值:{}", simple_hash("hello"));
println!("\"a much longer sentence\" 的哈希值:{}", simple_hash("a much longer sentence"));
}

输出:

号码 254
号码 255
号码 0
号码 1
"hello" 的哈希值:261238937
"a much longer sentence" 的哈希值:3373490575
▶ 动手试一试

哈希的输入保留了英文,因为哈希值是按字节算出来的,换成中文结果就不一样了。如果用普通的 * 和 +,那个长句子会让 u32 溢出很多次。用 wrapping_ 方法就没问题:这正是哈希想要的效果。

overflowing_add 返回一个元组:绕回后的结果,以及一个表示是否溢出的 bool。当你既想要绕回后的值,又想知道是否发生了溢出时,它就很方便:

fn main() {
let (value, overflowed) = 250u8.overflowing_add(10);
println!("{value},是否溢出:{overflowed}");
let (value, overflowed) = 50u8.overflowing_add(10);
println!("{value},是否溢出:{overflowed}");
}

输出:

4,是否溢出:true
60,是否溢出:false
▶ 动手试一试

这一类用得比其他几类少。如果拿不准该选哪个,checked_ 是个很好的默认选择。

用 as 在类型之间转换永远不会 panic,即使在 debug 构建里也不会。如果值放不下,它会被悄悄截短到能放下为止。很多人都被这一点吓到过,所以用的时候要小心:

fn main() {
let big: i32 = 300;
let negative: i32 = -1;
println!("300 as u8 = {}", big as u8);
println!("-1 as u32 = {}", negative as u32);
match u8::try_from(big) {
Ok(small) => println!("放得下:{small}"),
Err(e) => println!("300 放不进 u8:{e}"),
}
match u8::try_from(200) {
Ok(small) => println!("放得下:{small}"),
Err(e) => println!("200 放不进 u8:{e}"),
}
}

输出:

300 as u8 = 44
-1 as u32 = 4294967295
300 放不进 u8:out of range integral type conversion attempted
放得下:200
▶ 动手试一试

300 as u8 只保留最低的 8 位,也就是 44。-1 as u32 变成了最大的 u32。两者都不会提醒你。u8::try_from 会先检查,再给你一个 Result(错误信息 out of range integral type conversion attempted 是标准库自带的,意思是“尝试了超出范围的整数类型转换”),由你决定怎么处理。一个好习惯是:只有确定值放得下时才用 as;值来自程序外部时,用 try_from。(浮点数的行为不同:300.7 as u8 会停在 255,而不是绕回。)Rust 类型转换和 From 与 Into 转换两课会讲得更深入。

还有几个常让人栽跟头的地方,这里一并列出:

fn main() {
println!("7 / 2 = {}", 7 / 2);
println!("-7 / 2 = {}", -7 / 2);
println!("i32::MIN.checked_abs() = {:?}", i32::MIN.checked_abs());
println!("i32::MIN.unsigned_abs() = {}", i32::MIN.unsigned_abs());
println!("10u32.checked_pow(9) = {:?}", 10u32.checked_pow(9));
println!("10u32.checked_pow(10) = {:?}", 10u32.checked_pow(10));
}

输出:

7 / 2 = 3
-7 / 2 = -3
i32::MIN.checked_abs() = None
i32::MIN.unsigned_abs() = 2147483648
10u32.checked_pow(9) = Some(1000000000)
10u32.checked_pow(10) = None
▶ 动手试一试
  • 整数除法会丢掉余数。它不会四舍五入,而是向零截断,所以 -7 / 2 是 -3。要余数就用 %,要小数就用浮点数。
  • abs() 也会溢出。i32 最小能到 -2147483648,最大却只到 2147483647,所以 i32::MIN 的正数版本放不下。在 debug 构建里,i32::MIN.abs() 会 panic,提示 attempt to negate with overflow。改用 checked_abs(),或者用 unsigned_abs(),它返回的 u32 放得下这个值。
  • pow 增长得很快。10 的 10 次方对 u32 来说就已经太大了。除了 checked_pow,还有 saturating_pow 和 wrapping_pow。

如果某个值应该始终绕回或者始终饱和,可以把它包进 std::num::Wrapping 或 std::num::Saturating。这样普通的 + 和 - 就会自动做对,你也不会忘记:

use std::num::{Saturating, Wrapping};
fn main() {
let mut ticket = Wrapping(254u8);
ticket += 1;
ticket += 1;
ticket += 1;
println!("号码:{ticket}");
let mut battery = Saturating(10u8);
battery -= 25;
println!("电量:{battery}");
}

输出:

号码:1
电量:0
▶ 动手试一试
方法类别 返回值 适用场景
+ - * 数字本身(debug 下 panic,release 下绕回) 你确定它不会溢出
checked_add 等 Option<T> 算错数字就是 bug:金额、大小、ID
saturating_add 等 数字,停在 MIN 或 MAX 天然有下限或上限:血量、音量、库存
wrapping_add 等 数字,绕回后的值 绕回本身就是目的:哈希、循环计数器
overflowing_add 等 (T, bool) 既要绕回后的值,又想知道是否绕回了
T::try_from(x) Result<T, _> 转换一个可能放不进更小类型的值

len() 返回 usize,它不能是负数。对空列表来说,len() - 1 会是 -1,于是就溢出了:

fn last_index(tasks: &[String]) -> usize {
tasks.len() - 1
}
fn main() {
let tasks: Vec<String> = Vec::new();
println!("最后一个任务的索引是 {}", last_index(&tasks));
}

运行后会看到:

thread 'main' (12345) panicked at src/main.rs:2:5:
attempt to subtract with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
▶ 动手试一试

几乎每个人都至少踩过一次这个坑(attempt to subtract with overflow 的意思是“做减法时溢出”)。解决办法是让类型告诉你列表可能为空:返回 tasks.len().checked_sub(1),它的类型是 Option<usize>。如果你只是想拿到最后一个元素,tasks.last() 更简单。

三次考试的分数 90、85 和 100,每个都放得进 u8,可它们的总和 275 放不下。所以 let total: u8 = scores.iter().sum(); 会 panic,提示 attempt to add with overflow。panic 指向的是标准库内部,可能让人摸不着头脑,但罪魁祸首其实是你的 u8。在相加之前,先把每个值转换成更大的类型:

fn main() {
let scores: Vec<u8> = vec![90, 85, 100];
let total: u32 = scores.iter().map(|&s| s as u32).sum();
println!("总分:{total},平均分:{}", total / 3);
}

输出:

总分:275,平均分:91
▶ 动手试一试

用 as 把 u8 转换成 u32 永远是安全的,因为每个 u8 都放得进 u32。

手机电量是一个从 0 到 100 的 u8。写出 charge(level: u8, amount: u8) -> u8 和 drain(level: u8, amount: u8) -> u8,保证电量永远不超过 100、不低于 0。从 80 开始,先充 30,再耗掉 130,每一步之后都打印电量。提示:充电用 saturating_add 加 .min(100),耗电用 saturating_sub。

查看答案
fn charge(level: u8, amount: u8) -> u8 {
level.saturating_add(amount).min(100)
}
fn drain(level: u8, amount: u8) -> u8 {
level.saturating_sub(amount)
}
fn main() {
let mut level: u8 = 80;
println!("初始电量:{level}%");
level = charge(level, 30);
println!("充电后:{level}%");
level = drain(level, 130);
println!("用了一整天后:{level}%");
}

输出:

初始电量:80%
充电后:100%
用了一整天后:0%
▶ 动手试一试

saturating_sub 自己就会停在 0。充电时,类型的上限是 255,所以要用 .min(100) 再加上你自己的上限。

写一个 fn factorial(n: u64) -> Option<u64>,计算 1 × 2 × … × n,如果结果大到 u64 放不下,就返回 None。分别打印 5、20 和 21 的结果。提示:从 let mut result: u64 = 1; 开始,在 for 循环里使用 result.checked_mul(i)?。

查看答案
fn factorial(n: u64) -> Option<u64> {
let mut result: u64 = 1;
for i in 1..=n {
result = result.checked_mul(i)?;
}
Some(result)
}
fn main() {
for n in [5, 20, 21] {
match factorial(n) {
Some(value) => println!("{n}! = {value}"),
None => println!("{n}! 太大了,u64 放不下"),
}
}
}

输出:

5! = 120
20! = 2432902008176640000
21! 太大了,u64 放不下
▶ 动手试一试

只要有一次乘法放不下,? 就会让整个函数立刻返回 None。20! 是 u64 能装下的最大阶乘。

  • 每种整数类型都有固定的范围,从 T::MIN 到 T::MAX。超出这个范围就是溢出。
  • debug 构建会 panic,提示 attempt to add with overflow;release 构建会悄悄绕回;编译器能看出来的溢出直接是编译错误。
  • 自己选择行为:checked_ 返回 Option,saturating_ 停在边界,wrapping_ 绕回,overflowing_ 同时给出值和一个 bool。
  • as 从不检查,可能悄悄改变数字。值可能放不下时,用 T::try_from。
  • 小心这些坑:空列表上的 len() - 1、用小类型求和、MIN 的 abs(),还有 pow。

小测验

1. 在 debug 构建中,如果数值来自变量,250u8 + 10 会怎样?

2. 100u64.checked_sub(250) 返回什么?

3. 血条永远不能低于 0。哪个方法最合适?

4. 300_i32 as u8 的结果是什么?

得分: 0 / 4