Lewati ke konten

Integer overflow di Rust

Setiap tipe integer (bilangan bulat) di Rust punya ukuran yang tetap. Sebuah u8 hanya punya tempat untuk angka 0 sampai 255, tidak lebih. Jadi apa yang terjadi kalau kamu punya 255 lalu menambahkan 1? Hasilnya tidak muat. Inilah yang disebut integer overflow (luapan bilangan bulat).

Bayangkan odometer mobil tua yang cuma punya tiga digit. Setelah 999 kilometer, angkanya kembali ke 000. Untuk mainan sih tidak masalah, tapi untuk saldo rekening bank, itu bencana. Rust membantumu di sini dengan dua cara. Ia menangkap masalahnya dengan jelas selama kamu masih mengembangkan program. Dan ia memberimu method-method sederhana untuk menentukan dengan tepat apa yang harus terjadi. Tenang, jumlahnya cuma empat, dan semuanya mengikuti pola yang sama. Halaman ini melanjutkan materi dari Tipe data dan Operator.

Setiap tipe punya nilai terkecil (MIN) dan nilai terbesar (MAX). Kamu bisa mencetaknya dengan konstanta seperti u8::MAX atau i32::MIN:

fn main() {
println!("u8: {} sampai {}", u8::MIN, u8::MAX);
println!("i8: {} sampai {}", i8::MIN, i8::MAX);
println!("u16: {} sampai {}", u16::MIN, u16::MAX);
println!("i32: {} sampai {}", i32::MIN, i32::MAX);
println!("u32: {} sampai {}", u32::MIN, u32::MAX);
println!("i64: {} sampai {}", i64::MIN, i64::MAX);
println!("u64: {} sampai {}", u64::MIN, u64::MAX);
println!("u128: {} sampai {}", u128::MIN, u128::MAX);
}

Output:

u8: 0 sampai 255
i8: -128 sampai 127
u16: 0 sampai 65535
i32: -2147483648 sampai 2147483647
u32: 0 sampai 4294967295
i64: -9223372036854775808 sampai 9223372036854775807
u64: 0 sampai 18446744073709551615
u128: 0 sampai 340282366920938463463374607431768211455
▶ Coba sendiri

Berikut seluruh keluarganya dalam satu tabel:

Tipe Bit Terkecil Terbesar
i8 / u8 8 -128 / 0 127 / 255
i16 / u16 16 -32.768 / 0 32.767 / 65.535
i32 / u32 32 sekitar -2,1 miliar / 0 sekitar 2,1 miliar / 4,3 miliar
i64 / u64 64 sekitar -9,2 × 10^18 / 0 sekitar 9,2 × 10^18 / 1,8 × 10^19
i128 / u128 128 sekitar -1,7 × 10^38 / 0 sekitar 1,7 × 10^38 / 3,4 × 10^38
isize / usize 64 di kebanyakan komputer sama dengan i64 / 0 sama dengan i64 / u64

Tipe signed (bertanda, i) memakai separuh rentangnya untuk angka negatif. Tipe unsigned (tidak bertanda, u) mulai dari 0, jadi nilai terbesarnya kira-kira dua kali lipat. Apa pun di luar rentang ini adalah overflow, dan turun di bawah MIN (seperti 0 - 1 pada u8) juga termasuk. Yang terakhir ini kadang disebut underflow, tapi Rust memperlakukannya dengan cara yang sama.

Apa yang dilakukan Rust saat angka overflow

Section titled “Apa yang dilakukan Rust saat angka overflow”

Saat kamu menjalankan programmu dengan cargo run (build debug), Rust memeriksa setiap +, -, dan *. Kalau hasilnya tidak muat, program berhenti dengan pesan yang jelas. Di sini sebuah game menambahkan poin bonus ke skor bertipe u8:

fn add_points(score: u8, bonus: u8) -> u8 {
score + bonus
}
fn main() {
let total = add_points(250, 10);
println!("total: {total}");
}

Saat dijalankan, kamu mendapat:

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
▶ Coba sendiri

Pesan attempt to add with overflow artinya “mencoba menjumlahkan, tapi hasilnya overflow”, dan baris pertama menunjukkan file serta nomor baris tempat itu terjadi. Nilai-nilainya sengaja dikirim lewat parameter fungsi. Kalau kamu menulis angkanya langsung di sebelah +, compiler akan melihat masalahnya bahkan sebelum program dijalankan (sebentar lagi kamu akan melihatnya). Program yang crash mungkin terasa kejam, tapi sebenarnya itu bentuk kebaikan: kamu menemukan bug-nya di komputermu sendiri, lengkap dengan nomor barisnya, alih-alih mendapat skor yang salah di production. Penjelasan lebih lanjut tentang panic ada di halaman Panic.

Saat kamu membangun dengan cargo run --release, pemeriksaan itu dimatikan supaya kodenya sedikit lebih cepat. Angkanya berputar balik (wrap around) seperti odometer tadi: 250 + 10 pada u8 menjadi 4 (karena 260 - 256 = 4).

Terminal window
cargo run --release
total: 4

Tidak ada panic, tidak ada peringatan, hanya angka yang diam-diam salah. Itulah sebabnya kamu sebaiknya tidak mengandalkan overflow yang terjadi “secara kebetulan”. Kalau kamu memang ingin angkanya berputar balik, katakan dengan jelas lewat wrapping_add (dibahas di bawah). Tombol “Coba sendiri” di situs ini menjalankan build debug, jadi wrapping_add juga cara untuk melihat perilaku ini di sana:

fn main() {
let score: u8 = 250;
println!("{}", score.wrapping_add(10));
}

Output:

4
▶ Coba sendiri

Kalau kamu ingin build release juga panic, kamu bisa menyalakan lagi pemeriksaannya di Cargo.toml:

[profile.release]
overflow-checks = true

Dengan angka langsung, kodenya bahkan tidak bisa dikompilasi

Section titled “Dengan angka langsung, kodenya bahkan tidak bisa dikompilasi”

Kalau compiler bisa melihat seluruh perhitungannya, ia menolak untuk membangun program itu:

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

Compiler bilang operasi aritmetika ini akan overflow, karena u8::MAX + 1 tidak muat. Hal yang sama berlaku untuk literal yang memang terlalu besar, seperti let level: u8 = 256;, yang menghasilkan pesan literal out of range for u8 (literal di luar rentang u8).

Daripada berharap overflow tidak pernah terjadi, kamu bisa memilih apa yang harus terjadi. Setiap tipe integer punya empat keluarga method untuk ini. Semuanya bekerja dengan cara yang sama pada u8, i32, u64, dan tipe lainnya.

checked_: “beri tahu aku kalau tidak muat”

Section titled “checked_: “beri tahu aku kalau tidak muat””

checked_add, checked_sub, dan checked_mul mengembalikan Option: Some(hasil) kalau muat, None kalau tidak. Ini pilihan paling aman setiap kali angka yang salah akan jadi masalah serius, misalnya soal uang. Saldo bank tidak bisa di bawah 0, jadi u64 dengan checked_sub menolak penarikan itu untukmu:

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!("Menarik {amount}, saldo sekarang {left}"),
None => println!("Tidak bisa menarik {amount}: uangnya tidak cukup"),
}
}
println!("{:?}", order_total(20, 3, 5));
println!("{:?}", order_total(2_000_000_000, 3, 5));
}

Output:

Menarik 30, saldo sekarang 70
Tidak bisa menarik 250: uangnya tidak cukup
Some(65)
None
▶ Coba sendiri

Karena hasilnya berupa Option, operator ? juga bisa dipakai. Di order_total, kalau perkaliannya overflow, fungsinya langsung mengembalikan None.

saturating_add dan saturating_sub tidak pernah melewati MIN atau MAX. Keduanya berhenti saja di sana, seperti gelas yang sudah penuh. Ini cocok sekali untuk hal-hal yang punya batas bawah atau batas atas alami: bar nyawa di game, kenop volume, jumlah barang di stok.

fn main() {
let health: u8 = 20;
let after_hit = health.saturating_sub(35);
println!("nyawa setelah serangan besar: {after_hit}");
let volume: u8 = 90;
let louder = volume.saturating_add(25).min(100);
println!("volume setelah dinaikkan: {louder}");
let stock: u32 = 3;
let left = stock.saturating_sub(5);
println!("sisa barang di stok: {left}");
}

Output:

nyawa setelah serangan besar: 0
volume setelah dinaikkan: 100
sisa barang di stok: 0
▶ Coba sendiri

Baris volume butuh satu langkah tambahan. saturating_add hanya berhenti di batas milik tipenya sendiri, yaitu 255 untuk u8. Kenop volumemu berhenti di 100, jadi .min(100) membatasinya ke batasmu sendiri.

wrapping_: “berputar balik dengan sengaja”

Section titled “wrapping_: “berputar balik dengan sengaja””

Kadang berputar balik justru yang kamu inginkan. Misalnya penghitung nomor tiket yang berjalan 254, 255, 0, 1. Atau fungsi hash sederhana, di mana angka-angkanya memang seharusnya teraduk. wrapping_add dan wrapping_mul melakukan ini di semua build, baik debug maupun release:

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!("tiket {ticket}");
ticket = ticket.wrapping_add(1);
}
println!("hash dari \"hello\": {}", simple_hash("hello"));
println!("hash dari \"a much longer sentence\": {}", simple_hash("a much longer sentence"));
}

Output:

tiket 254
tiket 255
tiket 0
tiket 1
hash dari "hello": 261238937
hash dari "a much longer sentence": 3373490575
▶ Coba sendiri

Teks yang di-hash sengaja dibiarkan dalam bahasa Inggris, karena nilai hash-nya dihitung dari setiap byte teks itu: kata lain akan menghasilkan angka lain. Kalimat panjang tadi akan membuat u32 overflow berkali-kali kalau memakai * dan + biasa. Dengan method wrapping_, itu tidak masalah: memang begitulah cara kerja hash ini.

overflowing_add mengembalikan tuple: hasil yang sudah berputar balik dan sebuah bool yang menunjukkan apakah terjadi overflow. Ini berguna kalau kamu ingin nilai yang berputar balik, tapi juga ingin tahu bahwa hal itu terjadi:

fn main() {
let (value, overflowed) = 250u8.overflowing_add(10);
println!("{value}, overflow: {overflowed}");
let (value, overflowed) = 50u8.overflowing_add(10);
println!("{value}, overflow: {overflowed}");
}

Output:

4, overflow: true
60, overflow: false
▶ Coba sendiri

Yang satu ini akan lebih jarang kamu pakai dibanding yang lain. Kalau kamu ragu harus pilih yang mana, checked_ adalah pilihan bawaan yang bagus.

Mengonversi tipe dengan as tidak pernah panic, bahkan di build debug. Kalau nilainya tidak muat, nilainya diam-diam dipotong supaya muat. Ini mengejutkan banyak orang, jadi hati-hati:

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!("muat: {small}"),
Err(e) => println!("300 tidak muat di u8: {e}"),
}
match u8::try_from(200) {
Ok(small) => println!("muat: {small}"),
Err(e) => println!("200 tidak muat di u8: {e}"),
}
}

Output:

300 as u8 = 44
-1 as u32 = 4294967295
300 tidak muat di u8: out of range integral type conversion attempted
muat: 200
▶ Coba sendiri

Pesan out of range integral type conversion attempted berasal dari Rust sendiri, artinya “mencoba konversi tipe integer di luar rentang”. 300 as u8 hanya menyimpan 8 bit terbawah, yaitu 44. -1 as u32 berubah menjadi u32 terbesar. Keduanya tidak memberi peringatan apa pun. u8::try_from memeriksa dulu dan memberimu Result, jadi kamu bisa memutuskan apa yang harus dilakukan. Kebiasaan yang baik: pakai as hanya kalau kamu yakin nilainya muat, dan try_from kalau nilainya datang dari luar programmu. (Float berperilaku berbeda: 300.7 as u8 berhenti di 255, bukan berputar balik.) Halaman Konversi tipe dan Konversi (From, Into) membahasnya lebih dalam.

Beberapa hal lain yang sering bikin orang tersandung, dikumpulkan di satu tempat:

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));
}

Output:

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
▶ Coba sendiri
  • Pembagian integer membuang sisanya. Hasilnya tidak dibulatkan, tapi dipotong ke arah nol, jadi -7 / 2 hasilnya -3. Pakai % untuk mendapatkan sisa bagi, atau pakai float kalau kamu butuh desimal.
  • abs() bisa overflow. i32 bisa turun sampai -2147483648 tapi hanya naik sampai 2147483647, jadi versi positif dari i32::MIN tidak muat. i32::MIN.abs() akan panic di build debug dengan pesan attempt to negate with overflow (mencoba membuat negatif, tapi overflow). Pakai checked_abs(), atau unsigned_abs(), yang mengembalikan u32 sehingga hasilnya muat.
  • pow tumbuh dengan cepat. 10 pangkat 10 saja sudah terlalu besar untuk u32. Ada juga checked_pow, saturating_pow, dan wrapping_pow.

Kalau sebuah nilai selalu harus berputar balik atau selalu harus berhenti di batas, kamu bisa membungkusnya dengan std::num::Wrapping atau std::num::Saturating. Dengan begitu, + dan - biasa langsung berperilaku benar, dan kamu tidak mungkin lupa:

use std::num::{Saturating, Wrapping};
fn main() {
let mut ticket = Wrapping(254u8);
ticket += 1;
ticket += 1;
ticket += 1;
println!("tiket: {ticket}");
let mut battery = Saturating(10u8);
battery -= 25;
println!("baterai: {battery}");
}

Output:

tiket: 1
baterai: 0
▶ Coba sendiri
Keluarga method Mengembalikan Pakai kalau
+ - * Angkanya (panic di debug, berputar balik di release) Kamu yakin tidak mungkin overflow
checked_add dan kawan-kawan Option<T> Angka yang salah berarti bug: uang, ukuran, ID
saturating_add dan kawan-kawan Angkanya, tertahan di MIN atau MAX Ada batas bawah atau atas alami: nyawa, volume, stok
wrapping_add dan kawan-kawan Angkanya, setelah berputar balik Berputar balik memang tujuannya: hash, penghitung yang berulang
overflowing_add dan kawan-kawan (T, bool) Kamu ingin nilai yang berputar balik sekaligus tahu bahwa itu terjadi
T::try_from(x) Result<T, _> Mengonversi nilai yang mungkin tidak muat di tipe yang lebih kecil

len() mengembalikan usize, yang tidak bisa negatif. Pada list yang kosong, len() - 1 akan bernilai -1, jadi terjadi overflow:

fn last_index(tasks: &[String]) -> usize {
tasks.len() - 1
}
fn main() {
let tasks: Vec<String> = Vec::new();
println!("tugas terakhir ada di indeks {}", last_index(&tasks));
}

Saat dijalankan, kamu mendapat:

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
▶ Coba sendiri

Pesan attempt to subtract with overflow artinya “mencoba mengurangi, tapi hasilnya overflow”. Kesalahan ini menjebak hampir semua orang setidaknya sekali. Cara memperbaikinya adalah membiarkan tipenya memberi tahu kamu bahwa list-nya mungkin kosong: kembalikan tasks.len().checked_sub(1), yang bertipe Option<usize>. Kalau kamu cuma butuh item terakhir, tasks.last() lebih sederhana lagi.

Menjumlahkan angka kecil di tipe yang kecil

Section titled “Menjumlahkan angka kecil di tipe yang kecil”

Tiga nilai ujian, 90, 85, dan 100, masing-masing muat di u8. Tapi totalnya, 275, tidak muat. Jadi let total: u8 = scores.iter().sum(); akan panic dengan pesan attempt to add with overflow. Panic-nya menunjuk ke dalam standard library, yang bisa membingungkan, tapi penyebabnya adalah u8 milikmu. Konversikan setiap nilai ke tipe yang lebih besar sebelum dijumlahkan:

fn main() {
let scores: Vec<u8> = vec![90, 85, 100];
let total: u32 = scores.iter().map(|&s| s as u32).sum();
println!("total: {total}, rata-rata: {}", total / 3);
}

Output:

total: 275, rata-rata: 91
▶ Coba sendiri

Mengubah u8 menjadi u32 dengan as selalu aman, karena setiap u8 pasti muat di u32.

Level baterai ponsel adalah u8 dari 0 sampai 100. Tulis charge(level: u8, amount: u8) -> u8 dan drain(level: u8, amount: u8) -> u8 yang tidak pernah melewati 100 atau turun di bawah 0. Mulai dari 80, isi daya sebanyak 30, lalu kurangi sebanyak 130, dan cetak level baterainya setelah setiap langkah. Petunjuk: saturating_add ditambah .min(100) untuk mengisi daya, saturating_sub untuk mengurangi.

Lihat solusi
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!("awal: {level}%");
level = charge(level, 30);
println!("setelah diisi daya: {level}%");
level = drain(level, 130);
println!("setelah seharian dipakai: {level}%");
}

Output:

awal: 80%
setelah diisi daya: 100%
setelah seharian dipakai: 0%
▶ Coba sendiri

saturating_sub berhenti di 0 dengan sendirinya. Untuk mengisi daya, batas tipenya adalah 255, jadi .min(100) menambahkan batasmu sendiri di atasnya.

Tulis fn factorial(n: u64) -> Option<u64> yang mengalikan 1 × 2 × … × n, dan mengembalikan None kalau hasilnya terlalu besar untuk u64. Cetak hasilnya untuk 5, 20, dan 21. Petunjuk: mulai dengan let mut result: u64 = 1; dan pakai result.checked_mul(i)? di dalam perulangan for.

Lihat solusi
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}! terlalu besar untuk u64"),
}
}
}

Output:

5! = 120
20! = 2432902008176640000
21! terlalu besar untuk u64
▶ Coba sendiri

? langsung mengembalikan None dari seluruh fungsi begitu ada satu perkalian yang tidak muat. 20! adalah faktorial terbesar yang bisa ditampung u64.

  • Setiap tipe integer punya rentang yang tetap, dari T::MIN sampai T::MAX. Keluar dari rentang itu disebut overflow.
  • Build debug akan panic dengan pesan attempt to add with overflow. Build release diam-diam berputar balik. Overflow yang bisa dilihat compiler menjadi error saat kompilasi.
  • Pilih perilakunya sendiri: checked_ memberi Option, saturating_ berhenti di batas, wrapping_ berputar balik, overflowing_ memberi nilainya sekaligus sebuah bool.
  • as tidak pernah memeriksa dan bisa diam-diam mengubah angka. Pakai T::try_from kalau sebuah nilai mungkin tidak muat.
  • Hati-hati dengan len() - 1 pada list kosong, penjumlahan di tipe yang kecil, abs() dari MIN, dan pow.

Kuis singkat

1. Apa yang terjadi pada 250u8 + 10 di build debug, kalau nilainya berasal dari variabel?

2. Apa yang dikembalikan 100u64.checked_sub(250)?

3. Bar nyawa tidak boleh turun di bawah 0. Method mana yang paling cocok?

4. Berapa hasil 300_i32 as u8?

Skor: 0 / 4