Bỏ qua để đến nội dung

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 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 255
i8: -128 đến 127
u16: 0 đến 65535
i32: -2147483648 đến 2147483647
u32: 0 đến 4294967295
i64: -9223372036854775808 đến 9223372036854775807
u64: 0 đến 18446744073709551615
u128: 0 đến 340282366920938463463374607431768211455
▶ Tự chạy thử

Đâ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.

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 overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
▶ Tự chạy thử

Thô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.

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).

Terminal window
cargo run --release
tổng: 4

Khô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ả:

4
▶ Tự chạy thử

Nế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 = true

Vớ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 default

Trì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.

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 70
Không thể rút 250: không đủ tiền
Some(65)
None
▶ Tự chạy thử

Vì 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_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: 100
số hàng còn trong kho: 0
▶ Tự chạy thử

Dò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.

Đô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ố 254
vé số 255
vé số 0
vé số 1
hash của "hello": 261238937
hash của "a much longer sentence": 3373490575
▶ Tự chạy thử

Câ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_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: true
60, bị tràn: false
▶ Tự chạy thử

Bạ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.

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 = 4294967295
300 không vừa trong u8: out of range integral type conversion attempted
vừa: 200
▶ Tự chạy thử

Phầ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.

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 = -3
i32::MIN.checked_abs() = None
i32::MIN.unsigned_abs() = 2147483648
10u32.checked_pow(9) = Some(1000000000)
10u32.checked_pow(10) = None
▶ Tự chạy thử
  • 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 / 2 là -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ột i32 đi xuống tới -2147483648 nhưng chỉ lên tới 2147483647, nên phiên bản dương của i32::MIN không vừa. i32::MIN.abs() panic trong bản build debug với thông báo attempt to negate with overflow. Hãy dùng checked_abs(), hoặc unsigned_abs(), phương thức này trả về một u32 để vừa chỗ.
  • pow tăng rất nhanh. 10 mũ 10 đã quá lớn cho một u32. Cũng có checked_pow, saturating_pow và 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ố: 1
pin: 0
▶ Tự chạy thử
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

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 overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
▶ Tự chạy thử

Panic 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.

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
▶ Tự chạy thử

Đ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.

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%
▶ Tự chạy thử

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! = 120
20! = 2432902008176640000
21! quá lớn cho một u64
▶ Tự chạy thử

? 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.

  • Mọi kiểu số nguyên đều có một phạm vi cố định, từ T::MIN đến T::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ột Option, saturating_ dừng ở mép, wrapping_ quay vòng, overflowing_ cho cả giá trị lẫn một bool.
  • as không bao giờ kiểm tra và có thể âm thầm thay đổi một con số. Dùng T::try_from khi một giá trị có thể không vừa.
  • Cẩn thận với len() - 1 trên danh sách rỗng, tính tổng trong kiểu nhỏ, abs() của MIN, và pow.

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

  • 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ức checked_, 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êm checked_abs, unsigned_abs và 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 as quay vòng, cắt bớt và dừng ở mép.