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();
}
String baru yang kosongBaris 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();
}
to_string buat bikin String dari literal stringKode 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");
}
String::from buat bikin String dari literal stringKarena 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");
}
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");
}
String pake method push_strSetelah 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}");
}
StringKalau 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');
}
String pake pushHasilnya, 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
}
+ buat ngegabungin dua nilai String jadi nilai String baruString 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];
}
StringKode 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!