Back

在 Rust 中处理整数溢出

大多数情况下,整数溢出是意外产生的。并且容易造成非预期行为。本文旨在介绍 Rust 中常用的,避免整数溢出,或显式利用其行为的方式。

原文:Handling Integer Overflow in Rust - bmoxb.io

大多数情况下,整数溢出是意外产生的。整数溢出造成意外行为的案例数不胜数:《超级马里奥》中最大重生次数为 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 默认定义了两个编译配置:devrelease。前者就是在 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 对我们的行为不太满意。这一次,错误发生在运行时。该差异的来源在于,devrelease 配置的定义不同(参见: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",
    result,
    if 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 {
    x + y
}
$ 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 {
    x.wrapping_add(y)
}
$ 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);

x + Wrapping(200); // 69
x - Wrapping(200); // 181
// 如果我们同时更改变量 x, 那么可以直接使用基本数据类型, 不用再套一层
// x 现在为 113
x *= 5;

// 错误! 请注意 - 我们只可以在有赋值操作时使用基本数据类型
// (如在使用 += -= 等操作符时)
x / 5;

这比在每个运算时都使用 wrapping_ 函数显得更清晰。

也有一个类似的 Saturating<T>,它和 Wrapping<T> 类似,但在溢出时饱合而非回绕。~尽管在本文写成时,此类型尚在实验中,可能在未来的某一天合并进入稳定版本。当前,saturating_ 系列函数仍然是不错的选择!~

Saturating<T> 已于 2023 年 11 月发布的 Rust 1.74.0 中稳定:#115477

comments powered by Disqus