Rust 整数溢出
Rust 的每种整数类型都有固定的大小。u8 只能存放 0 到 255 之间的数字,别的都放不下。那么,255 再加 1 会怎样?答案放不进去了。这就叫整数溢出(integer overflow)。
想象一下老式汽车上只有三位数的里程表:跑满 999 公里后,它会变回 000。对玩具来说这无所谓,可要是银行余额也这样,那就糟透了。Rust 在这件事上从两方面帮你:开发时,它会大声地把问题揪出来;同时,它还提供了简单的方法,让你明确说出这时应该怎么处理。别担心,这样的方法只有四类,而且用法都是一个套路。这一课建立在 Rust 数据类型和 Rust 运算符的基础上。
每种整数类型有多大?
Section titled “每种整数类型有多大?”每种类型都有最小值(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 到 255i8: -128 到 127u16: 0 到 65535i32: -2147483648 到 2147483647u32: 0 到 4294967295i64: -9223372036854775808 到 9223372036854775807u64: 0 到 18446744073709551615u128: 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 对它们一视同仁。
数字溢出时 Rust 会怎么做
Section titled “数字溢出时 Rust 会怎么做”debug 构建:直接 panic
Section titled “debug 构建:直接 panic”用 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 overflownote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace这条英文信息的意思是:“第 2 行第 5 列尝试做加法时溢出了”。这里特意让数值经过函数参数传进去。如果把数字直接写在 + 两边,编译器在程序运行之前就能发现问题(马上就会看到)。程序崩溃看起来有点狠,其实是一种善意:你在自己的电脑上就发现了 bug,还拿到了准确的行号,而不是等到上线后才得到一个错误的分数。关于 panic 的更多内容,请看 Rust panic 一课。
release 构建:绕回
Section titled “release 构建:绕回”用 cargo run --release 构建时,为了让代码快一点,这些检查会被关掉。数字会像里程表一样绕回(wrap around):在 u8 上,250 + 10 变成了 4(因为 260 - 256 = 4)。
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直接写数字:连编译都通不过
Section titled “直接写数字:连编译都通不过”如果编译器能看到完整的计算过程,它会拒绝编译:
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 的范围)。
自己选择溢出时的行为
Section titled “自己选择溢出时的行为”与其祈祷溢出永远不会发生,不如自己决定溢出时该怎么办。每种整数类型都为此准备了四类方法。它们在 u8、i32、u64 等所有整数类型上用法都一样。
checked_:“放不下就告诉我”
Section titled “checked_:“放不下就告诉我””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_:“到边界就停下”
Section titled “saturating_:“到边界就停下””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) 把它限制在你自己的上限。
wrapping_:“故意绕回”
Section titled “wrapping_:“故意绕回””有时候,绕回正是你想要的。比如一个号码机,号码依次是 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_:“两样都给我”
Section titled “overflowing_:“两样都给我””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,是否溢出:true60,是否溢出:false这一类用得比其他几类少。如果拿不准该选哪个,checked_ 是个很好的默认选择。
用 as 转换时的溢出
Section titled “用 as 转换时的溢出”用 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 = 4294967295300 放不进 u8:out of range integral type conversion attempted放得下:200300 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 转换两课会讲得更深入。
其他关于数字的意外
Section titled “其他关于数字的意外”还有几个常让人栽跟头的地方,这里一并列出:
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 = -3i32::MIN.checked_abs() = Nonei32::MIN.unsigned_abs() = 214748364810u32.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该用哪种方法?
Section titled “该用哪种方法?”| 方法类别 | 返回值 | 适用场景 |
|---|---|---|
+ - * |
数字本身(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, _> |
转换一个可能放不进更小类型的值 |
空列表的长度减 1
Section titled “空列表的长度减 1”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 overflownote: 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() 更简单。
用小类型累加小数字
Section titled “用小类型累加小数字”三次考试的分数 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。
1. 手机电量
Section titled “1. 手机电量”手机电量是一个从 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) 再加上你自己的上限。
2. 知道自己极限的阶乘
Section titled “2. 知道自己极限的阶乘”写一个 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! = 12020! = 243290200817664000021! 太大了,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
- 数据类型 —《Rust 程序设计语言》:官方教程中“整型溢出”一节讲解了 debug 下的 panic、release 下的绕回,以及四类方法。
u8— 标准库文档(英文):所有checked_、saturating_、wrapping_和overflowing_方法,每个都附有小例子。i32— 标准库文档(英文):有符号整数的同一套方法,还有checked_abs、unsigned_abs和checked_pow。- Overflow — The Rust Reference(英文):哪些运算会溢出、溢出后会发生什么的确切规则。
- 类型转换 — 通过例子学 Rust:可运行的
as转换示例,包括绕回、截断和饱和的情况。