大多数情况下,整数溢出是意外产生的。整数溢出造成意外行为的案例数不胜数:《超级马里奥》中最大重生次数为 127 次的漏洞,亦或是波音 787 软件中的漏洞。为了避免这些问题,Rust 编译器会分析代码,以识别可能出现的非预期整数溢出,同时也为程序员提供了一些不同的方法,以明确地允许溢出,这就是我们将在本文中探讨的内容。
默认行为
考虑以下代码,这可能会造成整数溢出:
fn main() {
let x: u8 = 255 + 1;
println!("{}", x);
}
我很确定,以上运算的结果是 0
。验证一下:
$ cargo run
error: this arithmetic operation will overflow
--> src/main.rs:2:17
|
2 | let x: u8 = 255 + 1;
| ^^^^^^^ attempt to compute `u8::MAX + 1_u8`, which would overflow
|
= note: `#[deny(arithmetic_overflow)]` on by default
error: could not compile `overflow-example` due to previous error
不用担心,可以看出 rustc
不愿意编译这种有歧义的代码。
以上案例只是 Rust
编译器一系列静态检查的冰山一角。仔细看一下错误信息,可以发现,该错误是因为默认开启了
#[deny(arithmetic_overflow)]
。这暗示了我们可以通过
allow
宏,来关闭该检查。让我们试一下:
fn main() {
#[allow(arithmetic_overflow)]
let x: u8 = 255 + 1;
println!("{}", x);
}
以上代码如何执行,还取决于我们编译时的配置。如果你不知道什么是配置(profile),配置其实就是一系列简单的编译选项(比如优化级别)。对于可执行文件,Cargo
默认定义了两个编译配置:dev
和
release
。前者就是在 cargo build
时使用的配置,后者需要添加 --release
。先试试
release
配置:
$ cargo run --release
Compiling overflow-example v0.1.0 (/tmp/overflow-example)
Finished release [optimized] target(s) in 0.50s
Running `target/release/overflow-example`
0
这正是我们最初期待的行为。再试试 dev
配置:
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/overflow-example`
thread 'main' panicked at 'attempt to add with overflow', src/main.rs:3:17
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
再一次,Rust
对我们的行为不太满意。这一次,错误发生在运行时。该差异的来源在于,dev
和 release
配置的定义不同(参见:Profiles
- The Cargo
Book)。有很多不同是可以预见的(比如优化级别、调试断言等),但是造成这一行为的选项是
overflow-checks
,它在 dev
中开启,release
中关闭——这解释了上方的运行时错误。
如果你的应用程序依赖于整数溢出行为,你可能会想直接更改
dev
配置,以关闭 overflow-checks
:
[profile.dev]
overflow-checks = false
我不建议这样做。我建议在 Rust 代码中,显式指定如何处理整数溢出。
显式运算函数
对于所有的有符号和无符号整数,Rust
都提供了四组不同的运算函数,这提供了显式处理整数溢出的方式。第一组是
wrapping_
系列函数:
250_u8).wrapping_add(10); // 4
(120_i8).wrapping_add(10); // -126
(300_u16).wrapping_mul(800); // 43392
(-100_i8).wrapping_sub(100); // 56
(8000_i32).wrapping_pow(5000); // 640000 (
这些例子清楚地表明了,wrapping_
系列函数处理整数溢出的方法是回绕,即从整数类型的最大值回绕到最小值(也是我们期望发生的默认情况)。这种方法确保了在使用这些函数时,无论构建配置文件如何,都不会造成意外的运行时错误。虽然语法冗长,但这也是一种优点,因为它让代码的读者知道,这些值有可能溢出,且处理溢出的方式是回绕。
略有不同的 overflowing_
变体:
// 4, true
let (result, overflowed) = (250_u8).overflowing_add(10);
println!(
"sum is {} where overflow {} occur",
,
resultif overflowed { "did" } else { "did not" },
; )
这些函数等同于 wrapping_
变体,除了返回值会多一个
bool
以指明是否有溢出产生。例如,这在实现模拟器时可能特别有用,因为许多 CPU
有一个标志,且必须在指令导致溢出时设置。
也许我们不想回绕值,而是将溢出作为一种特殊情况处理。可以通过
checked_
变体达到这一效果:
match (100_u8).checked_add(200) {
Some(result) => println!("{result}"),
None => panic!("overflowed!"),
}
另一种选择是在溢出时饱合,而非回绕(即到达最大值或最小时,保持该值):
-32768_i16).saturating_sub(10); // -32768
(200_u8).saturating_add(100); // 255 (
额外开销?
我们很自然地会担心,每当想执行基本的运算时,多余的函数调用会减慢代码的执行速度。幸运的是,没有什么好担心的,因为
Rust 足够聪明,可以优化掉多余的函数调用。我们可以通过使用 cargo-show-asm
来查看某个函数编译后的汇编指令。
先看看普通的加法,和编译后的汇编:
pub fn addition(x: u8, y: u8) -> u8 {
+ y
x }
$ cargo asm overflow_example::addition --simplify
Finished release [optimized] target(s) in 0.00s
overflow_example::addition:
lea eax, [rsi + rdi]
ret
编译的结果是单条 lea
指令。再来看看
wrapping_add
:
pub fn addition(x: u8, y: u8) -> u8 {
.wrapping_add(y)
x}
$ cargo asm overflow_example::addition --simplify
Finished release [optimized] target(s) in 0.00s
overflow_example::addition:
lea eax, [rsi + rdi]
ret
正如我们期望的,编译的结果相同——wrapping_add
的调用已经被优化。
包装类型
如果在特定场景中,有许多地方都有可能整数溢出,那么上述方法就会显得有些冗长,也许很难操作。很多时候还容易忘记处理整数溢出。幸运的是,Rust
提供了 Wrapping<T>
包装类型,这种类型允许使用正常的算术操作符,同时确保在整数溢出时自动回绕。让我们来试试:
use std::num::Wrapping;
let mut x = Wrapping(125_u8);
+ Wrapping(200); // 69
x - Wrapping(200); // 181
x // 如果我们同时更改变量 x, 那么可以直接使用基本数据类型, 不用再套一层
// x 现在为 113
*= 5;
x
// 错误! 请注意 - 我们只可以在有赋值操作时使用基本数据类型
// (如在使用 += -= 等操作符时)
/ 5; x
这比在每个运算时都使用 wrapping_
函数显得更清晰。
也有一个类似的 Saturating<T>
,它和
Wrapping<T>
类似,但在溢出时饱合而非回绕。~尽管在本文写成时,此类型尚在实验中,可能在未来的某一天合并进入稳定版本。当前,saturating_
系列函数仍然是不错的选择!~
Saturating<T>
已于 2023 年 11 月发布的 Rust 1.74.0
中稳定:#115477。