String vs &str

Teks milik sendiri dibanding teks dipinjam serta kenyataan UTF-8.

Daftar isi

Topik ini membahas dua tipe teks Rust, yaitu String yang memiliki penyangga byte milik sendiri dan &str yang hanya meminjam tampilan atas teks. Pembahasan mencakup pembuatan dan penggabungan String, tata memori UTF-8 yang membuat panjang teks dihitung dalam byte, alasan teks tidak dapat diindeks satu karakter, pemilihan tipe parameter di antara keduanya, serta validasi barisan byte yang tidak sah. Setelah membaca topik ini, tipe teks dapat dipilih sesuai kepemilikan datanya, dan perilaku teks non-ASCII tidak lagi mengejutkan.

String sebagai teks milik sendiri#

Sebuah String adalah pembungkus atas Vec<u8> yang isinya dijamin berupa barisan byte UTF-8 yang sah, sehingga teks bertipe ini dapat bertambah, dipotong, dan disimpan selama program membutuhkan. Nilainya dibuat dari literal dengan String::from atau dengan metode to_string pada &str, kemudian diisi lebih lanjut dengan push_str yang menambah potongan teks dan push yang menambah satu karakter. Operator + menggabungkan dua teks dengan satu catatan kepemilikan: operan kirinya dipindahkan ke hasil, sedangkan operan kanannya hanya dipinjam lewat referensi. Makro format! menggabungkan banyak bagian tanpa memindahkan operan apa pun karena hasilnya dibangun dari nol.

rust bangun-teks.rs
fn main() {
    let mut teks = String::from("suhu");
    println!("awal     : {teks}");

    teks.push_str(" ruangan");
    println!("push_str : {teks}");

    teks.push('!');
    println!("push     : {teks}");

    let satuan = String::from(" naik");
    let gabung = teks + &satuan; // teks dipindahkan ke hasil gabung
    println!("gabung   : {gabung}");

    let laporan = format!("{gabung} (pagi ini)");
    println!("format!  : {laporan}");
}
text Keluaran
awal     : suhu
push_str : suhu ruangan
push     : suhu ruangan!
gabung   : suhu ruangan! naik
format!  : suhu ruangan! naik (pagi ini)

Keluaran tersebut memperlihatkan pertumbuhan teks tahap demi tahap. Setelah baris gabung, variabel teks tidak dapat dipakai lagi karena isinya telah dipindahkan oleh operator +, sedangkan satuan tetap utuh karena hanya dipinjam melalui &satuan. Makro format! menangkap gabung hanya untuk dibaca, sehingga variabel itu tetap hidup sesudahnya. Untuk penggabungan lebih dari dua bagian, format! umumnya lebih mudah dibaca daripada rantai + yang menumpuk pemindahan, dan makro ini juga menerima angka serta tipe lain yang mengimplementasikan trait Display.

String adalah Vec berisi UTF-8#

Isi String adalah barisan byte UTF-8, bukan barisan karakter. Encoding UTF-8 memakai satu byte untuk karakter ASCII seperti huruf dan angka, tetapi karakter di luarnya menempati dua sampai empat byte. Akibatnya, metode len() melaporkan jumlah byte, sedangkan jumlah karakter dihitung dengan chars().count(), dan kedua angka itu hanya sama apabila seluruh karakter teksnya ASCII. Tanda derajat ° pada teks 23°C adalah contoh yang lazim pada data sensor: karakter itu menempati dua byte, sehingga teksnya panjang lima byte meskipun jumlah karakternya empat.

rust ukuran-teks.rs
fn main() {
    let polos = "23C";
    println!("\"{}\": {} byte, {} karakter", polos, polos.len(), polos.chars().count());

    let derajat = "23°C";
    println!("\"{}\": {} byte, {} karakter", derajat, derajat.len(), derajat.chars().count());
}
text Keluaran
"23C": 3 byte, 3 karakter
"23°C": 5 byte, 4 karakter

Diagram berikut memetakan teks 23°C ke penyangga byte di heap. Struktur kendali String di stack berupa penunjuk, panjang, dan kapasitas, sama seperti pada Vec, dan tanda derajat menempati dua sel byte yang berdampingan.

String "23°C" sebagai penyangga byte UTF-8Stackptrlen = 5cap = 5Heap2 byte505119417667'2''3''°''C'karakter '°' menempati dua byte (194 dan 176), sehingga len() melaporkan 5sementara chars().count() melaporkan 4 karakter
Tata memori String berisi teks 23°C: karakter derajat menempati dua byte

Karakter tidak diakses dengan indeks#

Kebiasaan mengambil karakter pertama dengan teks[0] tidak berlaku pada teks Rust, dan penolakannya terjadi saat kompilasi. Satu byte pada posisi tertentu belum tentu satu karakter yang utuh, dan satu karakter dapat menempati beberapa byte, sehingga bentuk akses per karakter tidak memiliki makna yang tunggal. Bahasa ini memilih menolak pengindeksan bilangan pada teks sama sekali daripada menebak makna mana yang diinginkan. Penolakan tersebut dapat diperiksa langsung dengan program kecil berikut.

bash
export PATH="$HOME/.cargo/bin:$PATH"
# buat program yang mencoba mengakses satu karakter dengan indeks bilangan
printf 'fn main() {\n    let teks = String::from("suhu");\n    println!("{}", teks[0]);\n}\n' > salah-indeks.rs
# kompilasi ditolak karena teks tidak dapat diindeks oleh bilangan
rustc --edition=2021 salah-indeks.rs 2>&1 | sed -n '1,8p'
text Keluaran
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> salah-indeks.rs:3:25
  |
3 |     println!("{}", teks[0]);
  |                         ^ string indices are ranges of `usize`
  |
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`
  = note: you can use `.chars().nth()` or `.bytes().nth()`

Error E0277 menyatakan bahwa tipe str tidak dapat diindeks oleh bilangan, dan catatannya menyarankan chars().nth() atau bytes().nth() sebagai pengganti. Untuk membaca isi teks, dua iterator tersedia: chars() menghasilkan karakter demi karakter bertipe char, sedangkan bytes() menghasilkan byte demi byte bertipe u8. Program berikut memakai keduanya pada teks berisi tanda derajat.

rust baca-teks.rs
fn main() {
    let teks = "23°C";

    for (i, c) in teks.chars().enumerate() {
        println!("karakter {}: {}", i, c);
    }

    let byte: Vec<u8> = teks.bytes().collect();
    println!("byte : {:?}", byte);

    let ukuran: Vec<usize> = teks.chars().map(|c| c.len_utf8()).collect();
    println!("byte per karakter : {:?}", ukuran);
}
text Keluaran
karakter 0: 2
karakter 1: 3
karakter 2: °
karakter 3: C
byte : [50, 51, 194, 176, 67]
byte per karakter : [1, 1, 2, 1]

Iterasi chars() menghasilkan empat karakter dengan ° utuh pada indeks dua, sedangkan iterasi bytes() menghasilkan lima byte. Pasangan 194 dan 176 adalah bentang dua byte karakter °, dan metode len_utf8() merangkum hal yang sama dalam angka. Angka pada keluaran pertama adalah indeks karakter, bukan indeks byte, dan indeks byte tempat karakter ° bermula juga bernilai dua karena dua karakter sebelumnya masing-masing menempati satu byte. Pemrosesan teks yang sadar karakter selalu berjalan lewat chars(), sedangkan bytes() dipakai untuk protokol biner atau teks yang dijamin ASCII.

&str: teks yang dipinjam#

&str adalah irisan teks yang hanya meminjam isinya tanpa memiliki penyangga, seperti &[T] terhadap deret data. Literal teks bertipe &str dan menunjuk data yang tertanam pada berkas biner program, sedangkan &String dapat dipakai sebagai &str melalui koersi deref. Fungsi yang menerima &str karenanya melayani literal, irisan, maupun referensi ke String sekaligus, dan bentuk parameter inilah yang menjadi kebiasaan umum pada API Rust. Kepemilikan String cukup disimpan pada satu tempat penyimpanan data, misalnya field struct atau variabel pemilik utama.

rust sapa.rs
fn sapa(nama: &str) -> String {
    format!("Selamat datang, {nama}!")
}

fn main() {
    let pengguna = String::from("Citra");
    println!("{}", sapa(&pengguna));
    println!("{}", sapa("Dewi"));
}
text Keluaran
Selamat datang, Citra!
Selamat datang, Dewi!

Fungsi sapa menerima nama sebagai &str dan mengembalikan String baru yang dibangun oleh format!. Pemanggilan pertama mengirim &pengguna yang berkoersi dari &String menjadi &str, sedangkan pemanggilan kedua mengirim literal bertipe &str tanpa perantara. Arah pemilihannya sederhana: terima &str pada parameter apabila isi fungsi cukup membaca teks, dan kembalikan String apabila hasil fungsi memang teks baru yang harus dimiliki pemanggilnya. Penyimpanan jangka panjang, misalnya field struct, memakai String agar data tidak bergantung pada pemilik lain.

Validasi UTF-8#

Jaminan bahwa String selalu berisi UTF-8 yang sah ditegakkan pada dua gerbang. Gerbang pertama adalah literal pada kode sumber: penulisan \xC3\x28 pada literal teks ditolak saat kompilasi karena escape byte pada rentang di atas 0x7F hanya sah pada literal byte, bukan literal teks. Gerbang kedua adalah data dari luar program, misalnya isi berkas atau hasil pembacaan jaringan, yang baru dapat diperiksa saat program berjalan melalui String::from_utf8. Fungsi tersebut menerima Vec<u8> dan mengembalikan Result: varian Ok membawa String yang sah, sedangkan varian Err membawa error yang menunjuk byte penyebabnya.

bash
export PATH="$HOME/.cargo/bin:$PATH"
# buat program berisi barisan byte yang bukan UTF-8 yang sah
printf 'fn main() {\n    let teks = String::from("\\xC3\\x28");\n    println!("{teks}");\n}\n' > salah-utf8.rs
# kompilasi ditolak karena literal teks wajib berupa UTF-8 yang sah
rustc --edition=2021 salah-utf8.rs 2>&1 | sed -n '1,8p'
text Keluaran
error: out of range hex escape
 --> salah-utf8.rs:2:30
  |
2 |     let teks = String::from("\xC3\x28");
  |                              ^^^^ must be a character in the range [\x00-\x7f]

error: aborting due to 1 previous error

Escape \xC3 ditolak karena harus berada pada rentang \x00 sampai \x7f, dan rentang itu memang dibatasi agar literal teks selalu menjadi UTF-8 yang sah. Karakter non-ASCII ditulis langsung seperti °, atau lewat escape Unicode seperti \u{00B0} untuk tanda derajat. Pemeriksaan pada data yang datang dari luar program memakai String::from_utf8 sebagai berikut.

rust validasi.rs
fn main() {
    let dari_byte = String::from_utf8(vec![0x32, 0x33]);
    println!("{:?}", dari_byte);

    let rusak = String::from_utf8(vec![0xC3, 0x28]);
    match rusak {
        Ok(valid) => println!("valid : {valid}"),
        Err(_) => println!("byte tidak valid ditolak"),
    }

    println!("versi lossy : {}", String::from_utf8_lossy(&[0xC3, 0x28]));
}
text Keluaran
Ok("23")
byte tidak valid ditolak
versi lossy : �(

Dua byte 0x32 dan 0x33 membentuk teks 23 yang sah sehingga hasilnya Ok("23"). Barisan 0xC3 dan 0x28 bukan UTF-8 yang sah, sehingga from_utf8 mengembalikan Err yang ditangani cabang kedua. Varian from_utf8_lossy tetap menghasilkan String dengan mengganti bagian yang rusak dengan karakter pengganti, dan keluarannya memperlihatkan karakter pengganti tersebut diikuti tanda kurung yang lolos pemeriksaan. Pilihan di antara keduanya adalah pilihan antara menolak data yang rusak dan menyelamatkan sebagian isinya.

Kesalahan umum#

Menghitung batas tampilan dengan jumlah karakter. Perhitungan yang menganggap satu karakter sebagai satu byte menghasilkan batas byte yang salah untuk teks non-ASCII, dan irisan dengan batas seperti itu menghentikan program karena memotong tengah karakter. Hitung batas melalui char_indices() atau find() yang bekerja pada karakter, atau proses teks lewat chars() tanpa irisan sama sekali.

Menggabungkan banyak bagian dengan rantai operator +. Setiap operator + memindahkan operan kirinya, sehingga rantai a + &b + &c menghabiskan a dan menuntut referensi pada sisanya. Makro format! menghasilkan teks yang sama tanpa memindahkan operan mana pun, dengan placeholder yang juga lebih terbaca ketika bagian-bagiannya banyak.

Ringkasan#

  • String adalah Vec<u8> berisi UTF-8 yang sah dengan kepemilikan penuh, sedangkan &str adalah irisan teks yang dipinjam dan mencukupi untuk mayoritas parameter fungsi.
  • len() menghitung byte, chars().count() menghitung karakter, dan keduanya hanya bernilai sama untuk teks yang seluruh karakternya ASCII.
  • Pengindeksan bilangan pada teks ditolak saat kompilasi, sehingga pembacaan isi dikerjakan lewat chars(), bytes(), atau irisan dengan batas byte yang sah.
  • Operator + memindahkan operan kirinya, format! menggabungkan tanpa memindahkan operan, dan String::from_utf8 memeriksa byte dari luar program saat berjalan.

Referensi#