Tràn số nguyên trong Rust
Mọi kiểu số nguyên trong Rust đều có kích thước cố định. Một u8 chỉ có chỗ cho các số từ 0 đến 255, không hơn. Vậy chuyện gì xảy ra khi bạn có 255 rồi cộng thêm 1? Kết quả không vừa. Điều đó gọi là tràn số nguyên (integer overflow).
Hãy nghĩ đến đồng hồ đo quãng đường của một chiếc xe cũ chỉ có ba chữ số. Sau 999 km nó quay về 000. Với đồ chơi thì chẳng sao, nhưng với số dư tài khoản ngân hàng thì thật tệ. Rust giúp bạn ở đây theo hai cách. Nó lên tiếng rõ ràng khi bắt được vấn đề lúc bạn đang phát triển. Và nó cho bạn những phương thức đơn giản để nói chính xác điều gì nên xảy ra thay vào đó. Đừng lo, chỉ có bốn nhóm thôi, và chúng đều theo cùng một khuôn mẫu. Trang này xây tiếp trên các bài Kiểu dữ liệu và Toán tử.
Mỗi kiểu số nguyên lớn đến đâu?
Phần tiêu đề “Mỗi kiểu số nguyên lớn đến đâu?”Mỗi kiểu có một giá trị nhỏ nhất (MIN) và một giá trị lớn nhất (MAX). Bạn có thể in chúng ra bằng các hằng số như u8::MAX hay i32::MIN:
fn main() { println!("u8: {} đến {}", u8::MIN, u8::MAX); println!("i8: {} đến {}", i8::MIN, i8::MAX); println!("u16: {} đến {}", u16::MIN, u16::MAX); println!("i32: {} đến {}", i32::MIN, i32::MAX); println!("u32: {} đến {}", u32::MIN, u32::MAX); println!("i64: {} đến {}", i64::MIN, i64::MAX); println!("u64: {} đến {}", u64::MIN, u64::MAX); println!("u128: {} đến {}", u128::MIN, u128::MAX);}Kết quả:
u8: 0 đến 255i8: -128 đến 127u16: 0 đến 65535i32: -2147483648 đến 2147483647u32: 0 đến 4294967295i64: -9223372036854775808 đến 9223372036854775807u64: 0 đến 18446744073709551615u128: 0 đến 340282366920938463463374607431768211455Đây là cả gia đình trong một bảng:
| Kiểu | Số bit | Nhỏ nhất | Lớn nhất |
|---|---|---|---|
i8 / u8 |
8 | -128 / 0 | 127 / 255 |
i16 / u16 |
16 | -32.768 / 0 | 32.767 / 65.535 |
i32 / u32 |
32 | khoảng -2,1 tỷ / 0 | khoảng 2,1 tỷ / 4,3 tỷ |
i64 / u64 |
64 | khoảng -9,2 × 10^18 / 0 | khoảng 9,2 × 10^18 / 1,8 × 10^19 |
i128 / u128 |
128 | khoảng -1,7 × 10^38 / 0 | khoảng 1,7 × 10^38 / 3,4 × 10^38 |
isize / usize |
64 trên hầu hết máy tính | giống i64 / 0 |
giống i64 / u64 |
Một kiểu có dấu (i) dành một nửa phạm vi cho số âm. Một kiểu không dấu (u) bắt đầu từ 0, nên giá trị lớn nhất của nó lớn gấp khoảng hai lần. Bất cứ thứ gì nằm ngoài các phạm vi này đều là tràn số, và việc đi xuống dưới MIN (như 0 - 1 trên một u8) cũng tính. Trường hợp đó đôi khi được gọi là underflow, nhưng Rust xử lý nó y như nhau.
Rust làm gì khi một số bị tràn
Phần tiêu đề “Rust làm gì khi một số bị tràn”Trong bản build debug, nó panic
Phần tiêu đề “Trong bản build debug, nó panic”Khi bạn chạy chương trình bằng cargo run (một bản build debug), Rust kiểm tra mọi phép +, - và *. Nếu kết quả không vừa, chương trình dừng lại với một thông báo rõ ràng. Ở đây một trò chơi cộng điểm thưởng vào điểm số kiểu u8:
fn add_points(score: u8, bonus: u8) -> u8 { score + bonus}
fn main() { let total = add_points(250, 10); println!("tổng: {total}");}Khi chạy, bạn nhận được:
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 backtraceThông báo panic luôn bằng tiếng Anh: “attempt to add with overflow” nghĩa là “phép cộng bị tràn số”, xảy ra ở dòng 2, cột 5. Các giá trị được cố ý truyền qua tham số hàm. Nếu bạn viết các con số ngay cạnh dấu +, trình biên dịch sẽ phát hiện vấn đề trước cả khi chương trình chạy (bạn sẽ thấy điều đó ngay sau đây). Dừng chương trình nghe có vẻ khắc nghiệt, nhưng thực ra là tử tế: bạn tìm ra lỗi ngay trên máy mình, với số dòng chính xác, thay vì nhận một điểm số sai trên production. Có thêm về panic ở bài Panic.
Trong bản build release, nó quay vòng
Phần tiêu đề “Trong bản build release, nó quay vòng”Khi bạn build bằng cargo run --release, những bước kiểm tra đó bị tắt để code chạy nhanh hơn một chút. Con số quay vòng (wrap around) giống như đồng hồ đo quãng đường: 250 + 10 trên một u8 thành 4 (vì 260 - 256 = 4).
cargo run --releasetổng: 4Không panic, không cảnh báo, chỉ là một con số sai một cách lặng lẽ. Đó là lý do bạn không nên dựa vào việc tràn số xảy ra “tình cờ”. Nếu bạn muốn quay vòng, hãy nói rõ bằng wrapping_add (xem bên dưới). Các nút “Tự chạy thử” trên trang này chạy bản build debug, nên wrapping_add cũng là cách để bạn thấy hành vi này ở đó:
fn main() { let score: u8 = 250; println!("{}", score.wrapping_add(10));}Kết quả:
4Nếu bạn muốn bản build release cũng panic, bạn có thể bật lại các bước kiểm tra trong Cargo.toml:
[profile.release]overflow-checks = trueVới các con số viết trực tiếp, nó còn không biên dịch được
Phần tiêu đề “Với các con số viết trực tiếp, nó còn không biên dịch được”Khi trình biên dịch nhìn thấy toàn bộ phép tính, nó từ chối build:
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 defaultTrình biên dịch báo phép tính số học này sẽ bị tràn: u8::MAX + 1 không vừa trong một u8. Điều tương tự xảy ra với một literal đơn giản là quá lớn, như let level: u8 = 256;, sẽ cho lỗi literal out of range for u8.
Tự chọn cách xử lý
Phần tiêu đề “Tự chọn cách xử lý”Thay vì hy vọng tràn số không bao giờ xảy ra, bạn có thể chọn điều gì nên xảy ra. Mọi kiểu số nguyên đều có bốn nhóm phương thức cho việc này. Chúng hoạt động giống nhau trên u8, i32, u64 và các kiểu còn lại.
checked_: “cho tôi biết nếu không vừa”
Phần tiêu đề “checked_: “cho tôi biết nếu không vừa””checked_add, checked_sub và checked_mul trả về một Option: Some(kết_quả) nếu vừa, None nếu không. Đây là lựa chọn an toàn nhất mỗi khi một con số sai sẽ là vấn đề thật sự, như tiền bạc. Số dư ngân hàng không thể xuống dưới 0, nên một u64 với checked_sub sẽ từ chối giao dịch rút tiền giúp bạn:
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!("Đã rút {amount}, số dư còn {left}"), None => println!("Không thể rút {amount}: không đủ tiền"), } }
println!("{:?}", order_total(20, 3, 5)); println!("{:?}", order_total(2_000_000_000, 3, 5));}Kết quả:
Đã rút 30, số dư còn 70Không thể rút 250: không đủ tiềnSome(65)NoneVì kết quả là một Option, toán tử ? cũng dùng được. Trong order_total, nếu phép nhân bị tràn, hàm trả về None ngay lập tức.
saturating_: “dừng ở mép”
Phần tiêu đề “saturating_: “dừng ở mép””saturating_add và saturating_sub không bao giờ vượt quá MIN hay MAX. Chúng chỉ dừng lại ở đó, như một cái cốc đã đầy. Cách này hoàn hảo cho những thứ có sàn hoặc trần tự nhiên: thanh máu, núm âm lượng, hàng tồn kho.
fn main() { let health: u8 = 20; let after_hit = health.saturating_sub(35); println!("máu sau một đòn mạnh: {after_hit}");
let volume: u8 = 90; let louder = volume.saturating_add(25).min(100); println!("âm lượng sau khi vặn to: {louder}");
let stock: u32 = 3; let left = stock.saturating_sub(5); println!("số hàng còn trong kho: {left}");}Kết quả:
máu sau một đòn mạnh: 0âm lượng sau khi vặn to: 100số hàng còn trong kho: 0Dòng âm lượng cần thêm một bước. saturating_add chỉ dừng ở giới hạn của chính kiểu dữ liệu, tức 255 với u8. Núm vặn của bạn dừng ở 100, nên .min(100) kẹp nó về giới hạn của riêng bạn.
wrapping_: “cố ý quay vòng”
Phần tiêu đề “wrapping_: “cố ý quay vòng””Đôi khi quay vòng lại chính là điều bạn muốn. Một bộ đếm vé chạy 254, 255, 0, 1. Hoặc một hàm băm (hash) đơn giản, nơi các con số vốn được trộn lẫn với nhau. wrapping_add và wrapping_mul làm việc này ở mọi bản build, debug hay release. Ví dụ này giữ chuỗi tiếng Anh cho hàm băm, vì kết quả băm phụ thuộc vào từng byte của chuỗi:
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!("vé số {ticket}"); ticket = ticket.wrapping_add(1); }
println!("hash của \"hello\": {}", simple_hash("hello")); println!("hash của \"a much longer sentence\": {}", simple_hash("a much longer sentence"));}Kết quả:
vé số 254vé số 255vé số 0vé số 1hash của "hello": 261238937hash của "a much longer sentence": 3373490575Câu dài kia sẽ làm tràn một u32 rất nhiều lần nếu dùng * và + thông thường. Với các phương thức wrapping_ thì không sao: đó chính là việc hàm băm cần làm.
overflowing_: “cho tôi cả hai”
Phần tiêu đề “overflowing_: “cho tôi cả hai””overflowing_add trả về một tuple: kết quả đã quay vòng và một bool cho biết nó có bị tràn hay không. Nó tiện khi bạn muốn giá trị đã quay vòng nhưng cũng muốn biết chuyện đó đã xảy ra:
fn main() { let (value, overflowed) = 250u8.overflowing_add(10); println!("{value}, bị tràn: {overflowed}");
let (value, overflowed) = 50u8.overflowing_add(10); println!("{value}, bị tràn: {overflowed}");}Kết quả:
4, bị tràn: true60, bị tràn: falseBạn sẽ dùng nhóm này ít hơn các nhóm khác. Nếu không chắc nên chọn cái nào, checked_ là lựa chọn mặc định rất tốt.
Tràn số khi ép kiểu bằng as
Phần tiêu đề “Tràn số khi ép kiểu bằng as”Chuyển đổi giữa các kiểu bằng as không bao giờ panic, kể cả trong bản build debug. Nếu giá trị không vừa, nó lặng lẽ bị cắt cho vừa. Điều này khiến rất nhiều người bất ngờ, nên hãy cẩn thận:
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!("vừa: {small}"), Err(e) => println!("300 không vừa trong u8: {e}"), }
match u8::try_from(200) { Ok(small) => println!("vừa: {small}"), Err(e) => println!("200 không vừa trong u8: {e}"), }}Kết quả:
300 as u8 = 44-1 as u32 = 4294967295300 không vừa trong u8: out of range integral type conversion attemptedvừa: 200Phần “out of range integral type conversion attempted” là thông báo lỗi có sẵn của Rust (nghĩa là “chuyển đổi kiểu số nguyên bị vượt phạm vi”). 300 as u8 chỉ giữ lại 8 bit thấp nhất, tức là 44. -1 as u32 biến thành giá trị u32 lớn nhất. Không cái nào cảnh báo bạn cả. u8::try_from kiểm tra trước và trả cho bạn một Result, để bạn tự quyết định làm gì. Một thói quen tốt: chỉ dùng as khi bạn biết chắc giá trị vừa, và dùng try_from khi giá trị đến từ bên ngoài chương trình. (Số thực thì hoạt động khác: 300.7 as u8 dừng ở 255 thay vì quay vòng.) Các bài Ép kiểu và Chuyển đổi (From, Into) đi sâu hơn.
Những bất ngờ khác với con số
Phần tiêu đề “Những bất ngờ khác với con số”Thêm vài thứ hay làm người ta vấp, gom hết vào một chỗ:
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));}Kết quả:
7 / 2 = 3-7 / 2 = -3i32::MIN.checked_abs() = Nonei32::MIN.unsigned_abs() = 214748364810u32.checked_pow(9) = Some(1000000000)10u32.checked_pow(10) = None- Phép chia số nguyên bỏ phần dư. Nó không làm tròn; nó cắt về phía 0, nên
-7 / 2là-3. Dùng%để lấy phần dư, hoặc dùng số thực nếu bạn muốn có số thập phân. abs()có thể bị tràn. Mộti32đi xuống tới -2147483648 nhưng chỉ lên tới 2147483647, nên phiên bản dương củai32::MINkhông vừa.i32::MIN.abs()panic trong bản build debug với thông báoattempt to negate with overflow. Hãy dùngchecked_abs(), hoặcunsigned_abs(), phương thức này trả về mộtu32để vừa chỗ.powtăng rất nhanh. 10 mũ 10 đã quá lớn cho mộtu32. Cũng cóchecked_pow,saturating_powvàwrapping_pow.
Nếu một giá trị luôn luôn phải quay vòng hoặc luôn luôn phải dừng ở mép, bạn có thể bọc nó trong std::num::Wrapping hoặc std::num::Saturating. Khi đó + và - thông thường làm đúng việc, và bạn không thể quên được:
use std::num::{Saturating, Wrapping};
fn main() { let mut ticket = Wrapping(254u8); ticket += 1; ticket += 1; ticket += 1; println!("vé số: {ticket}");
let mut battery = Saturating(10u8); battery -= 25; println!("pin: {battery}");}Kết quả:
vé số: 1pin: 0Nên dùng phương thức nào?
Phần tiêu đề “Nên dùng phương thức nào?”| Nhóm phương thức | Trả về | Dùng khi |
|---|---|---|
+ - * |
Con số (panic ở debug, quay vòng ở release) | Bạn chắc chắn nó không thể tràn |
checked_add và các anh em |
Option<T> |
Một con số sai sẽ là bug: tiền, kích thước, ID |
saturating_add và các anh em |
Con số, dừng lại ở MIN hoặc MAX |
Có sàn hoặc trần tự nhiên: máu, âm lượng, tồn kho |
wrapping_add và các anh em |
Con số, đã quay vòng | Quay vòng chính là mục đích: hàm băm, bộ đếm chạy vòng |
overflowing_add và các anh em |
(T, bool) |
Bạn muốn giá trị đã quay vòng và muốn biết nó đã quay vòng |
T::try_from(x) |
Result<T, _> |
Chuyển đổi một giá trị có thể không vừa trong kiểu nhỏ hơn |
Lỗi thường gặp
Phần tiêu đề “Lỗi thường gặp”Trừ 1 từ độ dài của danh sách rỗng
Phần tiêu đề “Trừ 1 từ độ dài của danh sách rỗng”len() trả về một usize, kiểu này không thể âm. Với một danh sách rỗng, len() - 1 sẽ là -1, nên nó bị tràn:
fn last_index(tasks: &[String]) -> usize { tasks.len() - 1}
fn main() { let tasks: Vec<String> = Vec::new(); println!("việc cuối cùng nằm ở chỉ số {}", last_index(&tasks));}Khi chạy, bạn nhận được:
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 backtracePanic này báo “attempt to subtract with overflow” (phép trừ bị tràn). Lỗi này bắt được gần như tất cả mọi người ít nhất một lần. Cách sửa là để kiểu dữ liệu nói cho bạn biết danh sách có thể rỗng: trả về tasks.len().checked_sub(1), một Option<usize>. Nếu bạn chỉ muốn lấy phần tử cuối, tasks.last() còn đơn giản hơn.
Cộng các số nhỏ trong một kiểu nhỏ
Phần tiêu đề “Cộng các số nhỏ trong một kiểu nhỏ”Ba điểm kiểm tra 90, 85 và 100 đều vừa trong một u8. Tổng của chúng, 275, thì không. Vì vậy let total: u8 = scores.iter().sum(); panic với attempt to add with overflow. Panic chỉ vào bên trong thư viện chuẩn, điều này có thể gây khó hiểu, nhưng nguyên nhân là u8 của bạn. Hãy chuyển từng giá trị sang một kiểu lớn hơn trước khi cộng:
fn main() { let scores: Vec<u8> = vec![90, 85, 100]; let total: u32 = scores.iter().map(|&s| s as u32).sum(); println!("tổng: {total}, trung bình: {}", total / 3);}Kết quả:
tổng: 275, trung bình: 91Đi từ u8 lên u32 bằng as luôn an toàn, vì mọi u8 đều vừa trong một u32.
Luyện tập
Phần tiêu đề “Luyện tập”1. Pin điện thoại
Phần tiêu đề “1. Pin điện thoại”Mức pin điện thoại là một u8 từ 0 đến 100. Hãy viết charge(level: u8, amount: u8) -> u8 và drain(level: u8, amount: u8) -> u8 sao cho không bao giờ vượt quá 100 hay xuống dưới 0. Bắt đầu ở 80, sạc thêm 30, rồi tiêu hao 130, và in mức pin sau mỗi bước. Gợi ý: saturating_add cộng .min(100) cho việc sạc, saturating_sub cho việc tiêu hao.
Xem lời giải
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!("ban đầu: {level}%");
level = charge(level, 30); println!("sau khi sạc: {level}%");
level = drain(level, 130); println!("sau một ngày dài: {level}%");}Kết quả:
ban đầu: 80%sau khi sạc: 100%sau một ngày dài: 0%saturating_sub tự dừng ở 0. Với việc sạc, giới hạn của kiểu là 255, nên .min(100) thêm giới hạn của riêng bạn lên trên.
2. Hàm giai thừa biết giới hạn của mình
Phần tiêu đề “2. Hàm giai thừa biết giới hạn của mình”Viết fn factorial(n: u64) -> Option<u64> nhân 1 × 2 × … × n, và trả về None nếu kết quả quá lớn cho một u64. In kết quả cho 5, 20 và 21. Gợi ý: bắt đầu với let mut result: u64 = 1; và dùng result.checked_mul(i)? bên trong một vòng lặp for.
Xem lời giả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}! quá lớn cho một u64"), } }}Kết quả:
5! = 12020! = 243290200817664000021! quá lớn cho một u64? trả None về từ cả hàm ngay khi một phép nhân không vừa. 20! là giai thừa lớn nhất mà một u64 chứa được.
Tóm tắt
Phần tiêu đề “Tóm tắt”- Mọi kiểu số nguyên đều có một phạm vi cố định, từ
T::MINđếnT::MAX. Đi ra ngoài phạm vi đó là tràn số. - Bản build debug panic với
attempt to add with overflow. Bản build release lặng lẽ quay vòng. Tràn số mà trình biên dịch nhìn thấy được là lỗi biên dịch. - Tự chọn cách xử lý:
checked_cho mộtOption,saturating_dừng ở mép,wrapping_quay vòng,overflowing_cho cả giá trị lẫn mộtbool. askhông bao giờ kiểm tra và có thể âm thầm thay đổi một con số. DùngT::try_fromkhi một giá trị có thể không vừa.- Cẩn thận với
len() - 1trên danh sách rỗng, tính tổng trong kiểu nhỏ,abs()củaMIN, vàpow.
Kiểm tra nhanh
Phần tiêu đề “Kiểm tra nhanh”Kiểm tra nhanh
1. Chuyện gì xảy ra với 250u8 + 10 trong bản build debug, khi các giá trị đến từ biến?
2. 100u64.checked_sub(250) trả về gì?
3. Một thanh máu không bao giờ được xuống dưới 0. Phương thức nào hợp nhất?
4. 300_i32 as u8 bằng bao nhiêu?
Điểm: 0 / 4
Đọc thêm
Phần tiêu đề “Đọc thêm”- Integer Overflow — The Rust Book (tiếng Anh): lời giải thích chính thức về panic ở bản debug, quay vòng ở bản release, và bốn nhóm phương thức.
u8— standard library docs (tiếng Anh): mọi phương thứcchecked_,saturating_,wrapping_vàoverflowing_, mỗi cái kèm một ví dụ nhỏ.i32— standard library docs (tiếng Anh): cùng những phương thức đó cho số có dấu, cộng thêmchecked_abs,unsigned_absvàchecked_pow.- Overflow — The Rust Reference (tiếng Anh): quy tắc chính xác về những phép toán nào có thể tràn và chuyện gì xảy ra khi chúng tràn.
- Casting — Rust by Example (tiếng Anh): ví dụ chạy được về các phép ép
asquay vòng, cắt bớt và dừng ở mép.