Kontak Pertama unsafe

Hal yang dimungkinkan blok unsafe serta kontrak soundness.

Daftar isi

Topik ini membahas blok unsafe, yaitu bagian bahasa yang membuka kemampuan tertentu yang biasanya ditahan kompilator, serta kontrak soundness yang menyertainya, yakni janji bahwa kode aman tidak akan mengalami undefined behavior. Pembahasan mencakup dereferensi pointer mentah, akses static mut, pemanggilan fungsi unsafe, dan pola pembungkus aman yang menyembunyikan bagian unsafe dari pemanggil. Seluruh contoh dijalankan nyata dengan rustc, sedangkan pelanggaran kontrak hanya diuraikan sebagai konsep karena contoh yang benar-benar merusak memori tidak memiliki keluaran yang dapat diramalkan. Setelah membaca topik ini, blok unsafe dapat dibaca dengan pemahaman penuh atas jaminan yang berpindah dari kompilator ke pemrogram, dan keputusan membungkusnya ke dalam API aman dapat diambil dengan pola yang benar.

Lima kemampuan yang dibuka blok unsafe#

Kata kunci unsafe bukan saklar yang mematikan seluruh pemeriksaan kompilator. Blok unsafe membuka lima kemampuan yang bersifat spesifik, yaitu dereferensi pointer mentah, pemanggilan fungsi atau metode unsafe, akses dan modifikasi variabel statis yang mutabel, implementasi trait unsafe, dan akses medan union. Di luar lima hal itu, seluruh aturan bahasa tetap ditegakkan sebagaimana biasa. Penulisan unsafe fn menandai fungsi yang kontraknya harus dijaga pemanggilnya, sedangkan unsafe impl menandai implementasi yang menuntut jaminan serupa dari penulisnya.

Kemampuan yang dibuka memiliki satu kesamaan, yaitu kompilator tidak lagi dapat membuktikan keamanan operasinya dengan aturan umum. Pointer mentah tidak dilacak aliasingnya, static mut tidak memiliki satu pemilik yang dapat diperiksa lintas thread, dan fungsi unsafe dapat memuat syarat yang hanya diketahui penulisnya. Blok unsafe pada akhirnya adalah pernyataan tertulis dari pemrogram bahwa invarian yang tidak dapat diperiksa kompilator justru dipenuhi di titik itu, sehingga tanggung jawab kebenaran berpindah dari alat ke manusia.

Dereferensi pointer mentah#

Pointer mentah bertipe *const T dan *mut T. Berbeda dari referensi, pointer mentah tidak diikuti aturan satu penulis atau banyak pembaca, sehingga dua pointer boleh menunjuk data yang sama, dan pointer juga tidak menjaga data tetap hidup. Pembuatannya aman karena hanya menghasilkan alamat, sedangkan pemakaiannya berupa dereferensi yang menuntut blok unsafe, sebab kompilator tidak tahu apakah alamat itu masih valid ketika dipakai.

rust pointer-mentah.rs
fn main() {
    let a = 10;
    let mut b = 24;

    // pembuatan pointer mentah itu aman dan tidak memerlukan blok unsafe
    let p1: *const i32 = &a;
    let p2: *const i32 = &a; // dua pointer pada data yang sama diperbolehkan
    let pb: *mut i32 = &mut b;

    // dereferensi pointer mentah baru memerlukan blok unsafe
    unsafe {
        println!("a melalui p1: {}", *p1);
        println!("a melalui p2: {}", *p2);
        *pb += 1;
    }

    println!("b sekarang: {}", b);
    println!("p1 dan p2 menunjuk alamat sama: {}", p1 == p2);
}
text Keluaran
a melalui p1: 10
a melalui p2: 10
b sekarang: 25
p1 dan p2 menunjuk alamat sama: true

Nilai a terbaca melalui dua pointer sekaligus tanpa error pinjaman karena pointer mentah memang tidak dilacak. Nilai b berubah menjadi 25 melalui pb, dan pembandingan p1 == p2 yang bersifat aman memastikan kedua pointer menunjuk alamat yang identik. Kebenaran program ini bergantung pada satu fakta yang diketahui pemrogram, yaitu seluruh pointer dipakai selagi pemilik datanya masih hidup di lingkup yang sama. Pada referensi biasa, fakta semacam itu dibuktikan kompilator, sedangkan di sini hanya dipercaya.

Kegunaan yang sah atas kemampuan ini adalah membangun abstraksi aman yang tidak dapat diungkapkan dengan aturan pinjaman biasa. Contoh klasiknya adalah fungsi yang membelah satu potongan menjadi dua bagian tulis yang tidak tumpang tindih. Aturan pinjaman menolak dua &mut atas potongan yang sama, padahal dua belahan pada rentang indeks yang berbeda sebenarnya tidak pernah bertabrakan.

rust belah-slice.rs
use std::slice;

// signature tetap aman bagi pemanggil: dua pinjaman tulis yang tidak tumpang tindih
fn belah_di(values: &mut [i32], tengah: usize) -> (&mut [i32], &mut [i32]) {
    let panjang = values.len();
    let ptr = values.as_mut_ptr();

    assert!(tengah <= panjang);

    unsafe {
        (
            slice::from_raw_parts_mut(ptr, tengah),
            slice::from_raw_parts_mut(ptr.add(tengah), panjang - tengah),
        )
    }
}

fn main() {
    let mut data = [10, 24, 100, 7, 50];

    let (awal, akhir) = belah_di(&mut data, 2);

    println!("awal: {:?}", awal);
    println!("akhir: {:?}", akhir);

    awal[0] = 1;
    akhir[0] = 9;

    println!("data setelah diubah: {:?}", data);
}
text Keluaran
awal: [10, 24]
akhir: [100, 7, 50]
data setelah diubah: [1, 24, 9, 7, 50]

Fungsi belah_di memegang seluruh potongan lewat satu &mut, mengambil alamat awalnya sebagai pointer mentah, lalu membangun dua potongan baru dengan slice::from_raw_parts_mut atas rentang indeks yang tidak tumpang tindih. Pemeriksaan assert! menjaga syarat pertama bahwa tengah tidak melebihi panjang, dan aritmetika ptr.add(tengah) menjaga syarat kedua bahwa kedua belahan tidak pernah berbagi elemen. Kedua perubahan pada awal dan akhir kemudian terlihat utuh pada data tanpa elemen mana pun yang dipegang dua kali. Signature fungsi tetap sepenuhnya aman sehingga pemanggilnya tidak pernah menulis blok unsafe sendiri, dan pola inilah yang dipakai split_at_mut pada pustaka standar.

Akses static mut lewat unsafe#

Variabel static mut adalah tempat penyimpanan global yang dapat berubah, dan setiap aksesnya menuntut blok unsafe. Tanpa blok itu, kompilator menolak akses dengan error E0133 yang menyebut kebutuhan blok tidak aman, sehingga seluruh titik akses mudah dicari pada kode. Penyebab ketatnya adalah tidak adanya pemilik tunggal, karena kode pada thread mana pun dapat menjangkau variabel tersebut. Pada program satu thread, akses berurutan terbukti aman, dan blok unsafe menjadi pernyataan bahwa kesendirian thread itu benar.

rust statis-mut.rs
static mut CATATAN_PANGGILAN: u32 = 0;

fn catat_panggilan() {
    // akses langsung pada tempatnya, tanpa membuat referensi
    unsafe {
        CATATAN_PANGGILAN += 1;
    }
}

fn main() {
    catat_panggilan();
    catat_panggilan();
    catat_panggilan();

    let total = unsafe { CATATAN_PANGGILAN };
    println!("total panggilan: {}", total);
}
text Keluaran
total panggilan: 3

Tiga pemanggilan menghasilkan hitungan 3 karena penambahan berlangsung berurutan pada satu thread. Operasi += 1 pada program di atas sengaja menulis langsung pada tempatnya tanpa membuat referensi, karena pembuatan referensi ke static mut ditandai linter sebagai pola berbahaya dan diperketat lebih lanjut pada edisi Rust yang lebih baru. Pembacaan nilai akhir menyalin angka keluar dari tempat globalnya sebelum dipakai kembali pada kode aman.

Apabila dua thread menjalankan CATATAN_PANGGILAN += 1 pada saat bersamaan, kedua pembacaan dapat terjadi sebelum kedua penulisan sehingga satu hitungan hilang. Benturan tulis semacam itu tergolong undefined behavior, bukan sekadar hasil yang salah. Beban jaminan inilah alasan penghitung lintas thread pada praktiknya memakai AtomicU32 atau Mutex, yang menegakkan kebenaran tanpa menuntut jaminan pemrogram.

Memanggil fungsi unsafe dan kontraknya#

Fungsi unsafe adalah fungsi yang membawa syarat tidak tertulis pada tipenya. Kontrak tersebut dinyatakan pada dokumentasi, dan konvensi komunitas Rust memakai seksi # Safety untuk menuliskannya, berisi syarat yang harus dipenuhi pemanggil beserta akibat pelanggarannya. Pemanggilan fungsi semacam itu menuntut blok unsafe sehingga setiap titik berisiko tampak jelas pada kode.

rust fungsi-unsafe.rs
/// Mengambil elemen tanpa pemeriksaan batas.
///
/// # Safety
///
/// `indeks` harus lebih kecil daripada `data.len()`. Pemanggil yang
/// melanggar batas ini menyebabkan *undefined behavior*.
unsafe fn elemen_pada(data: &[i32], indeks: usize) -> i32 {
    *data.get_unchecked(indeks)
}

/// Pembungkus aman: pemeriksaan batas berpindah ke sini, pemanggil bebas unsafe.
fn coba_elemen(data: &[i32], indeks: usize) -> Option<i32> {
    if indeks < data.len() {
        Some(unsafe { elemen_pada(data, indeks) })
    } else {
        None
    }
}

fn main() {
    let data = [10, 24, 100];

    // pemanggilan langsung menuntut blok unsafe karena kontrak belum dijaga
    let pertama = unsafe { elemen_pada(&data, 0) };
    println!("elemen ke-0 langsung: {}", pertama);

    // pemanggilan lewat pembungkus aman tidak menuntut apa pun
    println!("elemen ke-1 aman   : {:?}", coba_elemen(&data, 1));
    println!("elemen ke-9 aman   : {:?}", coba_elemen(&data, 9));
}
text Keluaran
elemen ke-0 langsung: 10
elemen ke-1 aman   : Some(24)
elemen ke-9 aman   : None

Fungsi elemen_pada membungkus get_unchecked dari pustaka standar, yang melewatkan pemeriksaan batas demi kecepatan dan karenanya bertanda unsafe. Pemanggilan langsung pada main memang tepat secara kontrak karena indeks 0 pasti berada di dalam data, dan blok unsafe menandai titik tempat kebenaran itu dipegang pemrogram. Fungsi coba_elemen memindahkan pemeriksaan batas ke kode aman berupa pembandingan indeks < data.len(), sehingga kontrak elemen_pada selalu terpenuhi di satu tempat dan seluruh pemanggilnya menerima Option tanpa perlu menyentuh unsafe. Diagram berikut merangkum pembagian tanggung jawab pada pola ini.

pembungkus aman menempatkan jaminan pemrogram di dalamkode pemanggiltanpa blok unsafepembungkus amanpemeriksaan batas di kode amanblok unsafekontrak dipenuhi pemrogramdijamin kompilatordijamin pemrogram
Pembagian jaminan pada pola pembungkus aman

Kontrak soundness#

Soundness adalah jaminan bahwa program yang ditulis sepenuhnya dengan kode aman tidak mungkin mengalami undefined behavior pada jalur eksekusi mana pun. Jaminan tersebut memiliki dua sisi. Kompilator menegakkan sisi pertama untuk seluruh kode aman, sedangkan sisi kedua menjadi tanggung jawab penulis kode unsafe, yaitu memastikan setiap invarian yang dijanjikan pada blok dan fungsi unsafe benar-benar dipenuhi. Pelanggaran pada sisi kedua tidak selalu tampak sebagai kegagalan. Dereferensi pointer yang sudah melewati umur pemiliknya, pembacaan get_unchecked di luar batas, atau benturan tulis pada static mut dapat lolos pada uji terbatas karena memori bekas belum tertimpa. Kerusakan baru muncul pada pola pemanggilan yang berbeda.

Sifat senyap itulah yang membuat undefined behavior berbahaya secara struktural. Pengoptimal kode mengasumsikan UB tidak pernah terjadi, sehingga program yang mengandung UB dapat berubah perilaku setelah versi kompilator naik atau setelah kode di sekitarnya diedit, tanpa satu baris pun pada bagian yang bermasalah disentuh. Praktik yang sehat menjaga area unsafe sekecil mungkin, menyatakan kontraknya secara tertulis, dan menguji blok itu dengan Miri, yakni penerjemah yang mendeteksi banyak kelas UB saat program berjalan.

Kesalahan umum#

Kesalahan pertama adalah membungkus seluruh badan fungsi ke dalam satu blok unsafe yang lebar. Logika biasa yang terletak di dalam blok itu kehilangan makna pemeriksaannya tanpa manfaat apa pun, dan pembaca tidak lagi dapat membedakan operasi yang memang berisiko. Perkecil blok hanya pada ekspresi yang benar-benar membutuhkan kemampuan unsafe, lalu biarkan pemeriksaan dan logika lain berjalan pada kode aman.

Kesalahan kedua adalah menulis unsafe fn tanpa dokumentasi kontrak. Pemanggil tidak memiliki cara mengetahui syarat yang harus dijaganya, sehingga pelanggaran terjadi bukan karena kelalaian melainkan karena ketiadaan informasi. Tulis seksi # Safety yang menyebutkan setiap syarat dan akibat pelanggarannya sebelum kode unsafe diserahkan ke pemanggil lain.

Kesalahan ketiga adalah menyimpan pointer mentah melewati umur pemilik datanya. Pointer tidak ikut serta dalam aturan ownership. Ketika data dijatuhkan, tidak ada mekanisme yang ikut mematikan pointer, sehingga pemakaian berikutnya membaca memori yang sudah bebas. Gunakan pointer hanya dalam lingkup yang secara struktural menjamin pemiliknya masih hidup, dan pindahkan kepemilikan data bila nilai tersebut harus bertahan lebih lama.

Ringkasan#

  • Blok unsafe membuka lima kemampuan spesifik dan bukan mematikan borrow checker, sehingga aturan pinjaman atas referensi biasa tetap berlaku di dalamnya.
  • Pointer mentah dapat dibuat dengan aman, sedangkan dereferensinya menuntut blok unsafe karena kompilator tidak lagi melacak aliasing maupun keabsahan alamatnya.
  • Akses static mut dan pemanggilan fungsi unsafe menandai titik jaminan berpindah ke pemrogram, dengan benturan tulis dan pelanggaran kontrak sebagai risikonya.
  • Pola pembungkus aman menempatkan pemeriksaan pada kode aman dan kode unsafe di dalam, sehingga seluruh pemanggil tetap dilindungi dan blok berisiko terkonsentrasi di satu tempat.

Referensi#