處理 Rust 中的整數溢出
在軟件中,很容易發現很多整數溢出和溢出導致意外行爲的例子—從超級馬里奧兄弟中有趣的 - 127 條生命的小故障,到波音 787 軟件中可怕的 bug。爲了防止這些問題,Rust 編譯器對代碼進行分析,以識別潛在的非預期整數溢出情況,同時還爲程序員提供了幾種不同的方法來顯式地允許溢出—這就是我們將在本文中探討的內容。
默認行爲
考慮一個導致整數溢出的最小 Rust 應用程序:
fn main() {
let x: u8 = 255 + 1;
println!("{}", x);
}
我相信大多數人都希望這個程序的輸出爲 0。讓我們驗證一下:
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 `integer-overflow` due to previous error
這是 Rust 編譯器對它編譯的任何代碼執行的許多靜態檢查中的一個示例。仔細查看錯誤消息,我們被告知由於默認開啓了 #[deny(arithmetic_overflow)],因此啓用了該檢查。這意味着我們可以通過添加 allow 註釋來禁用該檢查。讓我們試試:
fn main() {
#[allow(arithmetic_overflow)]
let x: u8 = 255 + 1;
println!("{}", x);
}
當這個修改後的程序執行時,現在會發生什麼由我們用來編譯它的概要文件決定。對於那些不知道的人來說,概要文件只是編譯 Rust 程序時需要考慮的一組選項 (例如,要使用的優化級別)。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
這次失敗發生在運行時,原因是 dev 和 release 的定義不同。它們在很多方面都有所不同 (例如,優化級別,調試斷言的啓用等),但相關的選項是“overflow-checks” 選項,它在開發中啓用,在發佈中禁用——這解釋了上面的運行時恐慌。
如果你正在開發的應用程序在某種程度上依賴於整數溢出的發生,那麼需要將以下行添加到 Cargo.toml 中確保即使在使用 dev 構建時也禁用溢出檢查:
[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_函數:
let (result, overflowed) = (250_u8).overflowing_add(10); // 4, true
println!("sum is {} where overflow {} occur", result, if overflowed { "did" } else { "did not" });
這些函數等價於 wrapping_函數,除了它們也返回一個布爾值,表示是否發生溢出。這可能在實現模擬器時特別有用,例如,當指令導致溢出時,許多 cpu 都有一個必須設置的標誌。
也許我們不希望包裝值,而是希望將溢出作爲特殊情況處理。這可以使用 checked_函數來完成,如下所示:
match (100_u8).checked_add(200) {
Some(result) => println!("{result}"),
None => panic!("overflowed!"),
}
還有一個選項是不換行,而是 “Saturating”(當達到給定整數類型的最大值或最小值時,只保持該值而不是換行)。
(-32768_i16).saturating_sub(10); // -32768
(200_u8).saturating_add(100); // 255
性能?
人們很自然地會擔心,每當我們想要執行基本算術時,都會啓動一個函數調用,這可能會對代碼的執行速度產生負面影響。幸運的是,沒有什麼可擔心的,因爲 Rust 足夠聰明,可以優化掉任何實際的函數調用。我們可以通過使用 cargo-show-asm(不要與不再維護的 cargo-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 以 wrapped 類型的形式提供了一個解決方案。這種類型允許使用正常的算術運算符 (+、/ 等),同時確保在發生整數溢出時自動換行值。讓我們試一試:
use std::num::Wrapping;
let mut x = Wrapping(125_u8);
x + Wrapping(200); // 69
x - Wrapping(200); // 181
x *= 5; // 如果我們改變變量x,那麼我們可以使用原始整數類型- x現在是113
x / 5; // 錯誤!— 我們只能在賦值時使用原語(例如,使用=,-=等)
這顯然比到處調用 wrapping_函數更簡潔。
還有 Saturating,其工作方式與 Wrapping 大致相同,除非值在溢出發生時變得飽和。在撰寫本文時,這種類型是一個僅在 nightly 版本使用的實驗性特性,但它可能在未來的某個時候被合併爲穩定特性。就目前而言,saturating_函數當然仍然是一個選項!
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/CpWefoZMFhRW44cPBpZhow