Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Nyimpen Teks Berkode UTF-8 pake Strings

Kita udah pernah ngebahas strings di Bab 4, tapi sekarang kita bakal bahas lebih mendalam. Rustaceans (programmer Rust) baru biasanya sering mentok di strings karena kombinasi tiga alasan: kecenderungan Rust buat nge-ekspos kemungkinan error, strings yang ternyata adalah struktur data yang lebih ribet daripada yang dikira banyak programmer, dan UTF-8. Faktor-faktor ini kegabung dengan cara yang mungkin kerasa susah kalau kita asalnya dari bahasa pemrograman lain.

Kita ngebahas strings di dalem konteks koleksi (collections) karena strings diimplementasikan sebagai koleksi dari byte-byte, ditambah beberapa method buat nyediain fungsionalitas yang berguna pas byte-byte itu diterjemahin (interpreted) sebagai teks. Di bagian ini, kita bakal bahas operasi-operasi pada String yang dipunyai sama setiap tipe koleksi, kayak bikin (creating), ngubah (updating), sama ngebaca (reading). Kita juga bakal bahas gimana String itu beda dari koleksi lainnya, yaitu gimana proses indexing ke dalem String itu dibikin ribet karena perbedaan antara gimana manusia sama komputer nerjemahin data String.

Apa Itu String?

Pertama-tama kita bakal nentuin apa yang kita maksud dengan istilah string. Rust cuma punya satu tipe string di dalem bahasa intinya (core language), yaitu string slice str yang biasanya keliatan dalam bentuk referensi &str. Di Bab 4, kita udah ngebahas soal string slices, yang merupakan referensi ke sejumlah data string berkode UTF-8 yang disimpan di tempat lain. Literal string, misalnya, disimpan di dalem binary program kita dan makanya mereka itu adalah string slices.

Tipe String, yang disediain sama standard library Rust bukannya dikodein langsung ke bahasa intinya, adalah tipe string berkode UTF-8 yang bisa nambah ukurannya (growable), mutable, dan dimiliki (owned). Pas Rustaceans nyebut “strings” di Rust, mereka mungkin maksudnya tipe String atau tipe string slice &str, bukan cuma salah satunya doang. Walaupun bagian ini sebagian besar bahas soal String, kedua tipe ini sering sekali dipake di standard library Rust, dan baik String maupun string slices itu sama-sama berkode UTF-8.

Bikin String Baru

Banyak operasi yang sama yang tersedia buat Vec<T> itu tersedia buat String juga karena String sebenernya diimplementasikan sebagai bungkus (wrapper) dari sebuah vector berisi byte-byte dengan beberapa jaminan (guarantees), batasan, dan kemampuan tambahan. Salah satu contoh fungsi yang cara kerjanya sama buat Vec<T> sama String adalah fungsi new buat bikin instance baru, kayak yang ditunjukin di Listing 8-11.

fn main() {
    let mut s = String::new();
}
Listing 8-11: Bikin String baru yang kosong

Baris ini bikin string baru yang kosong namanya s, yang nantinya bisa kita isiin data. Biasanya, kita punya data awal yang mau kita pake buat mulai string-nya. Buat kasus itu, kita pake method to_string, yang tersedia di tipe apa pun yang mengimplementasikan trait Display, kayak literal string. Listing 8-12 nunjukin dua contohnya.

fn main() {
    let data = "initial contents";

    let s = data.to_string();

    // The method also works on a literal directly:
    let s = "initial contents".to_string();
}
Listing 8-12: Pake method to_string buat bikin String dari literal string

Kode ini bikin sebuah string yang isinya teks initial contents.

Kita juga bisa pake fungsi String::from buat bikin String dari literal string. Kode di Listing 8-13 itu ekuivalen (sama) sama kode di Listing 8-12 yang pake to_string.

fn main() {
    let s = String::from("initial contents");
}
Listing 8-13: Pake fungsi String::from buat bikin String dari literal string

Karena strings dipake buat macem-macem hal, kita bisa pake banyak API generik yang beda-beda buat strings, ngasih kita sangat banyak opsi. Beberapa mungkin keliatannya berlebihan (redundant), tapi semuanya punya tempatnya masing-masing! Di kasus ini, String::from sama to_string ngelakuin hal yang persis sama, jadi milih yang mana itu cuma masalah gaya (style) dan readability (keterbacaan) aja.

Inget ya kalau strings itu berkode UTF-8, jadi kita bisa masukin data apa pun yang di-encode dengan bener ke dalemnya, kayak yang ditunjukin di Listing 8-14.

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}
Listing 8-14: Nyimpen sapaan dalam berbagai bahasa di dalem strings

Semua ini adalah nilai String yang valid.

Ngubah String

Sebuah String bisa nambah ukurannya dan isinya bisa berubah, sama kayak isi dari Vec<T>, kalau kita nge-push (masukin) lebih banyak data ke dalemnya. Selain itu, kita bisa pake operator + atau macro format! buat ngegabungin (concatenate) nilai-nilai String dengan gampang.

Nambahin Teks ke String pake push_str sama push

Kita bisa nambah ukuran String dengan pake method push_str buat nambahin string slice di akhirnya, kayak yang ditunjukin di Listing 8-15.

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}
Listing 8-15: Nambahin string slice ke dalem String pake method push_str

Setelah dua baris ini, s bakal isinya foobar. Method push_str nerima string slice karena kita nggak selamanya mau ngambil ownership dari parameternya. Misalnya, di kode di Listing 8-16, kita mau tetep bisa pake s2 setelah nambahin isinya ke s1.

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {s2}");
}
Listing 8-16: Pake string slice setelah nambahin isinya ke dalem String

Kalau method push_str ngambil ownership dari s2, kita nggak bakal bisa nyetak nilainya di baris terakhir. Tapi, kode ini jalan sesuai yang kita mau kok!

Method push nerima satu karakter sebagai parameter dan nambahin itu ke String. Listing 8-17 nambahin huruf l ke dalem String pake method push.

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}
Listing 8-17: Nambahin satu karakter ke nilai String pake push

Hasilnya, s bakal isinya lol.

Penggabungan (Concatenation) pake Operator + atau Macro format!

Sering kali, kita mau ngegabungin dua string yang udah ada. Salah satu caranya adalah pake operator +, kayak yang ditunjukin di Listing 8-18.

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used
}
Listing 8-18: Pake operator + buat ngegabungin dua nilai String jadi nilai String baru

String s3 bakal isinya Hello, world!. Alasan kenapa s1 udah nggak valid lagi setelah penjumlahannya, dan alasan kenapa kita pake referensi ke s2, ada hubungannya sama signature dari method yang dipanggil pas kita pake operator +. Operator + pake method add, yang signature-nya kira-kira kayak gini:

fn add(self, s: &str) -> String {

Di standard library, kita bakal liat add didefinisikan pake generik (generics) sama associated types. Di sini, kita udah ngegantiinnya pake tipe konkret, yang merupakan apa yang terjadi pas kita manggil method ini pake nilai String. Kita bakal bahas generik di Bab 10. Signature ini ngasih kita petunjuk yang kita butuhin buat mahamin bagian-bagian tricky dari operator +.

Pertama, s2 punya &, yang artinya kita nambahin referensi dari string kedua ke string pertama. Ini gara-gara parameter s di fungsi add: kita cuma bisa nambahin &str ke dalem String; kita nggak bisa nambahin dua nilai String bareng-bareng. Tapi tunggu—tipe dari &s2 itu &String, bukan &str, kayak yang ditentuin di parameter kedua dari add. Terus kenapa Listing 8-18 bisa di-compile?

Alasan kenapa kita bisa pake &s2 di pemanggilan add adalah karena compiler bisa nge-coerce (maksa/ngubah) argumen &String jadi &str. Pas kita manggil method add, Rust pake yang namanya deref coercion, yang di sini ngerubah &s2 jadi &s2[..]. Kita bakal bahas deref coercion lebih dalem di Bab 15. Karena add nggak ngambil ownership dari parameter s, s2 bakal tetep jadi String yang valid setelah operasi ini.

Kedua, kita bisa liat di signature-nya kalau add ngambil ownership dari self karena self nggak punya &. Ini artinya s1 di Listing 8-18 bakal di-move ke dalem pemanggilan add dan nggak bakal valid lagi setelahnya. Jadi, walaupun let s3 = s1 + &s2; keliatannya kayak bakal ngopi kedua string dan bikin yang baru, statement ini sebenernya ngambil ownership dari s1, nambahin (append) salinan isi dari s2 ke dalemnya, terus balikin ownership dari hasilnya. Dengan kata lain, keliatannya dia bikin banyak salinan, tapi sebenernya nggak; implementasinya jauh lebih efisien daripada ngopi.

Kalau kita butuh ngegabungin banyak strings, perilaku dari operator + bakal jadi ribet sekali:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = s1 + "-" + &s2 + "-" + &s3;
}

Di titik ini, s bakal jadi tic-tac-toe. Dengan semua karakter + sama ", susah buat liat apa yang sebenernya lagi terjadi. Buat ngegabungin strings dengan cara yang lebih kompleks, kita bisa pake macro format! sebagai gantinya:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = format!("{s1}-{s2}-{s3}");
}

Kode ini juga nge-set s jadi tic-tac-toe. Macro format! cara kerjanya mirip println!, tapi bukannya nyetak output ke layar, dia balikin String yang isinya teks hasil formatnya. Versi kode yang pake format! itu jauh lebih gampang dibaca, dan kode yang dihasilin sama macro format! pake referensi jadi pemanggilan ini nggak bakal ngambil ownership dari parameter mana pun.

Indexing ke dalem Strings

Di banyak bahasa pemrograman lain, akses tiap karakter individu di dalem string dengan ngerujuk ke indeks mereka itu adalah operasi yang valid dan umum sekali. Tapi, kalau kita nyoba akses bagian dari String pake sintaks indexing di Rust, kita bakal dapet error. Coba liat kode yang nggak valid di Listing 8-19.

fn main() {
    let s1 = String::from("hi");
    let h = s1[0];
}
Listing 8-19: Nyoba pake sintaks indexing ke sebuah String

Kode ini bakal ngasilin error berikut:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src/main.rs:3:16
  |
3 |     let h = s1[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()`
          for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
  = help: the following other types implement trait `SliceIndex<T>`:
            `usize` implements `SliceIndex<ByteStr>`
            `usize` implements `SliceIndex<[T]>`
  = note: required for `String` to implement `Index<{integer}>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error

Error dan catatannya nyeritain ceritanya: strings di Rust nggak support indexing. Tapi kenapa nggak? Buat ngejawab pertanyaan itu, kita harus bahas gimana Rust nyimpen strings di memori.

Representasi Internal

Sebuah String adalah bungkus (wrapper) buat Vec<u8>. Yuk kita liat beberapa contoh strings berkode UTF-8 kita dari Listing 8-14. Pertama, yang ini:

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

Di kasus ini, len bakal bernilai 4, yang artinya vector yang nyimpen string "Hola" itu panjangnya 4 byte. Tiap huruf ini butuh satu byte pas di-encode dalam UTF-8. Tapi, baris berikut ini mungkin bikin kita kaget (perhatiin ya kalau string ini dimulai pake huruf kapital Cyrillic Ze, bukan angka 3):

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

Kalau kita ditanya seberapa panjang string ini, kita mungkin bakal jawab 12. Nyatanya, jawaban Rust adalah 24: itu adalah jumlah byte yang dibutuhin buat nge- encode “Здравствуйте” dalam UTF-8, karena tiap nilai scalar Unicode di dalem string itu butuh memori sebesar 2 byte. Karena itu, sebuah indeks ke dalem byte-byte dari string nggak bakal selalu sejalan sama nilai scalar Unicode yang valid. Buat ngedemonstrasiin ini, coba liat kode Rust yang nggak valid ini:

let hello = "Здравствуйте";
let answer = &hello[0];

Kita udah tau kalau answer nggak bakal isinya З, yaitu huruf pertamanya. Pas di-encode di UTF-8, byte pertama dari З itu 208 dan yang kedua itu 151, jadi kayaknya answer harusnya sebenernya 208, tapi 208 itu bukan karakter yang valid kalau sendirian. Balikin nilai 208 kemungkinannya bukan apa yang dipengenin user pas mereka minta huruf pertama dari string ini; tapi, cuma itu data yang dipunyai Rust di indeks byte 0. User biasanya nggak mau nilai byte-nya yang dibalikin, walaupun string-nya cuma isinya huruf Latin doang: kalau &"hi"[0] adalah kode valid yang balikin nilai byte-nya, dia bakal balikin 104, bukan h.

Jadi jawabannya adalah buat ngehindarin balikin nilai yang nggak disangka-sangka dan nyebabin bug yang mungkin nggak langsung ketahuan, Rust milih buat sama sekali nggak nge-compile kode ini dan nyegah kesalahpahaman dari awal di proses development (pengembangan).

Bytes dan Nilai Scalar dan Grapheme Clusters! Waduh!

Poin lainnya soal UTF-8 adalah sebenernya ada tiga cara yang relevan buat ngeliat strings dari sudut pandang Rust: sebagai bytes (byte-byte), nilai scalar (scalar values), sama grapheme clusters (hal yang paling mendekati sama apa yang bakal kita sebut letters atau huruf).

Kalau kita liat kata Hindi “नमस्ते” yang ditulis dalam aksara Devanagari, dia disimpan sebagai vector dari nilai u8 yang keliatannya kayak gini:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

Itu ada 18 byte dan ini adalah gimana komputer akhirnya nyimpen data ini. Kalau kita liat mereka sebagai nilai scalar Unicode, yang mana itu adalah representasi tipe char di Rust, byte-byte itu bakal keliatan kayak gini:

['न', 'म', 'स', '्', 'त', 'े']

Ada enam nilai char di sini, tapi yang keempat sama keenam itu bukan huruf: mereka itu diacritics (tanda baca tambahan) yang nggak ada artinya kalau berdiri sendiri. Terakhir, kalau kita liat mereka sebagai grapheme clusters, kita bakal dapet apa yang bakal disebut orang sebagai empat huruf yang ngebentuk kata Hindi tersebut:

["न", "म", "स्", "ते"]

Rust nyediain cara beda-beda buat nerjemahin (interpreting) data string mentah yang disimpan komputer biar tiap program bisa milih terjemahan yang dia butuhin, nggak peduli apa bahasa manusia dari data tersebut.

Alasan terakhir kenapa Rust nggak ngebolehin kita nge-index ke dalem String buat dapet sebuah karakter adalah karena operasi indexing diharapkan bakal selalu butuh waktu konstan (O(1)). Tapi nggak mungkin buat ngejamin performa itu kalo pake String, karena Rust harus jalanin (walk through) isinya mulai dari awal sampe indeks tersebut buat nentuin berapa banyak karakter valid yang ada di sana.

Slicing Strings

Indexing ke dalem string itu sering kali adalah ide yang jelek karena nggak jelas tipe return apa yang seharusnya dihasilin dari operasi indexing string itu: apakah nilai byte, karakter, grapheme cluster, atau sebuah string slice. Jadi, kalau kita bener-bener butuh pake indeks buat bikin string slice, Rust minta kita buat lebih spesifik.

Bukannya indexing pake [] sama satu angka doang, kita bisa pake [] bareng range (rentang) buat bikin string slice yang isinya byte-byte tertentu:

#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

let s = &hello[0..4];
}

Di sini, s bakal jadi &str yang isinya empat byte pertama dari string tersebut. Tadi, kita udah bilang kalau tiap karakter ini ukurannya dua byte, yang artinya s bakal jadi Зд.

Kalau kita nyoba nge-slice cuma sebagian dari byte-byte punya satu karakter, misalnya pake &hello[0..1], Rust bakal panic pas runtime sama kayak kalau ada indeks yang nggak valid yang diakses di vector:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/collections`

thread 'main' panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Kita harus hati-hati pas bikin string slices pake range, karena ngelakuin itu bisa bikin program kita crash.

Methods buat Iterasi Lewat Strings

Cara terbaik buat ngoperasiin potongan-potongan dari strings adalah dengan jelas (explicit) nentuin apakah kita mau karakter atau byte-nya. Buat dapet nilai scalar Unicode individu, pake method chars. Manggil chars di “Зд” bakal misahin dan balikin dua nilai bertipe char, dan kita bisa iterasi hasilnya buat akses tiap elemennya:

#![allow(unused)]
fn main() {
for c in "Зд".chars() {
    println!("{c}");
}
}

Kode ini bakal nyetak output berikut:

З
д

Alternatifnya, method bytes balikin tiap byte mentahnya (raw byte), yang mungkin cocok buat domain (ranah) kita:

#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
    println!("{b}");
}
}

Kode ini bakal nyetak empat byte yang ngebentuk string ini:

208
151
208
180

Tapi pastiin buat inget kalau nilai scalar Unicode yang valid itu mungkin disusun dari lebih dari satu byte.

Dapetin grapheme clusters dari strings, kayak pas pake aksara Devanagari tadi, itu kompleks, jadi fungsionalitas ini nggak disediain sama standard library. Ada crates yang tersedia di crates.io kalau ini fungsionalitas yang kita butuhin.

Strings Nggak Sesimpel Itu

Sebagai ringkasan, strings itu ribet (complicated). Bahasa pemrograman yang beda milih cara yang beda-beda juga soal gimana nyajiin keribetan ini ke programmer. Rust milih buat ngebikin cara nanganin data String dengan bener sebagai perilaku default buat semua program Rust, yang artinya programmer harus mikir lebih dalem buat nanganin data UTF-8 di awal. Trade-off (pertukaran) ini nge-ekspos lebih banyak keribetan strings daripada yang keliatan di bahasa pemrograman lain, tapi ini nyegah kita dari harus nanganin error yang ngelibatin karakter non-ASCII di masa depan pas siklus pengembangan (development life cycle).

Kabar baiknya adalah standard library nawarin sangat banyak fungsionalitas yang dibangun di atas tipe String sama &str buat ngebantu kita nanganin situasi kompleks ini dengan bener. Pastiin buat cek dokumentasi buat method-method berguna kayak contains buat nyari sesuatu di dalem string dan replace buat nggantiin bagian dari string pake string lainnya.

Yuk kita beralih ke sesuatu yang sedikit kurang ribet: hash maps!