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

Bahasa Pemrograman Rust

oleh Steve Klabnik, Carol Nichols, dan Chris Krycho, beserta kontribusi dari Komunitas Rust

Versi teks ini mengasumsikan kita menggunakan Rust 1.90.0 (rilis 18-09-2025) atau yang lebih baru dengan edition = "2024" di dalam file Cargo.toml pada semua proyek untuk mengonfigurasi mereka agar menggunakan idiom Edisi Rust 2024. Lihat bagian “Instalasi” di Bab 1 untuk instruksi pemasangan atau pembaruan Rust, dan lihat Lampiran E untuk informasi mengenai edisi.

Format HTML tersedia secara online di https://doc.rust-lang.org/stable/book/ dan offline pada instalasi Rust yang dilakukan dengan rustup; jalankan rustup doc --book untuk membukanya.

Beberapa terjemahan komunitas juga tersedia.

Teks ini tersedia dalam format cetak dan ebook dari No Starch Press.

🚨 Ingin pengalaman belajar yang lebih interaktif? Silakan coba versi yang berbeda dari Buku Rust, dengan fitur: kuis, sorotan, visualisasi, dan lainnya: https://rust-book.cs.brown.edu

Kata Pengantar

Bahasa pemrograman Rust sudah menempuh perjalanan panjang dalam beberapa tahun terakhir, mulai dari penciptaan dan inkubasinya oleh komunitas kecil penggemar pemula, hingga menjadi salah satu bahasa pemrograman yang paling dicintai dan banyak dicari di dunia. Menengok ke belakang, rasanya memang tidak terelakkan kalau kekuatan dan janji yang ditawarkan Rust bakal menarik perhatian banyak orang dan mendapatkan pijakan kuat di pemrograman sistem. Namun yang tidak terelakkan adalah pertumbuhan minat dan inovasi global yang merambah melalui komunitas sumber terbuka (open source) dan mengatalisasi adopsi skala luas di berbagai industri.

Saat ini, sangat mudah untuk menunjuk ke fitur-fitur luar biasa yang ditawarkan Rust guna menjelaskan ledakan minat dan adopsi ini. Siapa yang tidak menginginkan keamanan memori, sekaligus performa yang kencang, sekaligus compiler yang ramah, sekaligus peralatan yang hebat, di antara banyak fitur luar biasa lainnya? Bahasa Rust yang kita lihat sekarang menggabungkan penelitian selama bertahun-tahun dalam pemrograman sistem dengan kebijaksanaan praktis dari komunitas yang lincah dan bersemangat. Bahasa ini didesain dengan tujuan dan dibuat dengan cermat, menawarkan alat bagi para pengembang yang mempermudah penulisan kode yang aman, kencang, dan andal.

Namun, apa yang membuat Rust benar-benar istimewa adalah akarnya yang memberikan kuasa kepada kita, sang pengguna, untuk mencapai tujuan kita. Ini adalah bahasa yang ingin kita sukses, dan prinsip pemberian kuasa (empowerment) ini mengalir melalui inti dari komunitas yang membangun, memelihara, dan mengadvokasi bahasa ini. Sejak edisi sebelumnya dari teks definitif ini, Rust telah berkembang lebih jauh menjadi bahasa yang benar-benar global dan terpercaya. Proyek Rust kini didukung secara kuat oleh Rust Foundation, yang juga berinvestasi dalam inisiatif-inisiatif kunci untuk memastikan Rust aman, stabil, dan berkelanjutan.

Edisi The Rust Programming Language ini adalah pemutakhiran komprehensif, mencerminkan evolusi bahasa ini selama bertahun-tahun dan menyediakan informasi baru yang berharga. Namun, ini bukan sekadar panduan sintaks dan pustaka—ini adalah ajakan untuk bergabung dengan komunitas yang menghargai kualitas, performa, dan desain yang matang. Baik kita pengembang berpengalaman yang ingin menjelajahi Rust untuk pertama kalinya atau seorang Rustacean berpengalaman yang ingin mengasah keterampilan kita, edisi ini menawarkan sesuatu untuk semua orang.

Perjalanan Rust adalah perjalanan kolaborasi, pembelajaran, dan iterasi. Pertumbuhan bahasa dan ekosistemnya adalah cerminan langsung dari komunitas yang lincah dan beragam di belakangnya. Kontribusi dari ribuan pengembang, mulai dari desainer inti bahasa hingga kontributor kasual, adalah apa yang membuat Rust menjadi alat yang unik dan kuat. Dengan membaca buku ini, kita tidak sekadar mempelajari bahasa pemrograman baru—kita bergabung dengan sebuah gerakan untuk membuat perangkat lunak menjadi lebih baik, lebih aman, dan lebih menyenangkan untuk dikerjakan.

Selamat datang di komunitas Rust!

— Bec Rumbul, Executive Director of the Rust Foundation

Pengenalan

Catatan: Edisi buku ini sama dengan The Rust Programming Language yang tersedia dalam format cetak dan ebook dari No Starch Press.

Selamat datang di The Rust Programming Language, sebuah buku pengantar tentang Rust. Bahasa pemrograman Rust membantu kita menulis perangkat lunak yang lebih cepat dan andal. Ergonomi tingkat tinggi dan kontrol tingkat rendah sering kali dianggap berlawanan dalam desain bahasa pemrograman; Rust menantang konflik tersebut. Dengan menyeimbangkan kapasitas teknis yang kuat dan pengalaman pengembang yang menyenangkan, Rust memberi kita opsi untuk mengendalikan detail tingkat rendah (seperti penggunaan memori) tanpa semua kerumitan yang biasanya terkait dengan kontrol semacam itu.

Rust untuk Siapa

Rust sangat ideal untuk banyak orang karena berbagai alasan. Mari kita lihat beberapa kelompok yang paling penting.

Tim Pengembang

Rust terbukti menjadi alat yang produktif untuk berkolaborasi di antara tim pengembang yang besar dengan berbagai tingkat pemahaman tentang pemrograman sistem. Kode tingkat rendah rentan terhadap berbagai bug halus, yang di kebanyakan bahasa lain hanya bisa dideteksi setelah melalui pengujian ekstensif dan audit kode yang teliti oleh pengembang berpengalaman. Di Rust, compiler berperan sebagai penjaga gerbang dengan menolak memproses kode yang mengandung bug-bug sulit ini, termasuk bug konkurensi. Dengan bekerja berdampingan dengan compiler, tim bisa fokus menggunakan waktunya untuk logika program daripada sibuk mencari bug.

Rust juga membawa peralatan pengembang kontemporer ke dunia pemrograman sistem:

  • Cargo, manajer dependensi dan alat build bawaan, membuat proses menambah, mengompilasi, dan mengelola dependensi menjadi mudah dan konsisten di seluruh ekosistem Rust.
  • Alat pemformatan rustfmt memastikan gaya penulisan kode yang konsisten di antara para pengembang.
  • Rust Language Server mendukung integrasi Integrated Development Environment (IDE) untuk melengkapi kode (code completion) dan menampilkan pesan error secara langsung.

Dengan menggunakan ini dan alat lainnya di ekosistem Rust, pengembang dapat menjadi lebih produktif ketika menulis kode tingkat sistem.

Pelajar

Rust cocok untuk pelajar dan mereka yang tertarik dalam mempelajari konsep sistem. Melalui Rust, banyak orang yang belajar mengenai topik seperti pengembangan sistem operasi. Komunitas Rust sangat ramah dan senang menjawab pertanyaan dari para pelajar. Melalui upaya-upaya seperti buku ini, tim Rust ingin membuat konsep sistem dapat diakses oleh lebih banyak orang, terutama mereka yang masih baru dalam pemrograman.

Perusahaan

Ratusan perusahaan, besar maupun kecil, menggunakan Rust di lingkungan produksi untuk berbagai macam keperluan, termasuk alat baris perintah, layanan web, peralatan DevOps, perangkat embedded, analisis dan transkoding audio serta video, mata uang kripto, bioinformatika, mesin pencari, aplikasi Internet of Things, machine learning, dan bahkan bagian-bagian utama dari peramban web Firefox.

Pengembang Sumber Terbuka (Open Source)

Rust ditujukan bagi orang-orang yang ingin membangun bahasa pemrograman Rust, komunitas, peralatan pengembang, dan pustaka (libraries). Kami menunggu kontribusi kita pada bahasa Rust.

Mereka yang Mengutamakan Kecepatan dan Stabilitas

Rust ditujukan untuk orang-orang yang mendambakan kecepatan dan stabilitas dalam sebuah bahasa. Dengan kecepatan, maksudnya adalah seberapa cepat kode Rust bisa dijalankan dan seberapa cepat Rust memungkinkan kita menulis program. Pemeriksaan yang dilakukan oleh compiler Rust memastikan stabilitas bahkan ketika ada penambahan fitur atau refactoring. Ini berbeda dengan kode warisan (legacy code) yang rapuh pada bahasa tanpa pemeriksaan seperti ini, yang sering membuat pengembang takut untuk memodifikasinya. Dengan berupaya mencapai zero-cost abstractions—fitur tingkat tinggi yang dikompilasi menjadi kode tingkat rendah secepat kode yang ditulis manual—Rust berusaha agar kode yang aman juga bisa menjadi kode yang cepat.

Bahasa Rust juga berharap bisa mendukung banyak pengguna lainnya; yang disebutkan di sini hanyalah beberapa pemangku kepentingan terbesar. Secara keseluruhan, ambisi terbesar Rust adalah menghapus kompromi yang telah diterima para programmer selama puluhan tahun dengan menyediakan keamanan dan produktivitas, kecepatan dan ergonomi. Cobalah Rust dan lihat apakah pilihan- pilihannya cocok untuk kita.

Buku ini Ditujukan untuk Siapa

Buku ini mengasumsikan bahwa kita sudah pernah menulis kode dalam bahasa pemrograman lain, tetapi tidak mengasumsikan bahasa mana yang kita gunakan. Kami berusaha membuat materi ini dapat diakses secara luas oleh siapa pun dengan berbagai latar belakang pemrograman. Kami tidak banyak membahas tentang apa itu pemrograman atau bagaimana cara berpikir tentangnya. Jika kita benar-benar baru dalam pemrograman, akan lebih baik membaca buku yang secara khusus memberikan pengantar tentang pemrograman.

Bagaimana Cara Menggunakan Buku ini

Secara umum, buku ini mengasumsikan kita membacanya secara berurutan dari depan ke belakang. Bab selanjutnya mendasarkan konsepnya pada bab sebelumnya, dan bab- bab awal mungkin tidak akan menjelaskan secara detail mengenai topik tertentu tetapi akan mengulasnya kembali pada bab selanjutnya.

Kita akan menemukan dua jenis bab dalam buku ini: bab konsep dan bab proyek. Dalam bab konsep, kita akan mempelajari satu aspek dari Rust. Dalam bab proyek, kita akan membangun program kecil bersama-sama, menerapkan apa yang sudah kita pelajari sejauh ini. Bab 2, Bab 12, dan Bab 21 adalah bab proyek; sisanya adalah bab konsep.

Bab 1 menjelaskan bagaimana memasang Rust, bagaimana membuat program “Hello, world!”, dan bagaimana cara menggunakan Cargo, manajer paket dan alat build Rust. Bab 2 adalah pengenalan langsung dalam menulis program di Rust, di mana kita akan membuat permainan tebak-tebakan angka. Di sini, kita membahas konsep pada tingkat yang relatif tinggi, dan bab selanjutnya akan memberikan detail tambahan. Jika kita ingin segera mencoba mempraktikkan koding, Bab 2 adalah tempat yang paling cocok untuk itu. Jika kita tipe pelajar yang sangat teliti yang lebih memilih mempelajari setiap detailnya sebelum berpindah ke topik selanjutnya, mungkin lebih baik kita lewati Bab 2 dan langsung ke Bab 3, yang membahas fitur-fitur Rust yang mirip dengan bahasa pemrograman lainnya; baru kemudian kita kembali ke Bab 2 jika ingin mengerjakan suatu proyek untuk menerapkan detail-detail yang sudah dipelajari.

Di Bab 4, kita akan mempelajari sistem ownership Rust. Bab 5 membahas struct dan method. Bab 6 membahas enum, ekspresi match, serta konstruk kontrol alur if let dan let...else. Kita akan menggunakan struct dan enum untuk membuat tipe kustom.

Di Bab 7, kita akan mempelajari sistem modul Rust dan aturan privasi untuk mengelola kode kita dan API publiknya yang terkait. Bab 8 membahas beberapa struktur data koleksi umum yang tersedia pada pustaka standar, seperti vector, string, dan hash map. Bab 9 menjelajahi filosofi dan teknik penanganan error di Rust.

Bab 10 menggali lebih dalam mengenai generik, trait, dan lifetime, yang akan memberikan kita kemampuan dalam mendefinisikan kode yang diterapkan pada beberapa tipe. Bab 11 adalah semua hal tentang pengujian, di mana bahkan dengan jaminan keamanan dari Rust, pengujian tetap dibutuhkan untuk memastikan logika program kita benar. Di Bab 12, kita akan membangun implementasi sendiri dari sebagian fungsionalitas aplikasi baris perintah grep yang melakukan pencarian teks di dalam berkas. Untuk keperluan ini, kita akan menggunakan banyak konsep yang telah dibahas pada bab-bab sebelumnya.

Bab 13 mengeksplorasi closures dan iterators: fitur-fitur Rust yang berasal dari bahasa pemrograman fungsional. Di Bab 14, kita akan memeriksa Cargo lebih dalam dan berbicara mengenai praktik terbaik untuk membagikan pustaka kita ke orang lain. Bab 15 membahas smart pointers yang disediakan pustaka standar dan trait yang mendukung fungsionalitasnya.

Di Bab 16, kita akan membahas berbagai model pemrograman konkuren dan mempelajari bagaimana Rust membantu kita menulis program di banyak threads tanpa rasa takut. Di Bab 17, kita membangun di atas hal tersebut dengan mengeksplorasi sintaks async dan await di Rust, beserta task, future, dan stream, serta model konkurensi ringan yang mereka mungkinkan.

Bab 18 melihat bagaimana idiom Rust dibandingkan dengan prinsip pemrograman berorientasi objek yang mungkin sudah kita kenal. Bab 19 adalah referensi tentang pola dan pattern matching, yang merupakan cara kuat untuk mengekspresikan ide di seluruh program Rust. Bab 20 berisi beragam topik lanjutan yang menarik, termasuk unsafe Rust, macros, serta pembahasan lebih lanjut tentang lifetime, trait, tipe, fungsi, dan closure.

Di Bab 21, kita akan menyelesaikan sebuah proyek dengan mengimplementasikan web server multithreaded tingkat rendah!

Akhirnya, beberapa lampiran berisi informasi berguna tentang bahasa Rust dalam format yang lebih mirip referensi. Lampiran A membahas kata kunci Rust, Lampiran B membahas operator dan simbol Rust, Lampiran C membahas trait turunan (derivable traits) yang disediakan pustaka standar, Lampiran D membahas beberapa alat pengembangan yang berguna, dan Lampiran E menjelaskan edisi Rust. Pada Lampiran F, kita bisa menemukan terjemahan dari buku ini, dan di Lampiran G kita akan membahas bagaimana Rust dibuat serta apa itu Rust nightly.

Tidak ada cara salah dalam membaca buku ini: jika kita ingin melompat ke bab depan, lakukan saja! Kita mungkin harus melompat kembali ke bab-bab awal jika mengalami kebingungan. Tapi lakukanlah apa pun yang cocok untuk kita.

Bagian penting dari proses pembelajaran Rust adalah mempelajari bagaimana membaca pesan error yang ditampilkan oleh compiler: hal-hal ini akan menuntun kita menuju kode yang berfungsi. Karena itu, kami akan menyediakan banyak contoh yang tidak bisa di-compile beserta pesan error dari compiler pada tiap situasi tersebut. Pahami bahwa jika kita memasukkan dan menjalankan contoh secara acak, ada kemungkinan ia tidak bisa di-compile! Pastikan kita membaca teks di sekitarnya untuk melihat apakah contoh tersebut memang ditujukan untuk error. Dalam kebanyakan situasi, kami akan mengarahkan kita ke versi yang benar dari kode yang tidak bisa di-compile tersebut. Ferris juga akan membantu kita membedakan kode yang memang tidak dimaksudkan agar berfungsi:

FerrisMakna
Ferris dengan tanda tanyaKode ini tidak bisa di-compile!
Ferris mengangkat tanganKode ini menghasilkan panic!
Ferris dengan satu capit di atas, mengedikkan bahuKode ini tidak menghasilkan perilaku yang diinginkan.

Dalam kebanyakan situasi, kami akan mengarahkan kita ke versi yang benar dari kode apa pun yang tidak bisa di-compile.

Kode Sumber (Source Code)

Berkas sumber dari mana buku ini dibuat dapat ditemukan di GitHub.

Persiapan

Mari kita mulai perjalanan Rust kita! Ada banyak yg harus dipelajari, tetapi setiap perjalanan dimulai dari suatu permulaan. Pada bab ini, kita akan berbicara mengenai:

  • Memasang Rust di Linux, macOS, dan Windows
  • Menulis program yang mencetak Hello, world!
  • Menggunakan cargo, manajer paket dan sistem build Rust

Instalasi

Instalasi

Langkah pertama yang kita lakukan adalah instalasi bahasa Rust. Kita akan men- download Rust melalui rustup, tool command line ini digunakan untuk mengatur versi dari Rust dan beberapa tool - tool yang ada didalamnya. untuk instalasi ini kita membutuhkan koneksi internet untuk mendownloadnya.

Catatan: Jika Kita lebih memilih untuk tidak menggunakan rustup untuk beberapa alasan, Kita bisa merujuk pada Halaman Instalasi Rust Lainnya untuk opsi lainnya

Langkah selanjutnya memasang versi stable dari compiler Rust. Jaminan stabilitas Rust memastikan bahwa semua contoh di buku ini yg bisa di compile akan tetap bisa di compile dengan versi Rust yg lebih baru. Hasil Output mungkin berbeda sedikit antarversi karena Rust sering kali memperbaiki pesan error dan peringatan. Dengan kata lain, semua versi stable Rust yg lebih baru, yg kita install menggunakan metode berikut, seharusnya bekerja sesuai harapan dengan isi buku ini.

Notasi Baris Perintah

Pada bab ini dan keseluruhan buku, kita akan ditunjukkan beberapa perintah yg digunakan pada terminal. Baris yg seharusnya kita masukkan di terminal semua diawali dengan $. kita tidak perlu mengetikkan karakter $; Itu adalah penanda baris perintah yg ditampilkan untuk menunjukkan awal dari tiap perintah. Baris yg tidak dimulai dengan $ biasanya menampilkan output dari perintah sebelumnya. Sebagai tambahan, contoh khusus PowerShell akan menggunakan > daripada $.

Pemasangan rustup pada Linux atau macOS

Jika kita menggunakan Linux atau macOS, buka sebuah terminal dan masukkan perintah berikut:

$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

Perintah tersebut akan mengunduh sebuah script dan memulai pemasangan aplikasi rustup, dan memasang versi stable terakhir Rust. Kita mungkin akan diminta untuk memasukkan kata kunci. Jika pemasangan sukses, baris berikut akan muncul:

Rust is installed now. Great!

Kita juga akan memerlukan sebuah linker, yaitu sebuah program yang digunakan Rust untuk menggabungkan hasil kompilasi menjadi satu berkas. Kemungkinan besar kita sudah memilikinya. Jika kita mengalami error terkait linker, kita sebaiknya menginstal compiler C, yang biasanya sudah menyertakan linker. Compiler C juga berguna karena beberapa paket Rust yang umum bergantung pada kode C dan akan membutuhkan compiler C.

Di macOS, kita memasang compiler C dengan menjalankan:

$ xcode-select --install

Pengguna Linux biasanya memasang GCC atau Clang, sesuai dengan dokumentasi masing-masing distribusi. Sebagai contoh, jika kita menggunakan Ubuntu, kita bisa memasang paket build-essential.

Memasang rustup pada Windows

Di Windows, buka https://www.rust-lang.org/tools/install dan ikuti instruksi untuk memasang Rust. Pada suatu tahap dalam proses instalasi, kita akan diminta untuk menginstal Visual Studio. Ini akan menyediakan sebuah linker dan pustaka native yang dibutuhkan untuk mengompilasi program. Jika kita membutuhkan bantuan lebih lanjut pada langkah ini, lihat https://rust-lang.github.io/rustup/installation/windows-msvc.html

Sisa buku ini menggunakan perintah yang bisa dijalankan baik di cmd.exe maupun
PowerShell. Jika ada perbedaan khusus, kita akan menjelaskan mana yang harus digunakan.

Pemecahan Masalah

Untuk mengecek apakah kita memiliki Rust yg terpasang dengan baik, buka terminal dan masukkan:

$ rustc --version

Seharusnya keluar nomor versi, hash commit, dan tanggal commit untuk versi stable terakhir yg telah diterbitkan, dalam format:

rustc x.y.z (abcabcabc yyyy-mm-dd)

Jika kita mendapatkan informasi tersebut, berarti kita telah berhasil memasang Rust! Jika kita tidak bertemu dengan informasi tersebut, silakan cek apakah Rust berada di variabel sistem %PATH% sebagai berikut:

Di Windows CMD, gunakan:

> echo %PATH%

Di PowerShell, gunakan:

> echo $env:Path

Di Linux dan macOS, gunakan:

$ echo $PATH

Jika semuanya sudah benar tetapi Rust masih belum bekerja, ada beberapa tempat dimana kita bisa mencari bantuan. Cari tahu bagaimana berhubungan dengan Rustacean (sebutan bagi kita) lainnya di halaman komunitas.

Memperbarui dan Menghapus

Begitu Rust terpasang melalui rustup, memperbarui ke versi terbaru menjadi lebih mudah. Dari terminal kita, jalankan perintah berikut:

$ rustup update

Untuk menghapus Rust dan rustup, jalankan perintah berikut dari terminal kita:

$ rustup self uninstall

Dokumentasi Lokal

Pemasangan Rust juga menyertakan salinan dokumentasi lokal jadi kita dapat membacanya secara luring. Jalankan rustup doc untuk membuka dokumentasi lokal di peramban kita.

Setiap kali ada sebuah tipe atau fungsi yg tersedia di library std dan kita mungkin tidak paham mengenai apa dan bagaimana cara menggunakannya, gunakan dokumentasi API (Application Programming Interface) berikut untuk mencari tahu!

Editor Teks dan Lingkungan Pengembangan Terpadu

Buku ini tidak mengasumsikan alat apa yang kita gunakan untuk menulis kode Rust. Hampir semua editor teks bisa menyelesaikan pekerjaan! Namun, banyak editor teks dan Lingkungan Pengembangan Terpadu atau sering disebut Integrated Development Environments(IDE) yang memiliki dukungan bawaan untuk Rust. Kita selalu bisa menemukan daftar terkini dari berbagai editor dan IDE di halaman tools pada situs web Rust.

Bekerja Secara Offline dengan Buku Ini

Dalam beberapa contoh, kita akan menggunakan paket Rust di luar library standar. Untuk mengikuti contoh-contoh tersebut, kita perlu memiliki koneksi internet atau sudah mengunduh dependensi tersebut sebelumnya. Untuk mengunduh dependensi lebih dulu, kita bisa menjalankan perintah berikut. (Nantinya kita akan menjelaskan apa itu cargo dan apa fungsi dari setiap perintah ini secara lebih rinci.)

$ cargo new get-dependencies
$ cd get-dependencies
$ cargo add rand@0.8.5 trpl@0.2.0

Perintah ini akan menyimpan hasil unduhan paket-paket tersebut di cache sehingga kita tidak perlu mengunduhnya lagi nanti. Setelah menjalankan perintah ini, kita tidak perlu menyimpan folder get-dependencies. Jika kita sudah menjalankan perintah ini, kita bisa menggunakan flag --offline pada semua perintah cargo di sisa buku ini untuk memakai versi yang sudah tersimpan di cache alih-alih mencoba menggunakan jaringan.

Halo, Dunia!

Hello, World!

Sekarang setelah kita memasang Rust, saatnya menulis program Rust pertama kita. Sudah menjadi tradisi ketika belajar bahasa pemrograman baru untuk membuat program kecil yang mencetak teks Hello, world! ke layar, jadi mari kita lakukan hal yang sama di sini!

Catatan: Buku ini mengasumsikan kita memiliki pemahaman dasar tentang command line.
Rust tidak memiliki tuntutan khusus mengenai editor, tools, atau di mana kode kita berada,
jadi jika lebih suka menggunakan integrated development environment (IDE)
daripada command line, silakan gunakan IDE favorit kita sendiri. Banyak IDE sekarang sudah
memiliki dukungan tertentu untuk Rust; periksa dokumentasi IDE untuk detailnya.
Tim Rust berfokus pada peningkatan dukungan IDE melalui rust-analyzer.
Lihat Lampiran D untuk detail lebih lanjut.

Membuat Direktori Proyek

Kita akan mulai dengan membuat sebuah direktori untuk menyimpan kode Rust kita. Rust tidak peduli di mana kode kita berada, tetapi untuk latihan dan proyek dalam buku ini, kami menyarankan untuk membuat direktori projects di direktori home kita dan menyimpan semua proyek di sana.

Buka terminal dan masukkan perintah berikut untuk membuat direktori projects dan sebuah direktori untuk proyek “Hello, world!” di dalam direktori projects.

Untuk Linux, macOS, dan PowerShell di Windows, masukkan perintah ini:

$ mkdir ~/projects
$ cd ~/projects
$ mkdir hello_world
$ cd hello_world

untuk Command Line Windows, masukkan perintah ini:

> mkdir "%USERPROFILE%\projects"
> cd /d "%USERPROFILE%\projects"
> mkdir hello_world
> cd hello_world

Selanjutnya, buat sebuah berkas sumber baru dan beri nama main.rs. Berkas Rust selalu diakhiri dengan ekstensi .rs. Jika kita menggunakan lebih dari satu kata dalam nama berkas, konvensinya adalah menggunakan garis bawah (underscore) untuk memisahkan kata-kata tersebut. Misalnya, gunakan hello_world.rs daripada helloworld.rs.

Sekarang buka berkas main.rs yang baru saja kita buat dan masukkan kode pada Listing 1-1.

Filename: main.rs
fn main() {
    println!("Hello, world!");
}
Listing 1-1: Program yang mencetak Hello, world!

Simpan berkas tersebut lalu kembali ke jendela terminal kita di direktori
~/projects/hello_world. Pada Linux atau macOS, masukkan perintah berikut
untuk mengompilasi dan menjalankan berkas:

$ rustc main.rs
$ ./main
Hello, world!

Pada Windows, masukan perintah .\main daripada ./main:

> rustc main.rs
> .\main
Hello, world!

Terlepas dari sistem operasi yang kita gunakan, string Hello, world! seharusnya muncul di terminal. Jika kita tidak melihat output ini, lihat kembali bagian “Troubleshooting” pada bagian Instalasi untuk cara mendapatkan bantuan.

Jika Hello, world! berhasil tercetak, selamat! Kita secara resmi telah menulis program Rust. Itu berarti kita sudah menjadi seorang pemrogram Rust—selamat datang!

Anatomi Program Rust

Mari kita ulas program “Hello, world!” ini secara lebih rinci. Berikut adalah potongan pertama dari teka-teki tersebut:

fn main() {

}

Baris-baris ini mendefinisikan sebuah fungsi bernama main. Fungsi main itu
spesial: selalu menjadi kode pertama yang dijalankan dalam setiap program Rust
yang bisa dieksekusi. Di sini, baris pertama mendeklarasikan fungsi bernama
main yang tidak memiliki parameter dan tidak mengembalikan nilai apa pun.
Jika ada parameter, maka mereka akan ditulis di dalam tanda kurung ().

Badan fungsi dibungkus dengan {}. Rust mewajibkan penggunaan kurung kurawal
pada semua badan fungsi. Gaya penulisan yang baik adalah meletakkan kurung kurawal
pembuka pada baris yang sama dengan deklarasi fungsi, dengan memberi satu spasi
di antaranya.

Catatan: Jika kita ingin tetap menggunakan gaya standar di seluruh proyek Rust,
kita bisa memakai alat pemformat otomatis bernama rustfmt untuk memformat kode
sesuai gaya tertentu (lebih lanjut tentang rustfmt ada di Lampiran D).
Tim Rust sudah menyertakan alat ini dalam distribusi standar Rust, sama seperti rustc,
jadi seharusnya sudah terpasang di komputer kita!

Badan fungsi main berisi kode berikut:

#![allow(unused)]
fn main() {
println!("Hello, world!");
}

Baris ini melakukan seluruh pekerjaan dalam program kecil ini: mencetak teks ke layar. Ada tiga detail penting yang perlu kita perhatikan di sini.

Pertama, println! memanggil sebuah macro Rust. Jika yang dipanggil adalah fungsi biasa, maka penulisannya akan println (tanpa !). Macro Rust adalah cara untuk menulis kode yang menghasilkan kode lain untuk memperluas sintaks Rust, dan kita akan membahasnya lebih lanjut di Bab 20. Untuk saat ini, kita hanya perlu tahu bahwa penggunaan ! berarti kita sedang memanggil macro, bukan fungsi biasa, dan macro tidak selalu mengikuti aturan yang sama dengan fungsi.

Kedua, kita melihat string "Hello, world!". Kita meneruskan string ini sebagai argumen ke println!, dan string tersebut akan dicetak ke layar.

Ketiga, kita mengakhiri baris dengan tanda titik koma (;), yang menunjukkan bahwa ekspresi tersebut sudah selesai dan ekspresi berikutnya siap dimulai. Sebagian besar baris kode Rust diakhiri dengan titik koma.

Kompilasi dan Eksekusi adalah Langkah yang Terpisah

Kita baru saja menjalankan sebuah program baru, jadi mari kita periksa setiap langkah dalam prosesnya.

Sebelum menjalankan program Rust, kita harus mengompilasinya dengan compiler Rust dengan memasukkan perintah rustc dan menambahkan nama berkas sumber kita, seperti ini:

$ rustc main.rs

Jika kita memiliki latar belakang C atau C++, kita akan menyadari bahwa ini mirip dengan gcc atau clang. Setelah kompilasi berhasil, Rust menghasilkan sebuah biner yang bisa dieksekusi.

Di Linux, macOS, dan PowerShell di Windows, kita bisa melihat berkas executable dengan memasukkan perintah ls di shell:

$ ls
main  main.rs

Di Linux dan macOS, kita akan melihat dua berkas. Dengan PowerShell di Windows, kita akan melihat tiga berkas yang sama seperti yang muncul saat menggunakan CMD. Dengan CMD di Windows, kita akan memasukkan perintah berikut:

> dir /B %= the /B option says to only show the file names =%
main.exe
main.pdb
main.rs

Ini menampilkan berkas kode sumber dengan ekstensi .rs, berkas executable (main.exe di Windows, tetapi main di semua platform lain), dan, ketika menggunakan Windows, sebuah berkas yang berisi informasi debugging dengan ekstensi .pdb. Dari sini, kita bisa menjalankan berkas main atau main.exe, seperti ini:

$ ./main # or .\main on Windows

Jika main.rs kita adalah program “Hello, world!”, baris ini akan mencetak Hello, world! ke terminal kita.

Jika kita lebih familiar dengan bahasa dinamis seperti Ruby, Python, atau JavaScript, kita mungkin belum terbiasa dengan proses kompilasi dan eksekusi sebagai langkah terpisah. Rust adalah bahasa ahead-of-time compiled, artinya kita bisa mengompilasi program lalu memberikan file executable-nya kepada orang lain, dan mereka dapat menjalankannya tanpa harus memasang Rust. Sebaliknya, jika kita memberikan berkas .rb, .py, atau .js, orang tersebut perlu memasang Ruby, Python, atau JavaScript (sesuai bahasa yang digunakan). Namun, pada bahasa-bahasa tersebut, kita hanya perlu satu perintah untuk mengompilasi sekaligus menjalankan program. Semua ini adalah bentuk kompromi dalam desain bahasa pemrograman.

Mengompilasi dengan rustc saja sudah cukup untuk program sederhana, tetapi seiring pertumbuhan proyek, kita akan ingin mengatur semua opsi dan mempermudah berbagi kode. Selanjutnya, kita akan berkenalan dengan alat Cargo, yang akan membantu kita menulis program Rust untuk dunia nyata.

Halo, Cargo!

Hello, Cargo!

Cargo adalah sistem build dan manajer paket Rust. Sebagian besar Rustacean menggunakan alat ini untuk mengelola proyek Rust mereka karena Cargo menangani banyak tugas untuk kita, seperti membangun kode, mengunduh pustaka yang dibutuhkan kode kita, dan membangun pustaka tersebut. (Kita menyebut pustaka yang dibutuhkan oleh kode kita sebagai dependensi.)

Program Rust paling sederhana, seperti yang sudah kita tulis sejauh ini, tidak memiliki dependensi apa pun. Jika kita membangun proyek “Hello, world!” dengan Cargo, Cargo hanya akan menggunakan bagian yang menangani proses build kode kita. Seiring kita menulis program Rust yang lebih kompleks, kita akan menambahkan dependensi, dan jika kita memulai proyek dengan Cargo, menambahkan dependensi akan jauh lebih mudah dilakukan.

Karena sebagian besar proyek Rust menggunakan Cargo, sisa buku ini juga mengasumsikan kita menggunakan Cargo. Cargo sudah terpasang bersama Rust jika kita menggunakan installer resmi yang dibahas pada bagian “Instalasi”. Jika kita memasang Rust dengan cara lain, periksa apakah Cargo sudah terpasang dengan memasukkan perintah berikut di terminal:

$ cargo --version

Jika kita melihat nomor versi, berarti Cargo sudah ada! Jika yang muncul adalah error, seperti command not found, lihat dokumentasi dari metode instalasi yang kita gunakan untuk mengetahui cara memasang Cargo secara terpisah.

Membuat Proyek dengan Cargo

Mari kita buat proyek baru menggunakan Cargo dan melihat bagaimana perbedaannya dengan proyek “Hello, world!” yang asli. Arahkan kembali ke direktori projects (atau lokasi lain tempat kita menyimpan kode). Lalu, pada sistem operasi apa pun, jalankan perintah berikut:

$ cargo new hello_cargo
$ cd hello_cargo

Perintah pertama membuat direktori dan proyek baru bernama hello_cargo. Kita memberi nama proyek ini hello_cargo, dan Cargo membuat berkas-berkasnya di dalam direktori dengan nama yang sama.

Masuklah ke direktori hello_cargo dan lihat daftar berkasnya. Kita akan melihat bahwa Cargo telah menghasilkan dua berkas dan satu direktori untuk kita: sebuah berkas Cargo.toml dan direktori src dengan sebuah berkas main.rs di dalamnya.

Cargo juga telah menginisialisasi sebuah repositori Git baru beserta berkas .gitignore. Berkas Git tidak akan dibuat jika kita menjalankan cargo new di dalam repositori Git yang sudah ada; kita bisa menimpa perilaku ini dengan menggunakan cargo new --vcs=git.

Catatan: Git adalah sistem kontrol versi yang umum digunakan. Kita bisa mengubah
cargo new untuk menggunakan sistem kontrol versi lain atau tanpa sistem kontrol versi
dengan menambahkan flag --vcs. Jalankan cargo new --help untuk melihat opsi yang tersedia.

Buka Cargo.toml di editor teks pilihan kita. Isinya akan terlihat mirip dengan kode
pada Listing 1-2.

Filename: Cargo.toml
[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2024"

[dependencies]
Listing 1-2: Isi Cargo.toml yang dihasilkan oleh cargo new

Berkas ini menggunakan format TOML (Tom’s Obvious, Minimal Language), yang merupakan format konfigurasi untuk Cargo.

Baris pertama, [package], adalah judul bagian yang menunjukkan bahwa pernyataan berikutnya berfungsi untuk mengonfigurasi sebuah paket. Saat kita menambahkan lebih banyak informasi ke berkas ini, kita akan menambahkan bagian lain.

Tiga baris berikutnya mengatur informasi konfigurasi yang dibutuhkan Cargo untuk mengompilasi program kita: nama, versi, dan edition Rust yang digunakan. Kita akan membahas kunci edition di Lampiran E.

Baris terakhir, [dependencies], adalah awal dari bagian tempat kita mencantumkan dependensi proyek. Dalam Rust, paket kode disebut crate. Untuk proyek ini kita tidak membutuhkan crate tambahan, tetapi pada proyek pertama di Bab 2 kita akan membutuhkannya, jadi kita akan menggunakan bagian dependensi ini nanti.

Sekarang buka src/main.rs dan perhatikan isinya:

Nama berkas: src/main.rs

fn main() {
    println!("Hello, world!");
}

Cargo telah membuatkan program “Hello, world!” untuk kita, persis seperti yang kita tulis di Listing 1-1! Sejauh ini, perbedaan antara proyek kita dan proyek yang dihasilkan Cargo adalah bahwa Cargo menempatkan kode di dalam direktori src dan kita memiliki berkas konfigurasi Cargo.toml di direktori paling atas.

Cargo mengharapkan berkas sumber kita berada di dalam direktori src. Direktori proyek paling atas hanya untuk berkas README, informasi lisensi, berkas konfigurasi, dan hal lain yang tidak langsung berkaitan dengan kode. Menggunakan Cargo membantu kita mengatur proyek dengan rapi. Ada tempat untuk semuanya, dan semuanya berada di tempatnya.

Jika kita memulai sebuah proyek tanpa Cargo, seperti proyek “Hello, world!”, kita bisa mengonversinya menjadi proyek yang menggunakan Cargo. Pindahkan kode proyek ke dalam direktori src dan buat berkas Cargo.toml yang sesuai. Salah satu cara mudah untuk mendapatkan berkas Cargo.toml adalah dengan menjalankan cargo init, yang akan membuatkannya secara otomatis.

Membangun dan Menjalankan Proyek Cargo

Sekarang mari kita lihat perbedaan ketika kita membangun dan menjalankan program “Hello, world!” dengan Cargo! Dari direktori hello_cargo, bangun proyek kita dengan memasukkan perintah berikut:

$ cargo build
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 2.85 secs

Perintah ini membuat berkas executable di target/debug/hello_cargo
(atau target\debug\hello_cargo.exe di Windows) alih-alih di direktori kita saat ini.
Karena build bawaan adalah build debug, Cargo menaruh biner di dalam direktori bernama debug.
Kita bisa menjalankan berkas executable tersebut dengan perintah ini:

$ ./target/debug/hello_cargo # or .\target\debug\hello_cargo.exe on Windows
Hello, world!

Jika semuanya berjalan dengan baik, Hello, world! akan tercetak di terminal.
Menjalankan cargo build untuk pertama kalinya juga membuat Cargo menghasilkan
berkas baru di tingkat paling atas: Cargo.lock. Berkas ini menyimpan catatan
versi pasti dari dependensi dalam proyek kita. Proyek ini tidak memiliki dependensi,
jadi isi berkasnya masih cukup kosong. Kita tidak pernah perlu mengubah berkas ini secara manual;
Cargo akan mengelola isinya untuk kita.

Kita baru saja membangun proyek dengan cargo build dan menjalankannya dengan
./target/debug/hello_cargo, tetapi kita juga bisa menggunakan cargo run
untuk mengompilasi kode dan kemudian menjalankan executable hasilnya hanya dengan satu perintah:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/hello_cargo`
Hello, world!

Menggunakan cargo run lebih praktis daripada harus mengingat untuk menjalankan
cargo build lalu menggunakan path lengkap ke biner, jadi kebanyakan pengembang
lebih memilih cargo run.

Perhatikan bahwa kali ini kita tidak melihat output yang menunjukkan bahwa Cargo
sedang mengompilasi hello_cargo. Cargo mengetahui bahwa berkas-berkas tidak berubah,
jadi ia tidak melakukan build ulang dan langsung menjalankan binernya. Jika kita
mengubah kode sumber, Cargo akan melakukan build ulang proyek sebelum menjalankannya,
dan kita akan melihat output seperti ini:

$ cargo run
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.33 secs
     Running `target/debug/hello_cargo`
Hello, world!

Cargo juga menyediakan sebuah perintah yang dipanggil cargo check. perintah ini secara cepat memeriksa kode kita untuk memastikan bahwa kode tersebut bisa di kompilasi tetapi perintah ini tidak menghasilkan file eksekusi/executable:

$ cargo check
   Checking hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.32 secs

Kenapa kita mungkin tidak ingin menghasilkan executable? Sering kali, cargo check jauh lebih cepat dibandingkan cargo build karena ia melewati langkah untuk membuat executable. Jika kita terus-menerus memeriksa pekerjaan selagi menulis kode, menggunakan cargo check akan mempercepat proses untuk memberi tahu apakah proyek kita masih bisa dikompilasi! Karena itu, banyak Rustacean menjalankan cargo check secara berkala saat menulis program untuk memastikan kodenya tetap bisa dikompilasi. Lalu, mereka menjalankan cargo build ketika sudah siap menggunakan executable.

Mari kita rekap apa yang sudah kita pelajari tentang Cargo sejauh ini:

  • Kita bisa membuat proyek dengan cargo new.
  • Kita bisa membangun proyek dengan cargo build.
  • Kita bisa membangun sekaligus menjalankan proyek dalam satu langkah dengan cargo run.
  • Kita bisa membangun proyek tanpa menghasilkan biner untuk mengecek error dengan cargo check.
  • Alih-alih menyimpan hasil build di direktori yang sama dengan kode kita, Cargo menyimpannya di direktori target/debug.

Keuntungan tambahan dari menggunakan Cargo adalah perintah-perintahnya sama tidak peduli sistem operasi apa yang kita gunakan. Jadi, mulai dari titik ini, kita tidak lagi memberikan instruksi khusus untuk Linux dan macOS dibandingkan Windows.

Membangun untuk Rilis

Ketika proyek kita akhirnya siap dirilis, kita bisa menggunakan cargo build --release untuk mengompilasi dengan optimisasi. Perintah ini akan membuat executable di target/release alih-alih di target/debug. Optimisasi membuat kode Rust kita berjalan lebih cepat, tetapi mengaktifkannya akan memperpanjang waktu kompilasi program. Inilah alasan mengapa ada dua profil yang berbeda: satu untuk pengembangan, ketika kita ingin build cepat dan sering, dan satu lagi untuk membangun program final yang akan kita berikan kepada pengguna, yang tidak akan dibangun ulang berulang kali dan harus berjalan secepat mungkin. Jika kita melakukan benchmarking waktu eksekusi kode, pastikan untuk menjalankan cargo build --release dan melakukan benchmark dengan executable di target/release.

Cargo sebagai Konvensi

Untuk proyek sederhana, Cargo mungkin tidak terlihat jauh lebih berguna daripada sekadar menggunakan rustc, tetapi nilainya akan terasa ketika program kita menjadi lebih rumit. Begitu program berkembang menjadi banyak berkas atau membutuhkan dependensi, jauh lebih mudah membiarkan Cargo yang mengoordinasikan build.

Meskipun proyek hello_cargo sederhana, ia sudah menggunakan banyak tooling nyata yang akan kita gunakan sepanjang perjalanan kita dengan Rust. Bahkan, untuk bekerja pada proyek yang sudah ada, kita bisa menggunakan perintah berikut untuk mengambil kode dengan Git, berpindah ke direktori proyek tersebut, dan melakukan build:

$ git clone example.org/someproject
$ cd someproject
$ cargo build

Untuk informasi lebih lanjut tentang Cargo, lihat dokumentasinya.

Ringkasan

Kita sudah memulai perjalanan Rust dengan langkah yang hebat! Dalam bab ini, kita telah mempelajari cara:

  • Memasang versi stabil terbaru Rust menggunakan rustup
  • Memperbarui Rust ke versi yang lebih baru
  • Membuka dokumentasi yang terpasang secara lokal
  • Menulis dan menjalankan program “Hello, world!” langsung dengan rustc
  • Membuat dan menjalankan proyek baru menggunakan konvensi Cargo

Ini adalah waktu yang tepat untuk membangun program yang lebih substansial agar terbiasa membaca dan menulis kode Rust. Jadi, pada Bab 2, kita akan membangun program permainan tebak angka. Jika kita lebih suka memulai dengan mempelajari bagaimana konsep pemrograman umum bekerja di Rust, lihat Bab 3 lalu kembali ke Bab 2.

Membuat Game Tebak Angka

Yuk, kita langsung terjun ke Rust dengan ngerjain project bareng-bareng! Di bab ini, kita bakal kenalan sama beberapa konsep umum di Rust lewat program benar-benar. Kita bakal belajar soal let, match, methods, associated functions, external crates, dan banyak lagi! Di bab-bab selanjutnya, kita bakal bahas konsep ini lebih dalem. Tapi buat sekarang, kita latihan yang basic-basic dulu ya.

Kita bakal bikin problem klasik buat pemula: game tebak angka. Cara mainnya simpel: program bakal nge-generate angka random antara 1 sampe 100. Terus, program bakal minta player buat masukin tebakannya. Setelah tebakan dimasukin, program bakal ngasih tau apakah tebakannya ketinggian atau kerendahan. Kalau bener, game-nya bakal ngasih ucapan selamat terus exit.

Setup Project Baru

Buat nge-setup project baru, masuk ke direktori projects yang udah kita bikin di Bab 1, terus bikin project baru pake Cargo kayak gini:

$ cargo new guessing_game
$ cd guessing_game

Perintah pertama, cargo new, ngambil nama project (guessing_game) sebagai argumen pertamanya. Perintah kedua buat pindah ke direktori project yang baru dibikin.

Coba liat file Cargo.toml yang dihasilkan:

Nama file: Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2024"

[dependencies]

Kayak yang kita liat di Bab 1, cargo new nge-generate program “Hello, world!” buat kita. Cek file src/main.rs-nya:

Nama file: src/main.rs

fn main() {
    println!("Hello, world!");
}

Sekarang kita compile program “Hello, world!” ini terus jalanin sekalian pake perintah cargo run:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/guessing_game`
Hello, world!

Perintah run ini kepake sekali pas kita butuh iterasi cepet di sebuah project, kayak di game ini nanti, buat ngetes tiap perubahan sebelum lanjut ke langkah berikutnya.

Buka lagi file src/main.rs. Kita bakal nulis semua kodenya di file ini.

Memproses Tebakan (Guess)

Bagian pertama dari program game tebak angka ini bakal minta input dari user, proses input-nya, terus nge-cek apakah input-nya udah sesuai format. Buat awal, kita bakal bolehin player buat masukin tebakan. Masukin kode di Listing 2-1 ke dalam src/main.rs.

Filename: src/main.rs
use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}
Listing 2-1: Kode buat ngambil tebakan dari user dan mencetaknya

Kode ini isinya banyak informasi, jadi yuk kita bahas baris demi baris. Buat dapet input user terus nge-print hasilnya sebagai output, kita perlu bawa library io (input/output) ke dalam scope. Library io ini dateng dari standard library, yang dikenal sebagai std:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Secara default, Rust punya sekumpulan item yang udah didefinisiin di standard library yang otomatis dibawa ke scope tiap program. Kumpulan ini namanya prelude, dan kita bisa liat isinya di dokumentasi standard library.

Kalau tipe yang mau kita pake nggak ada di prelude, kita harus bawa tipe itu ke scope secara eksplisit pake statement use. Pake library std::io ngasih kita beberapa fitur berguna, termasuk kemampuan buat nerima input user.

Kayak yang kita liat di Bab 1, fungsi main adalah entry point ke programnya:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Sintaks fn mendeklarasikan fungsi baru; tanda kurung () nunjukin kalau nggak ada parameter; dan kurung kurawal { buat mulai body fungsinya.

Kita juga udah belajar di Bab 1 kalau println! itu macro buat nyetak string ke layar:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Kode ini nyetak prompt yang ngasih tau gamenya apa dan minta input dari user.

Menyimpan Nilai dengan Variabel

Selanjutnya, kita bakal bikin sebuah variable buat nyimpen input user, kayak gini:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Nah, programnya mulai seru nih! Ada banyak hal yang terjadi di baris kecil ini. Kita pake statement let buat bikin variabel. Ini contoh lainnya:

let apel = 5;

Baris ini bikin variabel baru namanya apel dan nge-bind ke nilai 5. Di Rust, variabel itu immutable (nggak bisa diubah) secara default, artinya sekali kita kasih nilai ke variabelnya, nilainya nggak bakal berubah. Kita bakal bahas konsep ini lebih detail di bagian “Variabel dan Mutabilitas” di Bab 3. Buat bikin variabel jadi mutable (bisa diubah), kita tambahin mut sebelum nama variabelnya:

let apel = 5; // immutable
let mut pisang = 5; // mutable

Catatan: Sintaks // buat mulai komentar sampe akhir baris. Rust bakal cuekin apa pun yang ada di dalam komentar. Kita bakal bahas komentar lebih detail di Bab 3.

Balik lagi ke program game tebak angka, sekarang kita tau kalau let mut guess bakal ngenalin variabel mutable namanya guess. Tanda sama dengan (=) ngasih tau Rust kalau kita mau nge-bind sesuatu ke variabelnya sekarang. Di sebelah kanan tanda sama dengan adalah nilai yang di-bind ke guess, yaitu hasil dari manggil String::new, fungsi yang balikin instance baru dari sebuah String. String adalah tipe string yang dikasih standard library yang isinya teks UTF-8 yang bisa nambah terus ukurannya (growable).

Sintaks :: di baris ::new nunjukin kalau new itu associated function dari tipe String. Associated function adalah fungsi yang diimplementasikan pada sebuah tipe, dalam hal ini String. Fungsi new ini bikin string baru yang kosong. Kita bakal sering nemu fungsi new di banyak tipe karena itu nama umum buat fungsi yang bikin nilai baru dari jenis tertentu.

Secara lengkap, baris let mut guess = String::new(); udah bikin variabel mutable yang sekarang di-bind ke instance baru dari String yang masih kosong. Fiuh!

Menerima Input User

Inget kan kalau kita udah masukin fungsi input/output dari standard library pake use std::io; di baris pertama program. Sekarang kita bakal manggil fungsi stdin dari modul io, yang bakal ngebolehin kita handle input user:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Kalau kita nggak import modul io pake use std::io; di awal program, kita tetep bisa pake fungsinya dengan nulis pemanggilan fungsi ini jadi std::io::stdin. Fungsi stdin balikin instance dari std::io::Stdin, tipe yang merepresentasikan handle ke standard input buat terminal kita.

Selanjutnya, baris .read_line(&mut guess) manggil method read_line pada handle standard input buat dapet input dari user. Kita juga masukin &mut guess sebagai argumen ke read_line buat ngasih tau string mana yang bakal dipake buat nyimpen input user. Tugas utama read_line adalah ngambil apa pun yang diketik user ke standard input terus nambahin itu ke sebuah string (tanpa nimpa isinya), makanya kita masukin string itu sebagai argumen. Argumen string-nya harus mutable biar method-nya bisa ngerubah isi string-nya.

Tanda & nunjukin kalau argumen ini adalah sebuah reference (referensi), yang ngasih cara biar beberapa bagian kode kita bisa akses satu data tanpa perlu copy data itu ke memori berkali-kali. Reference itu fitur yang lumayan kompleks, dan salah satu keunggulan utama Rust adalah seberapa aman dan gampangnya pake reference. Kita nggak perlu tau banyak detailnya buat nyelesein program ini. Buat sekarang, yang perlu kita tau adalah, kayak variabel, reference itu immutable secara default. Makanya, kita perlu nulis &mut guess bukannya &guess biar dia mutable. (Bab 4 bakal jelasin soal reference lebih mendalam.)

Menangani Potensi Gagal dengan Result

Kita masih bahas baris kode yang tadi ya. Sekarang kita lagi bahas bagian ketiganya, tapi inget kalau ini masih bagian dari satu baris kode yang logis. Bagian selanjutnya adalah method ini:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Kita bisa aja nulis kode ini kayak gini:

io::stdin().read_line(&mut guess).expect("Gagal baca baris");

Tapi, satu baris panjang itu susah dibaca, jadi mendingan dibagi-bagi. Biasanya lebih bijak buat nambahin baris baru dan whitespace lainnya buat bantu mecah baris panjang pas kita manggil method pake sintaks .nama_method(). Sekarang mari kita bahas fungsi baris ini.

Kayak yang udah disebutin tadi, read_line naruh apa pun yang user masukin ke string yang kita kasih, tapi dia juga balikin nilai Result. Result adalah sebuah enumeration, yang sering disebut enum, yaitu tipe yang bisa ada di salah satu dari beberapa kemungkinan state. Kita sebut tiap state itu sebagai variant.

Bab 6 bakal bahas enum lebih detail. Tujuan dari tipe-tipe Result ini adalah buat nge-encode informasi penanganan error (error-handling).

Varian dari Result adalah Ok dan Err. Varian Ok nunjukin operasinya berhasil, dan di dalemnya ada nilai yang berhasil di-generate. Varian Err artinya operasinya gagal, dan isinya informasi soal gimana atau kenapa operasinya gagal.

Nilai dari tipe Result, kayak nilai dari tipe apa pun, punya methods yang didefinisiin di atasnya. Instance dari Result punya expect method yang bisa kita panggil. Kalau instance Result ini adalah nilai Err, expect bakal bikin programnya crash dan nampilin pesan yang kita kasih sebagai argumen ke expect. Kalau method read_line balikin Err, itu kemungkinan hasil dari error yang dateng dari sistem operasi di bawahnya. Kalau instance Result ini adalah nilai Ok, expect bakal ngambil nilai balik yang dipegang sama Ok terus balikin nilai itu aja biar bisa kita pake. Dalam kasus ini, nilai itu adalah jumlah byte dari input user.

Kalau kita nggak manggil expect, programnya tetep bakal ke-compile, tapi kita bakal dapet warning:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
warning: unused `Result` that must be used
  --> src/main.rs:10:5
   |
10 |     io::stdin().read_line(&mut guess);
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
   |
10 |     let _ = io::stdin().read_line(&mut guess);
   |     +++++++

warning: `guessing_game` (bin "guessing_game") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.59s

Rust ngasih tau kalau kita nggak pake nilai Result yang dibalikin sama read_line, yang nunjukin kalau programnya belum handle kemungkinan error.

Cara yang bener buat ilangin warning itu adalah dengan benar-benar nulis kode error-handling, tapi dalam kasus kita, kita cuma mau nge-crash-in program ini pas ada masalah, jadi kita bisa pake expect. Kita bakal belajar soal cara bangkit dari error di Bab 9.

Mencetak Nilai dengan Placeholder println!

Selain kurung kurawal tutup, cuma ada satu baris lagi yang perlu dibahas di kode sejauh ini:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Baris ini nyetak string yang sekarang isinya input user. Tanda kurung kurawal {} itu adalah placeholder: bayangin aja {} kayak capit kepiting kecil yang megang sebuah nilai di tempatnya. Pas nyetak nilai variabel, nama variabelnya bisa masuk ke dalem kurung kurawal. Pas nyetak hasil evaluasi sebuah ekspresi, taruh kurung kurawal kosong di format string-nya, terus ikuti format string itu dengan daftar ekspresi yang dipisahin koma buat dicetak di tiap placeholder kurung kurawal kosong sesuai urutannya. Nyetak variabel dan hasil ekspresi dalam satu panggilan ke println! bakal keliatan kayak gini:

#![allow(unused)]
fn main() {
let x = 5;
let y = 10;

println!("x = {x} dan y + 2 = {}", y + 2);
}

Kode ini bakal nyetak x = 5 dan y + 2 = 12.

Ngetes Bagian Pertama

Yuk kita tes bagian pertama dari game tebak angka ini. Jalanin pake cargo run:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.44s
     Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6

Sampai titik ini, bagian pertama game-nya udah jadi: kita dapet input dari keyboard terus nyetak hasilnya.

Menghasilkan Secret Number

Selanjutnya, kita perlu generate angka rahasia (secret number) yang bakal ditebak sama user. Angka rahasianya harus beda terus tiap kali biar gamenya seru buat dimainin berkali-kali. Kita bakal pake angka random antara 1 sampe 100 biar gamenya nggak terlalu susah. Rust belum masukin fungsionalitas angka random di standard library-nya. Tapi, tim Rust nyediain rand crate dengan fungsionalitas tersebut.

Pake Crate buat Dapet Fitur Lebih

Inget ya, crate itu adalah kumpulan file source code Rust. Project yang lagi kita bangun ini adalah sebuah binary crate, yaitu sebuah executable. rand crate adalah sebuah library crate, yang isinya kode yang tujuannya buat dipake di program lain dan nggak bisa dijalankan sendiri.

Koordinasi Cargo sama external crates adalah bagian di mana Cargo bener-bener bersinar. Sebelum kita bisa nulis kode yang pake rand, kita perlu modifikasi file Cargo.toml buat masukin rand crate sebagai dependensi. Buka filenya sekarang terus tambahin baris berikut di paling bawah, di bawah header section [dependencies] yang udah dibikinin Cargo. Pastiin buat nulis rand persis kayak di sini, dengan nomor versi ini, biar contoh kode di tutorial ini bisa jalan:

Nama file: Cargo.toml

[dependencies]
rand = "0.8.5"

Di file Cargo.toml, apa pun yang ngikutin sebuah header adalah bagian dari section itu sampe section lain mulai. Di [dependencies] kita ngasih tau Cargo external crates mana aja yang diperluin project kita dan versi mana yang kita butuhin. Dalam hal ini, kita nentuin rand crate dengan penentu versi semantik 0.8.5. Cargo ngerti Semantic Versioning (kadang disebut SemVer), yaitu standar buat nulis nomor versi. Penentu 0.8.5 sebenernya singkatan dari ^0.8.5, yang artinya versi apa pun yang minimal 0.8.5 tapi di bawah 0.9.0.

Cargo nganggep versi-versi ini punya public API yang kompatibel sama versi 0.8.5, dan spesifikasi ini mastiin kita dapet patch release terbaru yang tetep bisa di-compile sama kode di bab ini. Versi 0.9.0 atau yang lebih baru nggak dijamin punya API yang sama kayak contoh-contoh yang kita pake.

Sekarang, tanpa ngerubah kode apa pun, yuk kita build project-nya, kayak yang ditunjukin di Listing 2-2.

$ cargo build
  Updating crates.io index
   Locking 15 packages to latest Rust 1.85.0 compatible versions
    Adding rand v0.8.5 (available: v0.9.0)
 Compiling proc-macro2 v1.0.93
 Compiling unicode-ident v1.0.17
 Compiling libc v0.2.170
 Compiling cfg-if v1.0.0
 Compiling byteorder v1.5.0
 Compiling getrandom v0.2.15
 Compiling rand_core v0.6.4
 Compiling quote v1.0.38
 Compiling syn v2.0.98
 Compiling zerocopy-derive v0.7.35
 Compiling zerocopy v0.7.35
 Compiling ppv-lite86 v0.2.20
 Compiling rand_chacha v0.3.1
 Compiling rand v0.8.5
 Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
  Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.48s
Listing 2-2: Output dari jalanin cargo build setelah nambahin rand crate sebagai dependensi

Mungkin kita bakal liat nomor versi yang beda (tapi semuanya bakal kompatibel sama kodenya, makasih buat SemVer!) dan baris-baris yang beda (tergantung sistem operasi), dan urutannya mungkin beda juga.

Pas kita masukin external dependency, Cargo bakal ngambil versi terbaru dari segala hal yang dibutuhin dependency itu dari registry, yang merupakan copy data dari Crates.io. Crates.io adalah tempat orang-orang di ekosistem Rust posting project open source Rust mereka biar bisa dipake orang lain.

Setelah update registry, Cargo cek section [dependencies] terus download crate apa pun yang terdaftar tapi belum ada di komputer kita. Dalam hal ini, walaupun kita cuma daftarin rand sebagai dependensi, Cargo juga ngambil crates lain yang dibutuhin rand biar bisa jalan. Setelah download crates-nya, Rust bakal compile crates itu terus compile project kita dengan dependensi yang udah siap.

Kalau kita langsung jalanin cargo build lagi tanpa ngerubah apa pun, kita nggak bakal dapet output apa-apa selain baris Finished. Cargo tau kalau dia udah download dan compile dependensi-nya, dan kita nggak ngerubah apa pun soal itu di file Cargo.toml. Cargo juga tau kalau kita nggak ngerubah kode kita, jadi dia nggak compile ulang juga. Karena nggak ada kerjaan, dia langsung exit.

Kalau kita buka file src/main.rs, bikin perubahan kecil, terus save dan build lagi, kita cuma bakal liat dua baris output:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s

Baris-baris ini nunjukin kalau Cargo cuma update build-nya sama perubahan kecil kita di file src/main.rs. Dependensi kita nggak berubah, jadi Cargo tau dia bisa pake lagi apa yang udah dia download dan compile sebelumnya.

Mastiin Build Bisa Direproduksi (Reproducible) dengan File Cargo.lock

Cargo punya mekanisme yang mastiin kita bisa rebuild artifact yang sama tiap kali kita atau orang lain build kode kita: Cargo cuma bakal pake versi dependensi yang kita tentuin sampe kita bilang sebaliknya. Misalnya, katakanlah minggu depan versi 0.8.6 dari rand crate keluar, dan versi itu isinya bug fix penting, tapi ada juga regresi yang bikin kode kita rusak. Buat handle ini, Rust bikin file Cargo.lock pas pertama kali kita jalanin cargo build, jadi sekarang kita punya file ini di direktori guessing_game.

Pas kita build project buat pertama kali, Cargo cari tau semua versi dependensi yang cocok sama kriterianya terus nulis versi itu ke file Cargo.lock. Pas kita build project-nya nanti, Cargo bakal liat kalau file Cargo.lock ada terus bakal pake versi yang ditentuin di situ bukannya cari-cari versi lagi. Ini bikin kita punya reproducible build secara otomatis. Dengan kata lain, project kita bakal tetep di versi 0.8.5 sampe kita eksplisit buat upgrade, berkat file Cargo.lock. Karena file Cargo.lock itu penting buat reproducible builds, filenya sering kali dimasukan ke source control barengan sama kode project lainnya.

Update Crate buat Dapet Versi Baru

Pas kita emang mau update sebuah crate, Cargo nyediain perintah update, yang bakal cuekin file Cargo.lock terus cari tau semua versi terbaru yang cocok sama spesifikasi kita di Cargo.toml. Cargo terus bakal nulis versi itu ke file Cargo.lock. Dalam hal ini, Cargo cuma bakal nyari versi yang lebih gede dari 0.8.5 dan kurang dari 0.9.0. Kalau rand crate udah ngerilis dua versi baru 0.8.6 dan 0.9.0, kita bakal liat output kayak gini pas jalanin cargo update:

$ cargo update
    Updating crates.io index
     Locking 1 package to latest Rust 1.85.0 compatible version
    Updating rand v0.8.5 -> v0.8.6 (available: v0.9.0)

Cargo cuekin rilis 0.9.0. Di titik ini, kita juga bakal nyadar ada perubahan di file Cargo.lock yang nyatet kalau versi rand crate yang sekarang kita pake itu 0.8.6. Buat pake rand versi 0.9.0 atau versi mana pun di seri 0.9.x, kita harus update file Cargo.toml-nya jadi kayak gini:

[dependencies]
rand = "0.9.0"

Lain kali kita jalanin cargo build, Cargo bakal update registry crate yang tersedia terus evaluasi ulang kebutuhan rand kita sesuai versi baru yang kita tentuin.

Masih banyak lagi hal soal Cargo dan ekosistemnya yang bakal kita bahas di Bab 14, tapi buat sekarang, itu aja yang perlu kita tau. Cargo bikin kita gampang sekali buat reuse library, jadi para Rustacean bisa nulis project kecil yang disusun dari banyak packages.

Menghasilkan Angka Random

Yuk mulai pake rand buat nge-generate angka buat ditebak. Langkah selanjutnya adalah update src/main.rs, kayak yang ditunjukin di Listing 2-3.

Filename: src/main.rs
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}
Listing 2-3: Nambahin kode buat nge-generate angka random

Pertama kita tambahin baris use rand::Rng;. Trait Rng nentuin methods yang diimplementasiin sama random number generators, dan trait ini harus ada di scope biar kita bisa pake methods itu. Bab 10 bakal bahas traits lebih detail.

Selanjutnya, kita nambahin dua baris di tengah. Di baris pertama, kita manggil fungsi rand::thread_rng yang ngasih kita random number generator tertentu yang bakal kita pake: yang lokal buat thread eksekusi saat ini dan dikasih seed sama sistem operasi. Terus kita manggil method gen_range pada random number generator-nya. Method ini didefinisiin sama trait Rng yang kita bawa ke scope lewat statement use rand::Rng;. Method gen_range ngambil ekspresi range sebagai argumen terus nge-generate angka random di dalem range itu. Jenis ekspresi range yang kita pake di sini bentuknya start..=end dan inklusif (termasuk) batas bawah sama batas atasnya, jadi kita perlu nulis 1..=100 buat minta angka antara 1 sampe 100.

Catatan: Kita nggak bakal tau gitu aja trait mana yang harus dipake sama method dan fungsi mana yang harus dipanggil dari sebuah crate, makanya tiap crate punya dokumentasi yang isinya instruksi buat pake crate itu. Fitur keren lainnya dari Cargo adalah jalanin perintah cargo doc --open bakal build dokumentasi yang disediain sama semua dependensi kita secara lokal terus buka di browser. Kalau kita penasaran sama fitur lainnya di rand crate, misalnya, jalanin cargo doc --open terus klik rand di sidebar sebelah kiri.

Baris baru yang kedua nyetak secret number-nya. Ini berguna pas kita lagi ngembangin programnya biar bisa dites, tapi nanti kita hapus di versi final. Nggak seru dong gamenya kalau programnya langsung ngasih tau jawabannya pas baru mulai!

Coba jalanin programnya beberapa kali:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 7
Please input your guess.
4
You guessed: 4

$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 83
Please input your guess.
5
You guessed: 5

Kita harusnya dapet angka random yang beda-beda, dan semuanya harusnya angka antara 1 sampe 100. Mantap!

Membandingkan Tebakan dengan Secret Number

Sekarang setelah kita dapet input user sama angka random, kita bisa bandingin keduanya. Langkah itu ditunjukin di Listing 2-4. Inget ya kalau kode ini belum bisa di-compile sekarang, nanti kita jelasin kenapa.

Filename: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    // --snip--
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}
Listing 2-4: Menangani kemungkinan nilai balik dari membandingkan dua angka

Pertama kita tambahin statement use lagi, bawa tipe namanya std::cmp::Ordering ke scope dari standard library. Tipe Ordering ini adalah enum lainnya dan punya varian Less, Greater, dan Equal. Ini adalah tiga hasil yang mungkin terjadi pas kita bandingin dua nilai.

Terus kita tambahin lima baris baru di bawah yang pake tipe Ordering. Method cmp bandingin dua nilai dan bisa dipanggil pada apa pun yang bisa dibandingin. Dia ngambil reference ke apa pun yang mau kita bandingin: di sini dia bandingin guess sama secret_number. Terus dia balikin varian dari enum Ordering yang kita bawa ke scope tadi. Kita pake ekspresi match buat nentuin apa yang harus dilakuin selanjutnya berdasarkan varian Ordering mana yang dibalikin dari pemanggilan cmp sama nilai di guess dan secret_number.

Ekspresi match itu disusun dari arms. Sebuah arm isinya sebuah pattern (pola) buat dicocokin, sama kode yang harus jalan kalau nilai yang dikasih ke match cocok sama pattern di arm itu. Rust ngambil nilai yang dikasih ke match terus liat tiap pattern di tiap arm secara bergantian. Pattern sama konstruk match itu fitur Rust yang sangat kuat: mereka ngebolehin kita mengekspresikan berbagai situasi yang mungkin dihadapi kode kita dan mastiin kita handle semuanya. Fitur-fitur ini bakal dibahas detail di Bab 6 dan Bab 19.

Yuk kita telusuri contoh ekspresi match yang kita pake di sini. Katakanlah user nebak 50 dan secret number yang di-generate random kali ini adalah 38.

Pas kodenya bandingin 50 sama 38, method cmp bakal balikin Ordering::Greater karena 50 lebih besar dari 38. Ekspresi match dapet nilai Ordering::Greater terus mulai cek tiap pattern di tiap arm. Dia liat pattern di arm pertama, Ordering::Less, dan liat kalau nilai Ordering::Greater nggak cocok sama Ordering::Less, jadi dia cuekin kode di arm itu terus lanjut ke arm berikutnya. Pattern arm berikutnya itu Ordering::Greater, yang emang cocok sama Ordering::Greater! Kode yang terkait di arm itu bakal jalan terus nyetak Too big! ke layar. Ekspresi match berakhir setelah match pertama yang berhasil, jadi dia nggak bakal liat arm terakhir di skenario ini.

Tapi, kode di Listing 2-4 belum bisa di-compile. Yuk kita coba:

$ cargo build
   Compiling libc v0.2.86
   Compiling getrandom v0.2.2
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.10
   Compiling rand_core v0.6.2
   Compiling rand_chacha v0.3.0
   Compiling rand v0.8.5
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
  --> src/main.rs:23:21
   |
23 |     match guess.cmp(&secret_number) {
   |                 --- ^^^^^^^^^^^^^^ expected `&String`, found `&{integer}`
   |                 |
   |                 arguments to this method are incorrect
   |
   = note: expected reference `&String`
              found reference `&{integer}`
note: method defined here
  --> /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/core/src/cmp.rs:979:8

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

Inti dari error-nya bilang kalau ada mismatched types (tipe nggak cocok). Rust punya sistem tipe yang kuat dan statis. Tapi, dia juga punya type inference (inferensi tipe). Pas kita nulis let mut guess = String::new(), Rust bisa tau kalau guess harusnya sebuah String dan nggak maksa kita buat nulis tipenya. Di sisi lain, secret_number itu tipe angka. Beberapa tipe angka di Rust bisa punya nilai antara 1 sampe 100: i32, angka 32-bit; u32, angka 32-bit tanpa tanda (unsigned); i64, angka 64-bit; dan lainnya. Kecuali ditentuin lain, Rust default-nya ke i32, yang merupakan tipe dari secret_number kecuali kita nambahin informasi tipe di tempat lain yang bakal bikin Rust tau tipe angka yang beda. Alasan kenapa ada error itu karena Rust nggak bisa bandingin tipe string sama tipe angka.

Akhirnya, kita mau convert String yang dibaca program sebagai input jadi tipe angka biar kita bisa bandingin secara numerik sama secret number-nya. Kita lakuin itu dengan nambahin baris ini ke body fungsi main:

Nama file: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    // --snip--

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse().expect("Please type a number!");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

Barisnya adalah:

let guess: u32 = guess.trim().parse().expect("Please type a number!");

Kita bikin variabel namanya guess. Tapi tunggu, bukannya programnya udah punya variabel namanya guess? Emang punya, tapi untungnya Rust ngebolehin kita buat shadow nilai guess yang lama sama yang baru. Shadowing ngebolehin kita pake lagi nama variabel guess bukannya maksa kita bikin dua variabel unik, kayak guess_str dan guess, misalnya. Kita bakal bahas ini lebih detail di Bab 3, tapi buat sekarang, tau aja kalau fitur ini sering dipake pas kita mau convert sebuah nilai dari satu tipe ke tipe lainnya.

Kita nge-bind variabel baru ini ke ekspresi guess.trim().parse(). guess yang ada di ekspresi itu ngerujuk ke variabel guess asli yang isinya input sebagai string. Method trim pada instance String bakal ngilangin whitespace apa pun di awal dan akhir, yang emang harus kita lakuin sebelum kita bisa convert string-nya jadi u32, yang cuma bisa berisi data numerik. User harus teken enter buat menuhi read_line dan masukin tebakan mereka, yang nambahin karakter baris baru (newline) ke string-nya. Misalnya, kalau user ketik 5 terus teken enter, guess bakal keliatan kayak gini: 5\n. \n itu simbol buat “newline.” (Di Windows, nekan enter ngasilin carriage return sama newline, \r\n.) Method trim ngilangin \n atau \r\n, hasilnya cuma 5.

Method parse pada string convert sebuah string ke tipe lain. Di sini, kita pake itu buat convert dari string jadi angka. Kita perlu ngasih tau Rust tipe angka pasti yang kita mau dengan pake let guess: u32. Titik dua (:) setelah guess ngasih tau Rust kalau kita bakal annotasi tipe variabelnya. Rust punya beberapa tipe angka bawaan; u32 yang diliat di sini adalah 32-bit unsigned integer. Ini pilihan default yang bagus buat angka positif kecil. Kita bakal belajar tipe angka lainnya di Bab 3.

Terus, annotasi u32 di program contoh ini dan perbandingan sama secret_number bikin Rust tau kalau secret_number juga harusnya tipe u32. Jadi sekarang perbandingannya bakal terjadi antara dua nilai dengan tipe yang sama!

Method parse cuma bakal kerja pada karakter yang logisnya bisa di-convert jadi angka, jadi dia gampang sekali bikin error. Kalau misalnya string-nya isinya A👍%, nggak bakal ada cara buat convert itu jadi angka. Karena dia mungkin gagal, method parse balikin tipe Result, mirip kayak method read_line (yang udah dibahas tadi di “Menangani Potensi Gagal dengan Result). Kita bakal perlakukan Result ini dengan cara yang sama pake method expect lagi. Kalau parse balikin varian Err dari Result karena dia nggak bisa bikin angka dari string-nya, panggilan expect bakal bikin gamenya crash terus nyetak pesan yang kita kasih. Kalau parse berhasil convert string-nya jadi angka, dia bakal balikin varian Ok dari Result, terus expect bakal balikin angka yang kita mau dari nilai Ok itu.

Yuk kita jalanin programnya sekarang:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.26s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
  76
You guessed: 76
Too big!

Keren! Walaupun ada spasi yang ditambahin sebelum tebakannya, programnya tetep tau kalau user nebak 76. Jalanin programnya beberapa kali buat mastiin perilaku yang beda-beda dengan berbagai jenis input: tebak angkanya dengan bener, tebak angka yang ketinggian, sama tebak angka yang kerendahan.

Kita udah punya sebagian besar gamenya jalan sekarang, tapi user cuma bisa nebak sekali. Yuk kita ubah itu dengan nambahin loop!

Ngebolehin Banyak Tebakan dengan Looping

Keyword loop bikin loop yang nggak bakal berhenti (infinite loop). Kita bakal tambahin loop biar user punya lebih banyak kesempatan buat nebak angkanya:

Nama file: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    // --snip--

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        // --snip--


        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse().expect("Please type a number!");

        println!("You guessed: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => println!("You win!"),
        }
    }
}

Kayak yang kita liat, kita udah mindahin segala hal dari prompt input tebakan dan seterusnya ke dalem sebuah loop. Pastiin buat indentasi baris-baris di dalem loop-nya nambah empat spasi lagi masing-masing terus jalanin programnya lagi. Programnya sekarang bakal minta tebakan lagi selamanya, yang sebenernya malah bikin masalah baru. Kayaknya user nggak bisa quit nih!

User sebenernya selalu bisa matiin programnya pake keyboard shortcut ctrl-c. Tapi ada cara lain buat kabur dari monster yang nggak pernah kenyang ini, kayak yang disebutin pas bahas parse di “Membandingkan Tebakan dengan Secret Number”: kalau user masukin jawaban yang bukan angka, programnya bakal crash. Kita bisa manfaatin itu biar user bisa quit, kayak yang ditunjukin di sini:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.23s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 59
Please input your guess.
45
You guessed: 45
Too small!
Please input your guess.
60
You guessed: 60
Too big!
Please input your guess.
59
You guessed: 59
You win!
Please input your guess.
quit

thread 'main' panicked at src/main.rs:28:47:
Please type a number!: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Ngetik quit bakal bikin gamenya berhenti, tapi kayak yang kita liat, masukin input apa pun yang bukan angka juga bakal bikin gitu. Ini kurang oke sih; kita mau gamenya juga berhenti pas angkanya udah berhasil ditebak dengan bener.

Quit Setelah Tebakan Bener

Yuk program gamenya biar quit pas user menang dengan nambahin statement break:

Nama file: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse().expect("Please type a number!");

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

Nambahin baris break setelah You win! bikin programnya keluar dari loop pas user nebak secret number-nya dengan bener. Keluar dari loop juga berarti keluar dari program, karena loop itu bagian terakhir dari main.

Menangani Input Nggak Valid

Buat makin memperhalus perilaku gamenya, bukannya nge-crash-in program pas user input bukan angka, mendingan kita bikin gamenya cuekin input bukan angka itu biar user bisa lanjut nebak. Kita bisa lakuin itu dengan ngerubah baris di mana guess di-convert dari String jadi u32, kayak yang ditunjukin di Listing 2-5.

Filename: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        // --snip--

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}
Listing 2-5: Cuekin tebakan bukan-angka terus minta tebakan lagi bukannya bikin program crash

Kita ganti dari panggilan expect jadi ekspresi match biar bisa pindah dari nge-crash pas error jadi handle error-nya. Inget kalau parse balikin tipe Result dan Result itu enum yang punya varian Ok dan Err. Kita pake ekspresi match di sini, kayak yang kita lakuin sama hasil Ordering dari method cmp.

Kalau parse berhasil ngerubah string jadi angka, dia bakal balikin nilai Ok yang isinya angka hasilnya. Nilai Ok itu bakal cocok sama pattern arm pertama, terus ekspresi match bakal langsung balikin nilai num yang dihasilin parse dan ditaruh di dalem nilai Ok itu. Angka itu bakal berakhir tepat di tempat yang kita mau di variabel guess baru yang kita bikin.

Kalau parse nggak bisa ngerubah string jadi angka, dia bakal balikin nilai Err yang isinya informasi lebih lanjut soal error-nya. Nilai Err itu nggak cocok sama pattern Ok(num) di arm match pertama, tapi dia cocok sama pattern Err(_) di arm kedua. Garis bawah, _, itu nilai catch-all; di contoh ini, kita bilang kita mau nyocokin semua nilai Err, nggak peduli informasi apa yang ada di dalemnya. Jadi programnya bakal jalanin kode arm kedua, continue, yang ngasih tau program buat lanjut ke iterasi loop berikutnya dan minta tebakan lagi. Jadi, secara efektif, programnya cuekin semua error yang mungkin ditemuin parse!

Sekarang segala hal di programnya harusnya jalan sesuai harapan. Yuk coba:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 61
Please input your guess.
10
You guessed: 10
Too small!
Please input your guess.
99
You guessed: 99
Too big!
Please input your guess.
foo
Please input your guess.
61
You guessed: 61
You win!

Mantul! Dengan satu perubahan kecil terakhir, kita bakal selesein game tebak angka ini. Inget kalau programnya masih nyetak secret number-nya. Itu emang enak buat ngetes, tapi ngerusak gamenya. Yuk kita hapus println! yang ngeluarin secret number itu. Listing 2-6 nunjukin kode final-nya.

Filename: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}
Listing 2-6: Kode lengkap game tebak angka

Sampai titik ini, kita udah berhasil bikin game tebak angka. Selamat ya!

Ringkasan

Project ini adalah cara belajar langsung (hands-on) buat ngenalin kita ke banyak konsep baru di Rust: let, match, fungsi, penggunaan external crates, dan banyak lagi. Di beberapa bab ke depan, kita bakal belajar konsep-konsep ini lebih detail. Bab 3 bahas konsep yang ada di kebanyakan bahasa pemrograman, kayak variabel, tipe data, dan fungsi, terus nunjukin cara pakenya di Rust. Bab 4 bahas ownership, fitur yang bikin Rust beda dari bahasa lain. Bab 5 bahas structs sama sintaks method, terus Bab 6 jelasin gimana cara kerja enum.

Konsep Pemrograman Umum

Bab ini ngebahas konsep-konsep yang ada di hampir semua bahasa pemrograman dan gimana cara kerjanya di Rust. Banyak bahasa pemrograman yang sebenernya punya kemiripan di intinya. Nggak ada konsep di bab ini yang unik cuma buat Rust doang, tapi kita bakal bahas dalam konteks Rust dan jelasin konvensi cara pakenya.

Spesifiknya, kita bakal belajar soal variabel, tipe dasar, fungsi, komentar, sama control flow. Dasar-dasar ini bakal ada di tiap program Rust, dan mempelajarinya dari awal bakal ngasih kita pondasi yang kuat buat mulai.

Keywords

Bahasa Rust punya sekumpulan keywords yang dipesen (reserved) cuma buat dipake sama bahasanya aja, sama kayak di bahasa lain. Inget ya kalau kita nggak bisa pake kata-kata ini sebagai nama variabel atau fungsi. Kebanyakan keyword punya makna khusus, dan kita bakal pakenya buat macem-macem tugas di program Rust kita; ada beberapa yang sekarang belum ada fungsinya tapi udah di-reserve buat fitur yang mungkin ditambahin ke Rust nanti. Kita bisa liat daftar keyword-nya di Lampiran A.

Variabel dan Mutabilitas

Variabel dan Mutabilitas

Kayak yang udah disebutin di bagian “Menyimpan Nilai dengan Variabel”, secara default, variabel itu immutable (nggak bisa diubah). Ini salah satu cara Rust “nyenggol” kita buat nulis kode yang manfaatin keamanan dan kemudahan concurrency yang ditawarin Rust. Tapi, kita tetep punya opsi buat bikin variabel jadi mutable (bisa diubah). Yuk kita eksplor gimana dan kenapa Rust nyaranin kita buat lebih milih immutability, dan kenapa kadang kita malah mau milih buat nggak pakenya.

Pas sebuah variabel itu immutable, sekali nilainya di-bind ke sebuah nama, kita nggak bisa ngerubah nilai itu. Buat gambarin ini, coba bikin project baru namanya variables di direktori projects kita pake cargo new variables.

Terus, di direktori variables yang baru, buka src/main.rs terus ganti kodenya jadi kayak gini, yang sebenernya belum bisa di-compile sekarang:

Nama file: src/main.rs

fn main() {
    let x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is: {x}");
}

Simpan terus jalanin programnya pake cargo run. Kita bakal dapet pesan error soal immutability error, kayak yang ditunjukin di output ini:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("The value of x is: {x}");
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
2 |     let mut x = 5;
  |         +++

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

Contoh ini nunjukin gimana compiler ngebantu kita nemuin error di program kita. Error dari compiler emang kadang bikin kesel, tapi sebenernya itu cuma berarti program kita belum aman buat ngelakuin apa yang kita mau; itu bukan berarti kita bukan programmer yang jago! Para Rustacean yang udah pro pun tetep sering dapet error dari compiler.

Kita dapet pesan error cannot assign twice to immutable variable `x` karena kita nyoba buat ngasih nilai kedua ke variabel x yang immutable.

Penting sekali buat kita dapet compile-time error pas kita nyoba ngerubah nilai yang udah ditentuin sebagai immutable karena situasi ini bisa memicu bug. Kalau satu bagian kode kita jalan dengan asumsi kalau sebuah nilai nggak bakal berubah, terus bagian kode lain malah ngerubah nilai itu, ada kemungkinan bagian pertama tadi nggak bakal jalan sesuai desainnya. Penyebab bug kayak gini bisa susah sekali dilacak setelah kejadian, apalagi kalau bagian kode kedua ngerubah nilainya cuma “kadang-kadang” doang. Compiler Rust ngejamin kalau pas kita bilang sebuah nilai nggak bakal berubah, ya dia benar-benar nggak bakal berubah, jadi kita nggak perlu repot-repot jagain sendiri. Kode kita jadi lebih gampang buat dipahamin alurnya.

Tapi mutability emang bisa sangat berguna, dan bisa bikin kode lebih nyaman buat ditulis. Walaupun variabel itu immutable secara default, kita bisa bikin mereka jadi mutable dengan nambahin mut di depan nama variabelnya kayak yang kita lakuin di Bab 2. Nambahin mut juga ngasih tau maksud (intent) kita ke orang yang baca kode kita nanti kalau bagian lain dari kode bakal ngerubah nilai variabel ini.

Contohnya, yuk kita ubah src/main.rs jadi kayak gini:

Nama file: src/main.rs

fn main() {
    let mut x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is: {x}");
}

Pas kita jalanin programnya sekarang, hasilnya kayak gini:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/variables`
The value of x is: 5
The value of x is: 6

Kita diperbolehkan buat ngerubah nilai yang di-bind ke x dari 5 jadi 6 pas mut dipake. Akhirnya, keputusan buat pake mutability atau nggak itu balik lagi ke kita dan tergantung apa yang menurut kita paling jelas di situasi tertentu itu.

Konstanta (Constants)

Kayak variabel immutable, konstanta adalah nilai yang di-bind ke sebuah nama dan nggak boleh berubah, tapi ada beberapa perbedaan antara konstanta sama variabel.

Pertama, kita nggak boleh pake mut sama konstanta. Konstanta nggak cuma immutable secara default—mereka selalu immutable. Kita mendeklarasikan konstanta pake keyword const bukannya let, dan tipe nilainya harus diannotasi. Kita bakal bahas soal tipe dan annotasi tipe di bagian selanjutnya, “Tipe Data”, jadi nggak usah pusing dulu soal detailnya sekarang. Pokoknya tau aja kalau kita harus selalu nulis tipenya.

Konstanta bisa dideklarasikan di scope mana pun, termasuk scope global, yang bikin mereka berguna buat nilai yang perlu diketahuin sama banyak bagian kode.

Perbedaan terakhir adalah konstanta cuma boleh di-set ke constant expression, bukan hasil dari nilai yang cuma bisa dihitung pas runtime.

Ini contoh deklarasi konstanta:

#![allow(unused)]
fn main() {
const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;
}

Nama konstantanya adalah THREE_HOURS_IN_SECONDS dan nilainya di-set ke hasil perkalian 60 (jumlah detik dalam satu menit) dikali 60 (jumlah menit dalam satu jam) dikali 3 (jumlah jam yang mau kita itung di program ini). Konvensi penamaan Rust buat konstanta adalah pake huruf kapital semua (uppercase) dengan garis bawah (underscore) di antara kata-katanya. Compiler bisa nge-evaluasi sekumpulan operasi terbatas pas compile time, yang bikin kita bisa milih buat nulis nilai ini dengan cara yang lebih gampang dipahamin dan diverifikasi, bukannya langsung nulis nilai 10.800. Liat bagian Rust Reference soal constant evaluation buat info lebih lanjut soal operasi apa aja yang bisa dipake pas deklarasi konstanta.

Konstanta itu valid selama program jalan, di dalem scope tempat mereka dideklarasikan. Sifat ini bikin konstanta berguna buat nilai di domain aplikasi kita yang mungkin perlu diketahuin sama banyak bagian program, kayak jumlah poin maksimal yang boleh didapet player sebuah game, atau kecepatan cahaya.

Ngambil nilai hardcoded yang dipake di seluruh program terus dikasih nama sebagai konstanta itu sangat berguna buat nyampein makna nilai itu ke orang yang bakal maintain kodenya nanti. Ini juga ngebantu biar cuma ada satu tempat di kode kita yang perlu diubah kalau nilai hardcoded itu perlu di-update di masa depan.

Shadowing

Kayak yang kita liat di tutorial game tebak angka di Bab 2, kita bisa mendeklarasikan variabel baru dengan nama yang sama kayak variabel sebelumnya. Para Rustacean bilang kalau variabel pertama itu di-shadow (dibayangi) sama variabel kedua, yang artinya variabel kedua lah yang bakal diliat sama compiler pas kita pake nama variabel itu. Efektifnya, variabel kedua menutupi variabel pertama, ngambil semua penggunaan nama variabel itu buat dirinya sendiri sampe dia sendiri di-shadow atau scope-nya abis. Kita bisa nge-shadow sebuah variabel dengan pake nama variabel yang sama dan ngulangin penggunaan keyword let kayak gini:

Nama file: src/main.rs

fn main() {
    let x = 5;

    let x = x + 1;

    {
        let x = x * 2;
        println!("The value of x in the inner scope is: {x}");
    }

    println!("The value of x is: {x}");
}

Program ini pertama-tama nge-bind x ke nilai 5. Terus dia bikin variabel baru x dengan ngulangin let x =, ngambil nilai aslinya terus ditambahin 1 biar nilai x jadi 6. Terus, di dalem scope dalem yang dibuat pake kurung kurawal, statement let yang ketiga juga nge-shadow x dan bikin variabel baru, ngaliin nilai sebelumnya sama 2 biar x jadi 12. Pas scope itu abis, shadowing dalemnya kelar dan x balik lagi jadi 6. Pas kita jalanin program ini, output-nya bakal kayak gini:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/variables`
The value of x in the inner scope is: 12
The value of x is: 6

Shadowing itu beda sama nandain variabel sebagai mut karena kita bakal dapet compile-time error kalau kita nggak sengaja nyoba buat nge-assign ulang ke variabel ini tanpa pake keyword let. Dengan pake let, kita bisa ngelakuin beberapa transformasi pada sebuah nilai tapi tetep bikin variabelnya jadi immutable setelah transformasi itu selesai.

Perbedaan lain antara mut sama shadowing adalah karena kita sebenernya bikin variabel baru pas kita pake keyword let lagi, kita bisa ngerubah tipe nilainya tapi tetep pake nama yang sama. Misalnya, katakanlah program kita minta user buat nunjukin berapa banyak spasi yang mereka mau di antara teks dengan masukin karakter spasi, terus kita mau nyimpen input itu sebagai angka:

fn main() {
    let spaces = "   ";
    let spaces = spaces.len();
}

Variabel spaces yang pertama itu tipe string dan variabel spaces yang kedua itu tipe angka. Jadi shadowing bikin kita nggak perlu repot mikirin nama yang beda, kayak spaces_str dan spaces_num; mendingan kita pake lagi nama spaces yang lebih simpel. Tapi, kalau kita nyoba pake mut buat hal ini, kayak yang ditunjukin di sini, kita bakal dapet compile-time error:

fn main() {
    let mut spaces = "   ";
    spaces = spaces.len();
}

Error-nya bilang kalau kita nggak diperbolehkan buat nge-mutasi tipe variabel:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0308]: mismatched types
 --> src/main.rs:3:14
  |
2 |     let mut spaces = "   ";
  |                      ----- expected due to this value
3 |     spaces = spaces.len();
  |              ^^^^^^^^^^^^ expected `&str`, found `usize`

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

Sekarang setelah kita eksplor gimana cara kerja variabel, yuk kita liat tipe data lainnya yang bisa mereka punya.

Tipe Data

Tipe Data

Tiap nilai di Rust itu punya data type (tipe data) tertentu, yang ngasih tau Rust jenis data apa yang kita maksud biar dia tau gimana cara nanganin data itu. Kita bakal liat dua subset tipe data: scalar sama compound.

Inget ya kalau Rust itu bahasa yang statically typed, artinya dia harus tau tipe dari semua variabel pas compile time. Compiler biasanya bisa tau (infer) tipe apa yang mau kita pake berdasarkan nilainya dan gimana kita pakenya. Di kasus di mana ada banyak kemungkinan tipe, kayak pas kita convert sebuah String jadi tipe numerik pake parse di bagian “Membandingkan Tebakan dengan Secret Number” di Bab 2, kita harus nambahin annotasi tipe, kayak gini:

#![allow(unused)]
fn main() {
let guess: u32 = "42".parse().expect("Bukan angka!");
}

Kalau kita nggak nambahin annotasi tipe : u32 kayak di atas, Rust bakal nampilin error berikut, yang artinya compiler butuh info lebih lanjut dari kita biar tau tipe mana yang mau kita pake:

$ cargo build
   Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0284]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^        ----- type must be known at this point
  |
  = note: cannot satisfy `<_ as FromStr>::Err == _`
help: consider giving `guess` an explicit type
  |
2 |     let guess: /* Type */ = "42".parse().expect("Not a number!");
  |              ++++++++++++

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

Kita bakal liat annotasi tipe yang beda buat tipe data lainnya.

Tipe Scalar

Tipe scalar merepresentasikan sebuah nilai tunggal. Rust punya empat tipe scalar utama: integer, floating-point numbers, Boolean, dan karakter. Kita mungkin udah kenal ini dari bahasa pemrograman lain. Yuk kita liat gimana cara kerjanya di Rust.

Tipe Integer

Integer itu angka tanpa komponen pecahan. Kita udah pake satu tipe integer di Bab 2, yaitu tipe u32. Deklarasi tipe ini nunjukin kalau nilai yang terkait harusnya sebuah unsigned integer (tipe signed integer diawali sama i bukannya u) yang makan tempat 32 bits. Tabel 3-1 nunjukin tipe-tipe integer bawaan di Rust. Kita bisa pake varian mana pun dari ini buat mendeklarasikan tipe dari sebuah nilai integer.

Tabel 3-1: Tipe Integer di Rust

PanjangSignedUnsigned
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
128-biti128u128
architecture dependentisizeusize

Tiap varian bisa jadi signed atau unsigned dan punya ukuran yang eksplisit. Signed sama unsigned ngerujuk ke apakah mungkin buat angkanya bernilai negatif—dengan kata lain, apakah angkanya perlu punya tanda (sign) barengannya (signed) atau apakah dia cuma bakal selalu positif dan makanya bisa direpresentasikan tanpa tanda (unsigned). Ini kayak nulis angka di kertas: pas tandanya penting, angka ditunjukin pake tanda plus atau minus; tapi pas aman buat diasumsikan kalau angkanya positif, dia ditunjukin tanpa tanda. Angka signed disimpan pake representasi two’s complement.

Tiap varian signed bisa nyimpen angka dari −(2n − 1) sampe 2n − 1 − 1 inklusif, di mana n itu jumlah bit yang dipake varian tersebut. Jadi sebuah i8 bisa nyimpen angka dari −(27) sampe 27 − 1, yang sama dengan −128 sampe 127. Varian unsigned bisa nyimpen angka dari 0 sampe 2n − 1, jadi sebuah u8 bisa nyimpen angka dari 0 sampe 28 − 1, yang sama dengan 0 sampe 255.

Terus, tipe isize sama usize itu tergantung dari arsitektur komputer tempat program kita jalan: 64 bits kalau kita di arsitektur 64-bit dan 32 bits kalau kita di arsitektur 32-bit.

Kita bisa nulis literal integer dalam bentuk apa pun yang ditunjukin di Tabel 3-2. Inget ya kalau literal angka yang bisa punya banyak tipe numerik ngebolehin ada akhiran (suffix) tipe, kayak 57u8, buat nentuin tipenya. Literal angka juga bisa pake _ sebagai pemisah visual biar angkanya lebih gampang dibaca, kayak 1_000, yang bakal punya nilai yang sama kayak kalau kita tulis 1000.

Tabel 3-2: Literal Integer di Rust

Literal AngkaContoh
Desimal98_222
Hex0xff
Oktal0o77
Biner0b1111_0000
Byte (u8 doang)b'A'

Terus gimana kita tau tipe integer mana yang harus dipake? Kalau bingung, default-nya Rust biasanya udah oke sekali: tipe integer default ke i32. Situasi utama di mana kita bakal pake isize atau usize itu pas lagi ngindeks sekumpulan koleksi (collection).

Integer Overflow

Katakanlah kita punya variabel tipe u8 yang bisa nampung nilai antara 0 sampe 255. Kalau kita nyoba ngerubah variabel itu jadi nilai di luar range itu, kayak 256, bakal terjadi integer overflow, yang bisa ngasilin salah satu dari dua perilaku. Pas kita compile di mode debug, Rust masukin pengecekan buat integer overflow yang bikin program kita panic pas runtime kalau perilaku ini kejadian. Rust pake istilah panicking pas sebuah program exit karena error; kita bakal bahas panic lebih dalem di bagian “Error yang Tidak Bisa Dipulihkan dengan panic! di Bab 9.

Pas kita compile di mode release pake flag --release, Rust nggak masukin pengecekan buat integer overflow yang bikin panic. Sebaliknya, kalau overflow kejadian, Rust ngelakuin two’s complement wrapping. Singkatnya, nilai yang lebih gede dari nilai maksimal yang bisa ditampung tipenya bakal “bungkus muter” (wrap around) ke nilai minimal yang bisa ditampung tipenya. Di kasus u8, nilai 256 jadi 0, nilai 257 jadi 1, dan seterusnya. Programnya nggak bakal panic, tapi variabelnya bakal punya nilai yang mungkin nggak sesuai ekspektasi kita. Ngandelin perilaku wrapping dari integer overflow itu dianggap sebagai error.

Buat handle kemungkinan overflow secara eksplisit, kita bisa pake keluarga method yang dikasih standard library buat tipe numerik primitif:

  • Wrap di semua mode pake method wrapping_*, kayak wrapping_add.
  • Balikin nilai None kalau ada overflow pake method checked_*.
  • Balikin nilainya sama Boolean yang nunjukin apakah ada overflow pake method overflowing_*.
  • Saturate di nilai minimal atau maksimal tipenya pake method saturating_*.

Tipe Floating-Point

Rust juga punya dua tipe primitif buat floating-point numbers, yaitu angka dengan titik desimal. Tipe floating-point di Rust adalah f32 dan f64, yang masing-masing ukurannya 32 bits dan 64 bits. Tipe default-nya adalah f64 karena di CPU modern, kecepatannya hampir sama kayak f32 tapi bisa lebih presisi. Semua tipe floating-point itu signed.

Ini contoh tipe floating-point beraksi:

Nama file: src/main.rs

fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

Angka floating-point direpresentasikan sesuai standar IEEE-754.

Operasi Numerik

Rust support operasi matematika dasar yang kita harapin buat semua tipe angka: penambahan, pengurangan, perkalian, pembagian, dan sisa bagi (remainder). Pembagian integer bakal dipotong (truncate) ke arah nol ke integer terdekat. Kode berikut nunjukin gimana cara pake tiap operasi numerik di statement let:

Nama file: src/main.rs

fn main() {
    // addition
    let sum = 5 + 10;

    // subtraction
    let difference = 95.5 - 4.3;

    // multiplication
    let product = 4 * 30;

    // division
    let quotient = 56.7 / 32.2;
    let truncated = -5 / 3; // Results in -1

    // remainder
    let remainder = 43 % 5;
}

Tiap ekspresi di statement ini pake operator matematika terus dievaluasi jadi satu nilai tunggal, yang terus di-bind ke sebuah variabel. Lampiran B isinya daftar semua operator yang disediain Rust.

Tipe Boolean

Kayak di kebanyakan bahasa pemrograman lain, tipe Boolean di Rust punya dua kemungkinan nilai: true sama false. Boolean ukurannya satu byte. Tipe Boolean di Rust ditentuin pake bool. Contohnya:

Nama file: src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // with explicit type annotation
}

Cara utama buat pake nilai Boolean itu lewat kondisional, kayak ekspresi if. Kita bakal bahas gimana cara kerja ekspresi if di Rust di bagian “Control Flow”.

Tipe Karakter (Character)

Tipe char di Rust adalah tipe alfabetik paling primitif di bahasanya. Ini beberapa contoh deklarasi nilai char:

Nama file: src/main.rs

fn main() {
    let c = 'z';
    let z: char = 'ℤ'; // with explicit type annotation
    let heart_eyed_cat = '😻';
}

Inget ya kalau kita nentuin literal char pake kutip tunggal, beda sama literal string yang pake kutip ganda. Tipe char di Rust ukurannya empat byte dan merepresentasikan Unicode Scalar Value, yang artinya dia bisa merepresentasikan jauh lebih banyak dari cuma ASCII doang. Huruf beraksen; karakter Cina, Jepang, dan Korea; emoji; sama zero-width spaces itu semua adalah nilai char yang valid di Rust. Unicode Scalar Values range-nya dari U+0000 sampe U+D7FF dan U+E000 sampe U+10FFFF inklusif. Tapi, sebuah “karakter” itu sebenernya bukan konsep di Unicode, jadi intuisi manusia kita soal apa itu “karakter” mungkin nggak pas sama apa itu char di Rust. Kita bakal bahas topik ini detail di “Menyimpan Teks Berkode UTF-8 dengan Strings” di Bab 8.

Tipe Compound

Tipe compound (campuran) bisa ngelempokin banyak nilai jadi satu tipe. Rust punya dua tipe compound primitif: tuple sama array.

Tipe Tuple

Sebuah tuple adalah cara umum buat ngelempokin sejumlah nilai dengan berbagai macam tipe jadi satu tipe compound. Tuple punya panjang yang tetap: sekali dideklarasikan, ukurannya nggak bisa nambah atau berkurang.

Kita bikin tuple dengan nulis daftar nilai yang dipisahin koma di dalem tanda kurung. Tiap posisi di tuple punya tipe, dan tipe-tipe dari nilai yang beda di tuple itu nggak harus sama. Kita udah nambahin annotasi tipe opsional di contoh ini:

Nama file: src/main.rs

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

Variabel tup nge-bind ke seluruh tuple karena sebuah tuple dianggap sebagai elemen compound tunggal. Buat dapet nilai individunya dari sebuah tuple, kita bisa pake pattern matching buat destructure sebuah nilai tuple, kayak gini:

Nama file: src/main.rs

fn main() {
    let tup = (500, 6.4, 1);

    let (x, y, z) = tup;

    println!("The value of y is: {y}");
}

Program ini pertama-tama bikin tuple terus nge-bind ke variabel tup. Terus dia pake pattern bareng let buat ngambil tup terus diubah jadi tiga variabel terpisah, x, y, dan z. Ini namanya destructuring karena dia mecah satu tuple jadi tiga bagian. Akhirnya, programnya nyetak nilai y, yaitu 6.4.

Kita juga bisa akses elemen tuple secara langsung pake tanda titik (.) diikuti sama indeks nilai yang mau kita akses. Contohnya:

Nama file: src/main.rs

fn main() {
    let x: (i32, f64, u8) = (500, 6.4, 1);

    let five_hundred = x.0;

    let six_point_four = x.1;

    let one = x.2;
}

Program ini bikin tuple x terus akses tiap elemen tuple pake indeksnya masing-masing. Kayak di kebanyakan bahasa pemrograman, indeks pertama di tuple itu 0.

Tuple tanpa nilai apa pun punya nama khusus, yaitu unit. Nilai ini sama tipe terkaitnya sama-sama ditulis () dan merepresentasikan nilai kosong atau tipe return kosong. Ekspresi secara implisit balikin nilai unit kalau mereka nggak balikin nilai lainnya.

Tipe Array

Cara lain buat punya sekumpulan banyak nilai itu pake array. Beda sama tuple, tiap elemen array harus punya tipe yang sama. Beda sama array di beberapa bahasa lain, array di Rust punya panjang yang tetap.

Kita nulis nilai-nilai di array sebagai daftar yang dipisahin koma di dalem kurung siku:

Nama file: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];
}

Array itu berguna pas kita mau data kita dialokasikan di stack, sama kayak tipe-tipe lain yang udah kita liat sejauh ini, bukannya di heap (kita bakal bahas stack sama heap lebih lanjut di Bab 4) atau pas kita mau mastiin kalau kita selalu punya jumlah elemen yang tetap. Tapi array itu nggak sefleksibel tipe vector. Vector adalah tipe koleksi serupa yang disediain standard library yang boleh nambah atau berkurang ukurannya karena isinya ada di heap. Kalau bingung mau pake array atau vector, kemungkinan besar mending pake vector. Bab 8 bahas vector lebih detail.

Tapi, array lebih berguna pas kita tau jumlah elemennya nggak perlu berubah. Misalnya, kalau kita lagi pake nama-nama bulan di sebuah program, kita mungkin bakal pake array bukannya vector karena kita tau isinya bakal selalu 12 elemen:

#![allow(unused)]
fn main() {
let months = ["Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli",
              "Agustus", "September", "Oktober", "November", "Desember"];
}

Kita nulis tipe array pake kurung siku yang isinya tipe tiap elemen, titik koma, terus jumlah elemen di array-nya, kayak gini:

#![allow(unused)]
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}

Di sini, i32 itu tipe tiap elemen. Setelah titik koma, angka 5 nunjukin kalau array-nya isinya lima elemen.

Kita juga bisa menginisialisasi array biar isinya nilai yang sama buat tiap elemen dengan nentuin nilai awalnya, diikuti titik koma, terus panjang array-nya di dalem kurung siku, kayak yang ditunjukin di sini:

#![allow(unused)]
fn main() {
let a = [3; 5];
}

Array namanya a bakal isinya 5 elemen yang semuanya bakal di-set ke nilai 3 pas awal. Ini sama aja kayak nulis let a = [3, 3, 3, 3, 3]; tapi dengan cara yang lebih singkat.

Akses Elemen Array

Array itu satu potongan memori tunggal dengan ukuran yang udah tau dan tetap yang bisa dialokasikan di stack. Kita bisa akses elemen array pake indexing, kayak gini:

Nama file: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];

    let first = a[0];
    let second = a[1];
}

Di contoh ini, variabel namanya first bakal dapet nilai 1 karena itu nilai di indeks [0] di array-nya. Variabel namanya second bakal dapet nilai 2 dari indeks [1] di array-nya.

Akses Elemen Array Nggak Valid

Yuk kita liat apa yang terjadi kalau kita nyoba akses elemen array yang ngelewatin akhir dari array-nya. Katakanlah kita jalanin kode ini, mirip kayak game tebak angka di Bab 2, buat dapet indeks array dari user:

Nama file: src/main.rs

use std::io;

fn main() {
    let a = [1, 2, 3, 4, 5];

    println!("Please enter an array index.");

    let mut index = String::new();

    io::stdin()
        .read_line(&mut index)
        .expect("Failed to read line");

    let index: usize = index
        .trim()
        .parse()
        .expect("Index entered was not a number");

    let element = a[index];

    println!("The value of the element at index {index} is: {element}");
}

Kode ini berhasil di-compile. Kalau kita jalanin kode ini pake cargo run terus masukin 0, 1, 2, 3, atau 4, programnya bakal nyetak nilai yang terkait di indeks itu di array-nya. Kalau kita malah masukin angka yang ngelewatin akhir array, kayak 10, kita bakal liat output kayak gini:

thread 'main' panicked at src/main.rs:19:19:
index out of bounds: the len is 5 but the index is 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Programnya ngasilin runtime error pas lagi pake nilai nggak valid di operasi indexing-nya. Programnya exit dengan pesan error dan nggak ngejalankan statement println! yang terakhir. Pas kita nyoba akses sebuah elemen pake indexing, Rust bakal nge-cek kalau indeks yang kita tentuin itu kurang dari panjang array-nya. Kalau indeksnya lebih gede dari atau sama dengan panjangnya, Rust bakal panic. Pengecekan ini harus kejadian pas runtime, apalagi di kasus ini, karena compiler nggak mungkin tau nilai apa yang bakal dimasukin user pas mereka jalanin kodenya nanti.

Ini contoh dari prinsip memory safety Rust yang lagi beraksi. Di banyak bahasa tingkat rendah (low-level), pengecekan kayak gini nggak dilakuin, dan pas kita ngasih indeks yang salah, memori yang nggak valid bisa diakses. Rust ngelindungin kita dari jenis error kayak gini dengan langsung exit bukannya ngebolehin akses memori itu terus lanjut. Bab 9 bakal bahas lebih banyak soal penanganan error di Rust dan gimana kita bisa nulis kode yang enak dibaca dan aman yang nggak panic maupun ngebolehin akses memori nggak valid.

Fungsi

Fungsi

Fungsi itu ada di mana-mana di kode Rust. Kita udah liat salah satu fungsi paling penting di bahasanya: fungsi main, yang jadi entry point buat banyak program. Kita juga udah liat keyword fn, yang ngebolehin kita mendeklarasikan fungsi baru.

Kode Rust pake snake case sebagai gaya konvensional buat nama fungsi sama variabel, di mana semua hurufnya kecil (lowercase) dan pake garis bawah (underscore) buat misahin kata. Ini program yang isinya contoh definisi fungsi:

Nama file: src/main.rs

fn main() {
    println!("Hello, world!");

    another_function();
}

fn another_function() {
    println!("Another function.");
}

Kita mendefinisikan fungsi di Rust dengan nulis fn diikuti sama nama fungsi dan tanda kurung. Kurung kurawal ngasih tau compiler di mana body fungsinya mulai sama selesai.

Kita bisa manggil fungsi apa pun yang udah kita definisikan dengan nulis namanya diikuti tanda kurung. Karena another_function didefinisikan di programnya, dia bisa dipanggil dari dalem fungsi main. Inget ya kalau kita mendefinisikan another_function setelah fungsi main di source code-nya; kita bisa aja mendefinisikannya sebelum main juga kok. Rust nggak peduli di mana kita mendefinisikan fungsi kita, yang penting mereka didefinisikan di suatu tempat di scope yang bisa diliat sama pemanggilnya.

Yuk kita bikin project biner baru namanya functions buat eksplor fungsi lebih lanjut. Taruh contoh another_function tadi di src/main.rs terus jalanin. Kita bakal liat output kayak gini:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.28s
     Running `target/debug/functions`
Hello, world!
Another function.

Baris-baris kodenya jalan sesuai urutan kemunculannya di fungsi main. Pertama pesan “Hello, world!” dicetak, terus another_function dipanggil dan pesannya dicetak.

Parameter

Kita bisa mendefinisikan fungsi biar punya parameter, yaitu variabel khusus yang jadi bagian dari signature sebuah fungsi. Pas sebuah fungsi punya parameter, kita bisa ngasih nilai konkret buat parameter itu. Secara teknis, nilai konkret itu namanya argument, tapi pas lagi ngobrol santai, orang-orang cenderung pake kata parameter sama argument secara bergantian buat nyebut variabel di definisi fungsi maupun nilai konkret yang dimasukin pas manggil fungsinya.

Di versi another_function ini kita nambahin satu parameter:

Nama file: src/main.rs

fn main() {
    another_function(5);
}

fn another_function(x: i32) {
    println!("The value of x is: {x}");
}

Coba jalanin program ini; kita bakal dapet output kayak gini:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.21s
     Running `target/debug/functions`
The value of x is: 5

Deklarasi another_function punya satu parameter namanya x. Tipe dari x ditentuin sebagai i32. Pas kita masukin 5 ke another_function, macro println! naruh 5 di tempat pasangan kurung kurawal yang isinya x di format string-nya.

Di signature fungsi, kita harus mendeklarasikan tipe dari tiap parameter. Ini keputusan yang disengaja di desainnya Rust: nuntut annotasi tipe di definisi fungsi artinya compiler hampir nggak pernah butuh kita buat nulis tipenya di tempat lain di kode buat cari tau tipe mana yang kita maksud. Compiler juga bisa ngasih pesan error yang lebih ngebantu kalau dia tau tipe apa yang diharapin sama fungsinya.

Pas mendefinisikan banyak parameter, pisahin deklarasi parameternya pake koma, kayak gini:

Nama file: src/main.rs

fn main() {
    print_labeled_measurement(5, 'h');
}

fn print_labeled_measurement(value: i32, unit_label: char) {
    println!("The measurement is: {value}{unit_label}");
}

Contoh ini bikin fungsi namanya print_labeled_measurement dengan dua parameter. Parameter pertama namanya value dan tipenya i32. Yang kedua namanya unit_label dan tipenya char. Fungsinya terus nyetak teks yang isinya baik value maupun unit_label.

Yuk coba jalanin kode ini. Ganti program yang ada di project functions kita di file src/main.rs sama contoh di atas terus jalanin pake cargo run:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/functions`
The measurement is: 5h

Karena kita manggil fungsinya dengan 5 sebagai nilai buat value dan 'h' sebagai nilai buat unit_label, output programnya isinya nilai-nilai itu.

Statement dan Ekspresi (Statements and Expressions)

Body fungsi itu disusun dari serangkaian statement yang opsional bisa diakhiri sama sebuah ekspresi. Sejauh ini, fungsi-fungsi yang kita bahas belum ada ekspresi akhirnya, tapi kita udah liat ekspresi sebagai bagian dari sebuah statement. Karena Rust itu bahasa yang berbasis ekspresi (expression-based language), ini perbedaan penting yang harus dipahamin. Bahasa lain nggak punya perbedaan yang sama, jadi yuk kita liat apa itu statement sama ekspresi dan gimana perbedaannya ngaruh ke body fungsi.

  • Statement adalah instruksi yang ngelakuin suatu aksi dan nggak balikin nilai.
  • Ekspresi dievaluasi jadi sebuah nilai hasil.

Yuk kita liat beberapa contoh.

Sebenernya kita udah pake statement sama ekspresi. Bikin variabel terus ngasih nilai ke variabel itu pake keyword let itu adalah sebuah statement. Di Listing 3-1, let y = 6; adalah sebuah statement.

Filename: src/main.rs
fn main() {
    let y = 6;
}
Listing 3-1: Deklarasi fungsi main yang isinya satu statement

Definisi fungsi juga termasuk statement; seluruh contoh di atas itu sebenernya sebuah statement. (Tapi kayak yang bakal kita liat di bawah, manggil fungsi itu bukan statement.)

Statement nggak balikin nilai. Makanya, kita nggak bisa nge-assign sebuah statement let ke variabel lain, kayak yang dicoba sama kode berikut; kita bakal dapet error:

Nama file: src/main.rs

fn main() {
    let x = (let y = 6);
}

Pas kita jalanin program ini, error yang kita dapet bakal keliatan kayak gini:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error: expected expression, found `let` statement
 --> src/main.rs:2:14
  |
2 |     let x = (let y = 6);
  |              ^^^
  |
  = note: only supported directly in conditions of `if` and `while` expressions

warning: unnecessary parentheses around assigned value
 --> src/main.rs:2:13
  |
2 |     let x = (let y = 6);
  |             ^         ^
  |
  = note: `#[warn(unused_parens)]` on by default
help: remove these parentheses
  |
2 -     let x = (let y = 6);
2 +     let x = let y = 6;
  |

warning: `functions` (bin "functions") generated 1 warning
error: could not compile `functions` (bin "functions") due to 1 previous error; 1 warning emitted

Statement let y = 6 nggak balikin nilai, jadi nggak ada apa-apa buat di-bind ke x. Ini beda sama apa yang terjadi di bahasa lain, kayak C sama Ruby, di mana assignment balikin nilai dari assignment-nya. Di bahasa-bahasa itu, kita bisa nulis x = y = 6 terus bikin baik x maupun y punya nilai 6; hal itu nggak berlaku di Rust.

Ekspresi dievaluasi jadi sebuah nilai dan nyusun sebagian besar sisa kode yang bakal kita tulis di Rust. Coba pikirin operasi matematika, kayak 5 + 6, yang merupakan ekspresi yang dievaluasi jadi nilai 11. Ekspresi bisa jadi bagian dari statement: di Listing 3-1, angka 6 di statement let y = 6; adalah sebuah ekspresi yang dievaluasi jadi nilai 6. Manggil fungsi itu adalah sebuah ekspresi. Manggil macro itu adalah sebuah ekspresi. Sebuah blok scope baru yang dibuat pake kurung kurawal juga ekspresi, contohnya:

Nama file: src/main.rs

fn main() {
    let y = {
        let x = 3;
        x + 1
    };

    println!("The value of y is: {y}");
}

Ekspresi ini:

{
    let x = 3;
    x + 1
}

adalah sebuah blok yang, dalam kasus ini, dievaluasi jadi 4. Nilai itu terus di-bind ke y sebagai bagian dari statement let. Inget ya kalau baris x + 1 nggak punya titik koma di akhirnya, beda sama kebanyakan baris yang udah kita liat sejauh ini. Ekspresi nggak pake titik koma di akhir. Kalau kita nambahin titik koma di akhir ekspresi, kita ngerubahnya jadi statement, dan dia nggak bakal balikin nilai. Terus inget ini pas kita eksplor nilai return fungsi sama ekspresi selanjutnya.

Fungsi dengan Nilai Return

Fungsi bisa balikin nilai ke kode yang manggil mereka. Kita nggak ngasih nama buat nilai return, tapi kita harus mendeklarasikan tipenya setelah tanda panah (->). Di Rust, nilai return dari sebuah fungsi itu sinonim sama nilai dari ekspresi terakhir di blok body fungsinya. Kita bisa return lebih awal dari sebuah fungsi pake keyword return terus nentuin nilainya, tapi kebanyakan fungsi balikin ekspresi terakhir secara implisit. Ini contoh fungsi yang balikin nilai:

Nama file: src/main.rs

fn five() -> i32 {
    5
}

fn main() {
    let x = five();

    println!("The value of x is: {x}");
}

Nggak ada pemanggilan fungsi, macro, atau bahkan statement let di fungsi five—cuma ada angka 5 sendirian. Itu fungsi yang sangat valid di Rust. Inget ya kalau tipe return fungsinya ditentuin juga, yaitu -> i32. Coba jalanin kode ini; output-nya bakal kayak gini:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/functions`
The value of x is: 5

Angka 5 di five adalah nilai return fungsinya, makanya tipe return-nya i32. Yuk kita pelajari ini lebih detail. Ada dua bagian penting: pertama, baris let x = five(); nunjukin kalau kita pake nilai return fungsi buat menginisialisasi variabel. Karena fungsi five balikin 5, baris itu sama aja kayak gini:

#![allow(unused)]
fn main() {
let x = 5;
}

Kedua, fungsi five nggak punya parameter dan mendefinisikan tipe nilai return-nya, tapi body fungsinya cuma angka 5 kesepian tanpa titik koma karena itu adalah ekspresi yang nilainya mau kita balikin.

Yuk liat contoh lainnya:

Nama file: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {x}");
}

fn plus_one(x: i32) -> i32 {
    x + 1
}

Jalanin kode ini bakal nyetak The value of x is: 6. Tapi kalau kita naruh titik koma di akhir baris yang isinya x + 1, ngerubahnya dari ekspresi jadi statement, kita bakal dapet error:

Nama file: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {x}");
}

fn plus_one(x: i32) -> i32 {
    x + 1;
}

Compile kode ini ngasilin error kayak gini:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error[E0308]: mismatched types
 --> src/main.rs:7:24
  |
7 | fn plus_one(x: i32) -> i32 {
  |    --------            ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
8 |     x + 1;
  |          - help: remove this semicolon to return this value

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

Pesan error utamanya, mismatched types, ngungkapin inti masalah kodenya. Definisi fungsi plus_one bilang kalau dia bakal balikin i32, tapi statement nggak dievaluasi jadi sebuah nilai, yang direpresentasikan sama (), yaitu tipe unit. Makanya, nggak ada apa pun yang dibalikin, yang bertentangan sama definisi fungsi dan ngasilin error. Di output ini, Rust ngasih pesan yang mungkin bisa ngebantu benerin masalah ini: dia nyaranin buat ngapus titik komanya, yang bakal benerin error-nya.

Komentar

Komentar

Semua programmer pasti pengen kodenya gampang dipahamin, tapi kadang emang butuh penjelasan tambahan. Di kasus kayak gini, programmer naruh komentar di source code mereka yang bakal dicuekin sama compiler tapi bakal berguna buat orang yang baca kodenya.

Ini contoh komentar simpel:

#![allow(unused)]
fn main() {
// hello, world
}

Di Rust, gaya komentar yang idiomatik itu dimulai pake dua garis miring (//), dan komentarnya lanjut sampe akhir baris. Buat komentar yang panjangnya lebih dari satu baris, kita perlu masukin // di tiap barisnya, kayak gini:

#![allow(unused)]
fn main() {
// Jadi kita lagi ngerjain sesuatu yang ribet di sini, cukup panjang sampe 
// kita butuh beberapa baris komentar buat jelasinnya! Fiuh! Semoga komentar 
// ini bisa jelasin apa yang sebenernya lagi terjadi.
}

Komentar juga bisa ditaruh di akhir baris yang isinya kode:

Nama file: src/main.rs

fn main() {
    let lucky_number = 7; // I'm feeling lucky today
}

Tapi kita bakal lebih sering liat komentar dipake dengan format kayak gini, di mana komentarnya ada di baris terpisah di atas kode yang lagi dianotasi:

Nama file: src/main.rs

fn main() {
    // I'm feeling lucky today
    let lucky_number = 7;
}

Rust juga punya jenis komentar lain, yaitu documentation comments, yang bakal kita bahas di bagian “Publishing a Crate to Crates.io” di Bab 14.

Control Flow (Alur Kontrol)

Control Flow

Kemampuan buat ngejalanin kode tergantung dari apakah sebuah kondisi itu true dan buat ngejalanin kode berulang kali pas sebuah kondisi itu true adalah blok dasar di kebanyakan bahasa pemrograman. Konstruk paling umum yang ngebolehin kita ngatur alur eksekusi kode Rust adalah ekspresi if sama loop.

Ekspresi if

Ekspresi if ngebolehin kita buat nyabangin kode tergantung kondisinya. Kita ngasih sebuah kondisi terus bilang, “Kalau kondisi ini terpenuhi, jalanin blok kode ini. Kalau nggak terpenuhi, jangan jalanin blok kode ini.”

Bikin project baru namanya branches di direktori projects kita buat eksplor ekspresi if. Di file src/main.rs, masukin kode berikut:

Nama file: src/main.rs

fn main() {
    let number = 3;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

Semua ekspresi if dimulai pake keyword if, diikuti sama sebuah kondisi. Di kasus ini, kondisinya nge-cek apakah variabel number punya nilai kurang dari 5. Kita taruh blok kode yang bakal jalan kalau kondisinya true tepat setelah kondisinya di dalem kurung kurawal. Blok kode yang terkait sama kondisi di ekspresi if kadang disebut arms (lengan), sama kayak arms di ekspresi match yang kita bahas di bagian “Membandingkan Tebakan dengan Secret Number” di Bab 2.

Opsionalnya, kita juga bisa masukin ekspresi else, kayak yang kita lakuin di sini, buat ngasih program blok kode alternatif buat jalan kalau kondisinya ternyata false. Kalau kita nggak ngasih ekspresi else dan kondisinya false, programnya bakal langsung lewatin blok if terus lanjut ke kode selanjutnya.

Coba jalanin kode ini; kita bakal dapet output kayak gini:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
condition was true

Yuk coba ubah nilai number jadi nilai yang bikin kondisinya false buat liat apa yang terjadi:

fn main() {
    let number = 7;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

Jalanin programnya lagi, terus liat output-nya:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
condition was false

Penting juga buat dicatet kalau kondisi di kode ini harus sebuah bool. Kalau kondisinya bukan bool, kita bakal dapet error. Contohnya, coba jalanin kode berikut:

Nama file: src/main.rs

fn main() {
    let number = 3;

    if number {
        println!("number was three");
    }
}

Kondisi if dievaluasi jadi nilai 3 kali ini, dan Rust ngelepar error:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: mismatched types
 --> src/main.rs:4:8
  |
4 |     if number {
  |        ^^^^^^ expected `bool`, found integer

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

Error-nya nunjukin kalau Rust ngarepin bool tapi dapetnya integer. Beda sama bahasa kayak Ruby sama JavaScript, Rust nggak bakal otomatis nyoba convert tipe non-Boolean jadi Boolean. Kita harus eksplisit dan selalu ngasih if sebuah Boolean sebagai kondisinya. Kalau kita mau blok kode if jalan cuma pas sebuah angka nggak sama dengan 0, misalnya, kita bisa ubah ekspresi if-nya jadi kayak gini:

Nama file: src/main.rs

fn main() {
    let number = 3;

    if number != 0 {
        println!("number was something other than zero");
    }
}

Jalanin kode ini bakal nyetak number was something other than zero.

Handle Banyak Kondisi dengan else if

Kita bisa pake banyak kondisi dengan ngelempokin if sama else di sebuah ekspresi else if. Contohnya:

Nama file: src/main.rs

fn main() {
    let number = 6;

    if number % 4 == 0 {
        println!("number is divisible by 4");
    } else if number % 3 == 0 {
        println!("number is divisible by 3");
    } else if number % 2 == 0 {
        println!("number is divisible by 2");
    } else {
        println!("number is not divisible by 4, 3, or 2");
    }
}

Program ini punya empat kemungkinan jalur yang bisa diambil. Setelah jalanin programnya, kita bakal liat output kayak gini:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
number is divisible by 3

Pas program ini jalan, dia cek tiap ekspresi if secara berurutan terus ngejalanin body pertama yang kondisinya dievaluasi jadi true. Inget ya walaupun 6 itu bisa dibagi 2, kita nggak liat output number is divisible by 2, dan kita juga nggak liat teks number is not divisible by 4, 3, or 2 dari blok else. Itu karena Rust cuma ngejalanin blok buat kondisi true yang pertama, dan sekali dia nemu satu, dia bahkan nggak bakal cek sisanya.

Pake terlalu banyak ekspresi else if bisa bikin kode kita berantakan, jadi kalau kita punya lebih dari satu, mendingan di-refactor kodenya. Bab 6 jelasin konstruk percabangan Rust yang sangat kuat namanya match buat kasus-kasus kayak gini.

Pake if di Statement let

Karena if itu adalah sebuah ekspresi, kita bisa pakenya di sisi kanan statement let buat ngasih hasil kondisinya ke sebuah variabel, kayak di Listing 3-2.

Filename: src/main.rs
fn main() {
    let condition = true;
    let number = if condition { 5 } else { 6 };

    println!("The value of number is: {number}");
}
Listing 3-2: Assign hasil dari ekspresi if ke sebuah variabel

Variabel number bakal di-bind ke sebuah nilai berdasarkan hasil dari ekspresi if. Jalanin kode ini buat liat apa yang terjadi:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/branches`
The value of number is: 5

Inget ya kalau blok kode dievaluasi jadi ekspresi terakhir di dalemnya, dan angka sendirian itu juga sebuah ekspresi. Di kasus ini, nilai dari seluruh ekspresi if tergantung dari blok kode mana yang jalan. Ini artinya nilai yang berpotensi jadi hasil dari tiap arm di if harus punya tipe yang sama; di Listing 3-2, hasil dari baik arm if maupun arm else adalah integer i32. Kalau tipenya nggak cocok, kayak di contoh berikut, kita bakal dapet error:

Nama file: src/main.rs

fn main() {
    let condition = true;

    let number = if condition { 5 } else { "six" };

    println!("The value of number is: {number}");
}

Pas kita nyoba compile kode ini, kita bakal dapet error. Arm if sama else punya tipe nilai yang nggak kompatibel, dan Rust nunjukin tepat di mana letak masalahnya di program kita:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: `if` and `else` have incompatible types
 --> src/main.rs:4:44
  |
4 |     let number = if condition { 5 } else { "six" };
  |                                 -          ^^^^^ expected integer, found `&str`
  |                                 |
  |                                 expected because of this

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

Ekspresi di blok if dievaluasi jadi integer, dan ekspresi di blok else dievaluasi jadi string. Ini nggak bakal bisa karena variabel harus punya tipe tunggal, dan Rust perlu tau pas compile time tipe apa variabel number itu, secara definitif. Tau tipe dari number bikin compiler bisa verifikasi kalau tipenya valid di mana pun kita pake number. Rust nggak bakal bisa ngelakuin itu kalau tipe number cuma bisa ditentuin pas runtime; compiler-nya bakal jadi lebih ribet dan bakal ngasih jaminan yang lebih dikit soal kodenya kalau dia harus jagain banyak tipe hipotetis buat variabel apa pun.

Pengulangan dengan Loops

Sering kali berguna buat ngejalanin sebuah blok kode lebih dari sekali. Buat tugas ini, Rust nyediain beberapa jenis loops, yang bakal ngejalanin kode di dalem body loop sampe selesai terus langsung mulai lagi dari awal. Buat eksperimen sama loops, yuk kita bikin project baru namanya loops.

Rust punya tiga jenis loop: loop, while, dan for. Yuk kita coba satu-satu.

Ngulang Kode pake loop

Keyword loop ngasih tau Rust buat ngejalanin sebuah blok kode terus-menerus selamanya sampe kita eksplisit nyuruh dia berhenti.

Sebagai contoh, ubah file src/main.rs di direktori loops kita jadi kayak gini:

Nama file: src/main.rs

fn main() {
    loop {
        println!("again!");
    }
}

Pas kita jalanin program ini, kita bakal liat again! dicetak terus-menerus tanpa henti sampe kita stop programnya secara manual. Kebanyakan terminal support keyboard shortcut ctrl-c buat interupsi program yang terjebak di loop terus-menerus. Cobain deh:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/loops`
again!
again!
again!
again!
^Cagain!

Simbol ^C merepresentasikan di mana kita teken ctrl-c.

Kita mungkin liat atau nggak liat kata again! dicetak setelah ^C, tergantung di mana kodenya lagi ada di dalem loop pas dia nerima sinyal interupsi.

Untungnya, Rust juga nyediain cara buat keluar dari loop pake kode. Kita bisa naruh keyword break di dalem loop buat ngasih tau program kapan harus berhenti ngejalanin loop-nya. Inget kan kita udah lakuin ini di game tebak angka di bagian “Quit Setelah Tebakan Bener” di Bab 2 buat keluar dari program pas user menangin gamenya dengan nebak angka yang bener.

Kita juga pake continue di game tebak angka, yang di dalem loop ngasih tau program buat lewatin sisa kode di iterasi loop ini terus lanjut ke iterasi berikutnya.

Balikin Nilai dari Loops

Salah satu kegunaan loop itu buat nyoba lagi sebuah operasi yang kita tau mungkin gagal, kayak nge-cek apakah sebuah thread udah kelar tugasnya. Kita mungkin juga perlu masukin hasil dari operasi itu keluar dari loop ke sisa kode kita. Buat lakuin ini, kita bisa nambahin nilai yang mau dibalikin setelah ekspresi break yang kita pake buat stop loop-nya; nilai itu bakal dibalikin keluar dari loop biar bisa kita pake, kayak yang ditunjukin di sini:

fn main() {
    let mut counter = 0;

    let result = loop {
        counter += 1;

        if counter == 10 {
            break counter * 2;
        }
    };

    println!("The result is {result}");
}

Sebelum loop, kita mendeklarasikan variabel namanya counter terus diinisialisasi jadi 0. Terus kita mendeklarasikan variabel namanya result buat nampung nilai yang dibalikin dari loop. Di tiap iterasi loop-nya, kita nambahin 1 ke variabel counter, terus cek apakah counter sama dengan 10. Pas udah sama, kita pake keyword break bareng nilai counter * 2. Setelah loop, kita pake titik koma buat ngakhiri statement yang ngasih nilainya ke result. Akhirnya, kita nyetak nilai di result, yang di kasus ini hasilnya 20.

Kita juga bisa return dari dalem loop. Kalau break cuma keluar dari loop saat ini, return bakal selalu keluar dari fungsi saat ini.

Loop Labels buat Bedain Banyak Loops

Kalau kita punya loop di dalem loop, break sama continue berlaku buat loop paling dalem di titik itu. Kita opsional bisa nentuin loop label di sebuah loop yang terus bisa kita pake bareng break atau continue buat nentuin kalau keyword itu berlaku buat loop yang dikasih label bukannya loop paling dalem. Loop label harus dimulai pake kutip tunggal. Ini contoh dengan dua loop bersarang:

fn main() {
    let mut count = 0;
    'counting_up: loop {
        println!("count = {count}");
        let mut remaining = 10;

        loop {
            println!("remaining = {remaining}");
            if remaining == 9 {
                break;
            }
            if count == 2 {
                break 'counting_up;
            }
            remaining -= 1;
        }

        count += 1;
    }
    println!("End count = {count}");
}

Loop luarnya punya label 'counting_up, dan dia bakal ngitung dari 0 sampe 2. Loop dalemnya tanpa label ngitung mundur dari 10 sampe 9. break pertama yang nggak nentuin label cuma bakal keluar dari loop dalem aja. Statement break 'counting_up; bakal keluar dari loop luar. Kode ini nyetak:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running `target/debug/loops`
count = 0
remaining = 10
remaining = 9
count = 1
remaining = 10
remaining = 9
count = 2
remaining = 10
End count = 2

Conditional Loops dengan while

Sebuah program sering kali perlu nge-evaluasi sebuah kondisi di dalem loop. Pas kondisinya true, loop-nya jalan. Pas kondisinya udah nggak true lagi, programnya manggil break, yang stop loop-nya. Mungkin aja buat mengimplementasikan perilaku kayak gini pake kombinasi loop, if, else, sama break; kita bisa cobain itu sekarang di sebuah program kalau mau. Tapi, pola ini saking umumnya sampe Rust punya konstruk bahasa bawaan buat itu, namanya while loop. Di Listing 3-3, kita pake while buat ngulang programnya tiga kali, ngitung mundur tiap kalinya, terus setelah loop-nya kelar, nyetak pesan terus exit.

Filename: src/main.rs
fn main() {
    let mut number = 3;

    while number != 0 {
        println!("{number}!");

        number -= 1;
    }

    println!("LIFTOFF!!!");
}
Listing 3-3: Pake while loop buat jalanin kode pas sebuah kondisi dievaluasi jadi true outdoor

Konstruk ini ngilangin banyak nesting (sarang) yang bakal diperluin kalau kita pake loop, if, else, sama break, dan dia lebih jelas. Selama sebuah kondisi dievaluasi jadi true, kodenya jalan; kalau nggak, dia keluar dari loop.

Looping Lewat Koleksi dengan for

Kita bisa milih buat pake konstruk while buat looping elemen-elemen dari sebuah koleksi, kayak array. Contohnya, loop di Listing 3-4 nyetak tiap elemen di array a.

Filename: src/main.rs
fn main() {
    let a = [10, 20, 30, 40, 50];
    let mut index = 0;

    while index < 5 {
        println!("the value is: {}", a[index]);

        index += 1;
    }
}
Listing 3-4: Looping lewat tiap elemen koleksi pake while loop

Di sini, kodenya ngitung naik lewat elemen-elemen di array-nya. Dia mulai di indeks 0, terus looping sampe nyampe indeks terakhir di array-nya (yaitu pas index < 5 udah nggak true lagi). Jalanin kode ini bakal nyetak tiap elemen di array:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.32s
     Running `target/debug/loops`
the value is: 10
the value is: 20
the value is: 30
the value is: 40
the value is: 50

Semua lima nilai array muncul di terminal, sesuai ekspektasi. Walaupun index bakal nyampe nilai 5 di suatu titik, loop-nya berhenti jalan sebelum nyoba ngambil nilai keenam dari array-nya.

Tapi, pendekatan ini gampang bikin error; kita bisa bikin programnya panic kalau nilai indeks atau kondisi tes-nya salah. Misalnya, kalau kita ngerubah definisi array a jadi punya empat elemen tapi lupa update kondisinya jadi while index < 4, kodenya bakal panic. Dia juga pelan, karena compiler nambahin kode runtime buat ngelakuin pengecekan kondisional apakah indeksnya masih di dalem batas array-nya di tiap iterasi loop-nya.

Sebagai alternatif yang lebih singkat, kita bisa pake for loop terus ngejalanin kode buat tiap item di koleksi. for loop keliatannya kayak kode di Listing 3-5.

Filename: src/main.rs
fn main() {
    let a = [10, 20, 30, 40, 50];

    for element in a {
        println!("the value is: {element}");
    }
}
Listing 3-5: Looping lewat tiap elemen koleksi pake for loop

Pas kita jalanin kode ini, kita bakal liat output yang sama kayak di Listing 3-4. Yang lebih penting, sekarang kita udah ningkatin keamanan kodenya dan ngilangin kemungkinan bug yang bisa hasil dari ngelewatin akhir array atau nggak cukup jauh dan ngelewatin beberapa item. Kode mesin yang dihasilin dari for loops juga bisa lebih efisien, karena indeksnya nggak perlu dibandingin sama panjang array-nya di tiap iterasi.

Pake for loop, kita nggak perlu repot-repot ngerubah kode lain kalau kita ngerubah jumlah nilai di array-nya, beda sama metode yang dipake di Listing 3-4.

Keamanan sama kesingkatan for loops bikin mereka jadi konstruk loop yang paling sering dipake di Rust. Bahkan di situasi di mana kita mau ngejalanin kode sejumlah kali tertentu, kayak di contoh hitung mundur yang pake while loop di Listing 3-3, kebanyakan Rustacean bakal pake for loop. Caranya adalah pake Range, yang disediain sama standard library, yang nge-generate semua angka secara berurutan mulai dari satu angka dan berakhir sebelum angka lainnya.

Ini penampakan hitung mundur kalau pake for loop sama metode lain yang belum kita bahas, rev, buat nge-reverse (balikin) range-nya:

Nama file: src/main.rs

fn main() {
    for number in (1..4).rev() {
        println!("{number}!");
    }
    println!("LIFTOFF!!!");
}

Kode ini jauh lebih keren, kan?

Ringkasan

Kita berhasil! Ini bab yang lumayan gede: kita udah belajar soal variabel, tipe data scalar sama compound, fungsi, komentar, ekspresi if, sama loop! Buat latihan konsep-konsep yang dibahas di bab ini, coba bikin program buat ngelakuin hal-hal berikut:

  • Convert temperatur antara Fahrenheit sama Celsius.
  • Generate angka Fibonacci ke-n.
  • Nyetak lirik lagu Natal “The Twelve Days of Christmas,” manfaatin pengulangan yang ada di lagunya.

Pas kita udah siap buat lanjut, kita bakal bahas konsep di Rust yang nggak umum ada di bahasa pemrograman lain: ownership.

Memahami Ownership

Ownership (Kepemilikan) adalah fitur paling unik di Rust dan punya pengaruh yang sangat dalem buat sisa bahasanya. Fitur ini bikin Rust bisa ngasih jaminan memory safety tanpa butuh garbage collector, jadi penting sekali buat kita paham gimana cara kerja ownership. Di bab ini, kita bakal bahas soal ownership barengan sama beberapa fitur terkait lainnya: borrowing, slices, dan gimana Rust nyusun data di memori.

Apa itu Ownership?

Apa itu Ownership?

Ownership (Kepemilikan) adalah sekumpulan aturan yang ngatur gimana program Rust ngelola memori. Semua program harus ngatur cara mereka pake memori komputer pas lagi jalan. Beberapa bahasa punya garbage collection (GC) yang secara rutin nyari memori yang udah nggak kepake pas programnya jalan; di bahasa lain, programmer harus secara eksplisit ngalokasiin dan ngebebasin memorinya. Rust pake pendekatan ketiga: memori dikelola lewat sistem ownership dengan sekumpulan aturan yang dicek sama compiler. Kalau ada aturan yang dilanggar, programnya nggak bakal ke-compile. Nggak ada satu pun fitur dari ownership yang bakal bikin program kita jadi lemot pas lagi jalan.

Karena ownership itu konsep baru buat banyak programmer, emang butuh waktu buat terbiasa. Kabar baiknya, makin kita berpengalaman sama Rust dan aturan sistem ownership-nya, kita bakal makin gampang buat nulis kode yang aman dan efisien secara alami. Semangat terus ya!

Pas kita paham ownership, kita bakal punya pondasi yang kuat buat mahamin fitur-fitur yang bikin Rust unik. Di bab ini, kita bakal belajar ownership lewat beberapa contoh yang fokus ke struktur data yang sangat umum: strings.

Stack dan Heap

Banyak bahasa pemrograman nggak nuntut kita buat sering-sering mikirin soal stack sama heap. Tapi di bahasa pemrograman sistem kayak Rust, apakah sebuah nilai ada di stack atau heap itu ngaruh ke gimana bahasanya berperilaku dan kenapa kita harus ngambil keputusan tertentu. Bagian-bagian dari ownership bakal dijelasin hubungannya sama stack dan heap nanti di bab ini, jadi ini penjelasan singkat buat persiapan.

Baik stack maupun heap adalah bagian dari memori yang tersedia buat dipake kode kita pas runtime, tapi mereka disusun dengan cara yang beda. Stack nyimpen nilai sesuai urutan yang dia dapet terus ngapus nilainya dengan urutan kebalikannya. Ini disebut last in, first out (LIFO). Bayangin tumpukan piring: pas kita nambahin piring lagi, kita taruh di atas tumpukannya, dan pas kita butuh piring, kita ambil satu dari paling atas. Nambahin atau ngambil piring dari tengah atau bawah nggak bakal semudah itu! Nambahin data disebut pushing onto the stack, dan ngambil data disebut popping off the stack. Semua data yang disimpan di stack harus punya ukuran yang udah tau dan tetap. Data dengan ukuran yang nggak tau pas compile time atau ukuran yang mungkin berubah harus disimpan di heap.

Heap itu kurang teratur: pas kita naruh data di heap, kita minta sejumlah tempat tertentu. Memory allocator bakal nemuin tempat kosong di heap yang cukup gede, nandain tempat itu lagi dipake, terus balikin sebuah pointer, yaitu alamat dari lokasi itu. Proses ini disebut allocating on the heap dan kadang disingkat jadi allocating doang (naruh nilai ke stack nggak dianggap sebagai allocating). Karena pointer ke heap itu ukurannya udah tau dan tetap, kita bisa nyimpen pointer-nya di stack, tapi pas kita mau datanya benar-benar, kita harus ngikutin pointer-nya. Bayangin kayak duduk di restoran. Pas masuk, kita bilang jumlah orang di grup kita, terus pelayannya nemuin meja kosong yang pas buat semuanya terus nganterin kita ke sana. Kalau ada temen kita yang telat dateng, mereka bisa nanya kita duduk di mana buat nemuin kita.

Pushing to the stack itu lebih cepet daripada allocating on the heap karena allocator nggak perlu cari-cari tempat buat nyimpen data baru; lokasinya selalu di paling atas stack. Sebagai perbandingan, ngalokasiin tempat di heap butuh kerja ekstra karena allocator harus nemuin dulu tempat yang cukup gede buat nampung datanya terus ngelakuin pembukuan buat persiapan alokasi selanjutnya.

Akses data di heap umumnya lebih lambat daripada akses data di stack karena kita harus ngikutin pointer buat nyampe ke sana. Prosesor zaman sekarang bakal lebih cepet kalau mereka nggak terlalu banyak lompat-lompat di memori. Lanjutin analoginya, bayangin seorang pelayan di restoran yang ngambil orderan dari banyak meja. Bakal paling efisien kalau dia ngambil semua orderan di satu meja sebelum lanjut ke meja berikutnya. Ngambil orderan dari meja A, terus meja B, terus meja A lagi, terus meja B lagi bakal jadi proses yang jauh lebih lambat. Dengan cara yang sama, prosesor biasanya bisa ngerjain tugasnya lebih baik kalau dia kerja sama data yang deket sama data lainnya (kayak di stack) bukannya yang jauh (kayak yang mungkin terjadi di heap).

Pas kode kita manggil sebuah fungsi, nilai-nilai yang dimasukin ke fungsinya (termasuk, mungkin, pointer ke data di heap) sama variabel lokal fungsinya bakal di-push ke stack. Pas fungsinya kelar, nilai-nilai itu bakal di-pop keluar dari stack.

Mantau bagian kode mana yang lagi pake data apa di heap, minimalisir jumlah data duplikat di heap, dan ngebersihin data yang udah nggak kepake di heap biar nggak keabisan tempat adalah masalah-masalah yang diselesein sama ownership. Sekali kita paham ownership, kita nggak bakal butuh sering- sering mikirin soal stack sama heap, tapi tau kalau tujuan utama ownership adalah buat ngelola data heap bisa bantu jelasin kenapa dia cara kerjanya kayak gitu.

Aturan Ownership

Pertama, yuk kita liat aturan-aturan ownership. Inget terus aturan ini pas kita ngerjain contoh-contoh yang bakal ngejelasin aturan ini:

  • Tiap nilai di Rust punya seorang owner (pemilik).
  • Cuma boleh ada satu owner dalam satu waktu.
  • Pas owner-nya keluar dari scope, nilainya bakal di-drop (dihapus).

Scope Variabel

Sekarang setelah kita ngelewatin sintaks dasar Rust, kita nggak bakal masukin semua kode fn main() { di contoh-contohnya, jadi kalau kita lagi ngikutin, pastiin buat masukin contoh-contoh berikut ke dalem fungsi main secara manual. Hasilnya, contoh-contoh kita bakal lebih singkat, biar kita bisa fokus ke detail aslinya bukannya kode boilerplate.

Sebagai contoh pertama dari ownership, kita bakal liat scope dari beberapa variabel. Sebuah scope adalah range di dalem program di mana sebuah item itu valid. Coba liat variabel ini:

#![allow(unused)]
fn main() {
let s = "hello";
}

Variabel s ngerujuk ke sebuah literal string, di mana nilai string-nya di-hardcoded ke teks program kita. Variabelnya valid dari titik di mana dia dideklarasikan sampe akhir dari scope saat ini. Listing 4-1 nunjukin program dengan komentar yang dianotasi di mana variabel s bakal valid.

fn main() {
    {                      // s is not valid here, since it's not yet declared
        let s = "hello";   // s is valid from this point forward

        // do stuff with s
    }                      // this scope is now over, and s is no longer valid
}
Listing 4-1: Sebuah variabel dan scope di mana dia valid

Dengan kata lain, ada dua titik waktu penting di sini:

  • Pas s masuk ke dalam scope, dia jadi valid.
  • Dia tetep valid sampe dia keluar dari scope.

Sampai titik ini, hubungan antara scope sama kapan variabel itu valid mirip sama bahasa pemrograman lainnya. Sekarang kita bakal kembangin pemahaman ini dengan ngenalin tipe String.

Tipe String

Buat gambarin aturan ownership, kita butuh tipe data yang lebih kompleks dari yang udah kita bahas di bagian “Tipe Data” di Bab 3. Tipe-tipe yang udah dibahas sebelumnya ukurannya udah tau, bisa disimpan di stack dan di-pop keluar dari stack pas scope-nya abis, dan bisa di-copy secara cepet dan gampang buat bikin instance baru yang independen kalau bagian kode lain perlu pake nilai yang sama di scope yang beda. Tapi kita mau liat data yang disimpan di heap dan eksplor gimana Rust tau kapan harus ngebersihin data itu, dan tipe String adalah contoh yang oke sekali.

Kita bakal fokus ke bagian-bagian String yang terkait sama ownership. Aspek-aspek ini juga berlaku buat tipe data kompleks lainnya, baik yang disediain standard library maupun yang kita bikin sendiri. Kita bakal bahas String lebih dalem di Bab 8.

Kita udah liat literal string, di mana nilai string-nya di-hardcoded ke program kita. Literal string emang nyaman, tapi mereka nggak cocok buat semua situasi di mana kita mungkin mau pake teks. Salah satu alasannya karena mereka itu immutable. Alasan lainnya karena nggak semua nilai string bisa diketahuin pas kita nulis kode: misalnya, gimana kalau kita mau ngambil input user terus nyimpannya? Buat situasi kayak gini, Rust punya tipe string kedua, yaitu String. Tipe ini ngelola data yang dialokasikan di heap dan makanya dia bisa nyimpen sejumlah teks yang ukurannya nggak kita ketahuin pas compile time. Kita bisa bikin sebuah String dari literal string pake fungsi from, kayak gini:

#![allow(unused)]
fn main() {
let s = String::from("hello");
}

Operator titik dua ganda :: ngebolehin kita buat ngelempokin fungsi from ini di bawah tipe String bukannya pake nama kayak string_from. Kita bakal bahas sintaks ini lebih lanjut di bagian “Sintaks Method” di Bab 5, dan pas kita bahas soal namespacing pake modul di “Path buat Ngerujuk Item di Pohon Modul” di Bab 7.

Jenis string ini bisa diubah (mutated):

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() appends a literal to a String

    println!("{s}"); // this will print `hello, world!`
}

Jadi, apa bedanya di sini? Kenapa String bisa diubah tapi literal nggak bisa? Bedanya ada di gimana kedua tipe ini nanganin memori.

Memori dan Alokasi

Di kasus literal string, kita tau isinya pas compile time, jadi teksnya di-hardcoded langsung ke file executable final-nya. Ini kenapa literal string itu cepet dan efisien. Tapi sifat-sifat ini cuma dateng dari sifat immutability literal string-nya. Sayangnya, kita nggak bisa naruh sepotong memori ke dalem biner buat tiap teks yang ukurannya nggak tau pas compile time dan ukurannya mungkin berubah pas lagi jalanin programnya.

Dengan tipe String, buat support sepotong teks yang mutable dan bisa nambah ukurannya, kita perlu ngalokasiin sejumlah memori di heap, yang nggak tau pas compile time, buat nampung isinya. Ini artinya:

  • Memorinya harus diminta dari memory allocator pas runtime.
  • Kita butuh cara buat balikin memori ini ke allocator pas kita udah selese pake String kita.

Bagian pertama itu kita yang ngerjain: pas kita manggil String::from, implementasinya minta memori yang dia butuhin. Ini hal yang cukup universal di bahasa pemrograman.

Tapi, bagian kedua itu beda. Di bahasa yang punya garbage collector (GC), GC bakal terus mantau dan ngebersihin memori yang udah nggak dipake lagi, dan kita nggak perlu mikirin itu. Di kebanyakan bahasa tanpa GC, itu tanggung jawab kita buat ngenalin kapan memori udah nggak dipake lagi terus manggil kode buat secara eksplisit ngebebasinnya, sama kayak pas kita memintanya. Ngelakuin ini dengan bener secara historis udah jadi masalah pemrograman yang susah. Kalau kita lupa, kita bakal buang-buang memori. Kalau kita lakuin terlalu cepet, kita bakal punya variabel yang nggak valid. Kalau kita lakuin dua kali, itu juga sebuah bug. Kita perlu masangin tepat satu allocate sama tepat satu free.

Rust ngambil jalur yang beda: memorinya otomatis dibalikin begitu variabel yang punya (owns) memori itu keluar dari scope. Ini versi contoh scope kita dari Listing 4-1 pake String bukannya literal string:

fn main() {
    {
        let s = String::from("hello"); // s is valid from this point forward

        // do stuff with s
    }                                  // this scope is now over, and s is no
                                       // longer valid
}

Ada titik waktu alami di mana kita bisa balikin memori yang dibutuhin String kita ke allocator: pas s keluar dari scope. Pas sebuah variabel keluar dari scope, Rust manggil fungsi khusus buat kita. Fungsi ini namanya drop, dan di situlah pembuat String bisa naruh kode buat balikin memorinya. Rust manggil drop secara otomatis di kurung kurawal tutup.

Catatan: Di C++, pola nge-dealokasi resource di akhir masa hidup sebuah item ini kadang disebut Resource Acquisition Is Initialization (RAII). Fungsi drop di Rust bakal terasa familiar kalau kita pernah pake pola-pola RAII.

Pola ini punya pengaruh yang sangat dalem ke gimana kode Rust ditulis. Mungkin keliatan simpel sekarang, tapi perilaku kodenya bisa jadi nggak terduga di situasi yang lebih ribet pas kita mau punya banyak variabel pake data yang udah kita alokasiin di heap. Yuk kita eksplor beberapa situasi itu sekarang.

Interaksi Variabel dan Data dengan Move

Beberapa variabel bisa berinteraksi sama data yang sama dengan berbagai cara di Rust. Yuk kita liat contoh pake integer di Listing 4-2.

fn main() {
    let x = 5;
    let y = x;
}
Listing 4-2: Assign nilai integer variabel x ke y

Kita mungkin bisa nebak apa yang dilakuin kode ini: “bind nilai 5 ke x; terus bikin copy dari nilai di x terus bind ke y.” Kita sekarang punya dua variabel, x sama y, dan keduanya sama dengan 5. Ini emang bener yang terjadi, karena integer adalah nilai simpel dengan ukuran yang udah tau dan tetap, dan dua nilai 5 ini di-push ke stack.

Sekarang yuk liat versi String-nya:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

Ini keliatannya mirip sekali, jadi kita mungkin asumsikan kalau cara kerjanya bakal sama: yaitu, baris kedua bakal bikin copy dari nilai di s1 terus bind ke s2. Tapi nggak gitu yang sebenernya terjadi.

Coba liat Gambar 4-1 buat liat apa yang terjadi di String di balik layar. Sebuah String disusun dari tiga bagian, yang ditunjukin di sebelah kiri: sebuah pointer ke memori yang nampung isi string-nya, sebuah length (panjang), dan sebuah capacity (kapasitas). Grup data ini disimpan di stack. Di sebelah kanan adalah memori di heap yang nampung isinya.

Dua tabel: tabel pertama isinya representasi s1 di stack, terdiri 
dari length (5), capacity (5), dan sebuah pointer ke nilai pertama di tabel 
kedua. Tabel kedua isinya representasi data string di heap, byte demi byte.

Gambar 4-1: Representasi di memori dari sebuah String yang nampung nilai "hello" yang di-bind ke s1

Length itu seberapa banyak memori, dalam byte, yang lagi dipake isinya String saat ini. Capacity itu total jumlah memori, dalam byte, yang diterima String dari allocator. Perbedaan antara length sama capacity itu penting, tapi nggak di konteks ini, jadi buat sekarang, cuekin aja capacity-nya.

Pas kita nge-assign s1 ke s2, data String-nya di-copy, artinya kita copy pointer, length, dan capacity yang ada di stack. Kita nggak copy data yang ada di heap yang dirujuk sama pointer-nya. Dengan kata lain, representasi data di memori keliatannya kayak Gambar 4-2.

Tiga tabel: tabel s1 dan s2 merepresentasikan string itu di stack, 
masing-masing, dan keduanya nunjuk ke data string yang sama di heap.

Gambar 4-2: Representasi di memori dari variabel s2 yang punya copy dari pointer, length, dan capacity dari s1

Representasinya nggak keliatan kayak Gambar 4-3, yang merupakan penampakan memori kalau misalnya Rust malah ikut copy data heap-nya juga. Kalau Rust lakuin ini, operasi s2 = s1 bisa jadi sangat mahal dalam hal performa Pas runtime kalau datanya di heap itu sangat besar.

Empat tabel: dua tabel merepresentasikan data stack buat s1 dan s2, 
dan masing-masing nunjuk ke copy data string-nya sendiri di heap.

Gambar 4-3: Kemungkinan lain soal apa yang mungkin dilakuin s2 = s1 kalau Rust ikut copy data heap-nya juga

Tadi kita bilang kalau pas sebuah variabel keluar dari scope, Rust otomatis manggil fungsi drop dan ngebersihin memori heap buat variabel itu. Tapi Gambar 4-2 nunjukin kedua pointer data nunjuk ke lokasi yang sama. Ini masalah: pas s2 sama s1 keluar dari scope, mereka berdua bakal nyoba buat ngebebasin memori yang sama. Ini dikenal sebagai double free error dan merupakan salah satu bug memory safety yang kita sebutin sebelumnya. Ngebebasin memori dua kali bisa bikin kerusakan memori (memory corruption), yang berpotensi memicu kerentanan keamanan.

Buat mastiin keamanan memori, setelah baris let s2 = s1;, Rust nganggep s1 udah nggak valid lagi. Makanya, Rust nggak perlu ngebebasin apa pun pas s1 keluar dari scope. Coba liat apa yang terjadi pas kita nyoba pake s1 setelah s2 dibuat; nggak bakal bisa:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}

Kita bakal dapet error kayak gini karena Rust ngelarang kita pake referensi yang udah nggak valid:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:16
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{s1}, world!");
  |                ^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

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

Kalau kita pernah denger istilah shallow copy (copy dangkal) sama deep copy (copy dalem) pas lagi belajar bahasa lain, konsep nyalin pointer, length, dan capacity tanpa nyalin datanya mungkin kedengeran kayak lagi bikin shallow copy. Tapi karena Rust juga ngebatalin variabel pertamanya, bukannya disebut shallow copy, ini dikenal sebagai move (pindah). Di contoh ini, kita bakal bilang kalau s1 udah di-move ke dalem s2. Jadi, apa yang benar-benar terjadi ditunjukin di Gambar 4-4.

Tiga tabel: tabel s1 dan s2 merepresentasikan string itu di stack, 
masing-masing, dan keduanya nunjuk ke data string yang sama di heap. Tabel s1 
di-grayed out karena s1 udah nggak valid; cuma s2 yang bisa dipake buat akses 
data heap-nya.

Gambar 4-4: Representasi di memori setelah s1 udah dibatalkan

Itu nyelesein masalah kita! Dengan cuma s2 yang valid, pas dia keluar dari scope cuma dia sendiri yang bakal ngebebasin memorinya, dan beres deh.

Sebagai tambahan, ada pilihan desain yang tersirat dari sini: Rust nggak bakal pernah otomatis bikin “deep” copy dari data kita. Makanya, penyalinan otomatis apa pun bisa diasumsikan nggak mahal dalam hal performa pas runtime.

Scope dan Assignment

Kebalikan dari ini juga bener buat hubungan antara scoping, ownership, dan memori yang dibebasin lewat fungsi drop juga. Pas kita ngasih nilai yang bener-bener baru ke variabel yang udah ada, Rust bakal manggil drop dan ngebebasin memori nilai aslinya langsung. Coba liat kode ini, contohnya:

fn main() {
    let mut s = String::from("hello");
    s = String::from("ahoy");

    println!("{s}, world!");
}

Kita awalnya mendeklarasikan variabel s terus di-bind ke sebuah String dengan nilai "hello". Terus kita langsung bikin String baru dengan nilai "ahoy" terus di-assign ke s. Di titik ini, nggak ada apa pun yang ngerujuk ke nilai asli di heap sama sekali.

Satu tabel s merepresentasikan nilai string di stack, nunjuk ke 
potongan data string kedua (ahoy) di heap, dengan data string asli (hello) 
di-grayed out karena udah nggak bisa diakses lagi.

Gambar 4-5: Representasi di memori setelah nilai awal udah diganti seluruhnya.

String aslinya makanya langsung keluar dari scope. Rust bakal jalanin fungsi drop padanya dan memorinya bakal langsung dibebasin. Pas kita nyetak nilainya di akhir, hasilnya bakal "ahoy, world!".

Interaksi Variabel dan Data dengan Clone

Kalau kita emang mau copy data heap dari String secara dalem (deeply copy), nggak cuma data stack-nya aja, kita bisa pake method umum namanya clone. Kita bakal bahas sintaks method di Bab 5, tapi karena method adalah fitur umum di banyak bahasa pemrograman, kita mungkin udah pernah liat sebelumnya.

Ini contoh method clone beraksi:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {s1}, s2 = {s2}");
}

Ini jalan dengan lancar dan secara eksplisit ngasilin perilaku yang ditunjukin di Gambar 4-3, di mana data heap-nya emang ikut di-copy.

Pas kita liat pemanggilan ke clone, kita tau kalau ada sejumlah kode sembarang yang lagi dijalankan dan kode itu mungkin mahal harganya. Ini adalah indikator visual kalau ada sesuatu yang beda yang lagi terjadi.

Data Khusus Stack: Copy

Ada hal unik lain yang belum kita bahas. Kode yang pake integer ini—yang sebagiannya ditunjukin di Listing 4-2—jalan dan valid:

fn main() {
    let x = 5;
    let y = x;

    println!("x = {x}, y = {y}");
}

Tapi kode ini kayaknya bertentangan sama apa yang baru aja kita pelajari: kita nggak manggil clone, tapi x tetep valid dan nggak di-move ke dalem y.

Alasannya karena tipe-tipe kayak integer yang ukurannya udah tau pas compile time disimpan seluruhnya di stack, jadi nyalin nilai aslinya itu cepet buat dilakuin. Itu artinya nggak ada alasan kenapa kita mau ngelarang x buat tetep valid setelah kita bikin variabel y. Dengan kata lain, nggak ada bedanya antara deep copy sama shallow copy di sini, jadi manggil clone nggak bakal ngelakuin hal yang beda dari shallow copy biasa, makanya kita bisa ngelewatinnya.

Rust punya anotasi khusus namanya trait Copy yang bisa kita taruh di tipe-tipe yang disimpan di stack, kayak integer (kita bakal bahas traits lebih banyak di Bab 10). Kalau sebuah tipe mengimplementasikan trait Copy, variabel yang pakenya nggak bakal di-move, tapi lebih ke disalin secara sepele, bikin mereka tetep valid setelah di-assign ke variabel lain.

Rust nggak bakal ngebolehin kita ngasih anotasi Copy ke sebuah tipe kalau tipe itu, atau bagian apa pun darinya, udah mengimplementasikan trait Drop. Kalau tipenya butuh sesuatu yang khusus terjadi pas nilainya keluar dari scope terus kita nambahin anotasi Copy ke tipe itu, kita bakal dapet compile-time error. Buat belajar gimana cara nambahin anotasi Copy ke tipe kita buat mengimplementasikan trait-nya, liat “Derivable Traits” di Lampiran C.

Jadi, tipe apa aja yang mengimplementasikan trait Copy? Kita bisa cek dokumentasi buat tipe tertentu buat mastiin, tapi sebagai aturan umum, kumpulan nilai scalar simpel apa pun bisa mengimplementasikan Copy, dan nggak ada satu pun yang butuh alokasi atau bentuk resource apa pun yang bisa mengimplementasikan Copy. Ini beberapa tipe yang mengimplementasikan Copy:

  • Semua tipe integer, kayak u32.
  • Tipe Boolean, bool, dengan nilai true sama false.
  • Semua tipe floating-point, kayak f64.
  • Tipe karakter, char.
  • Tuple, kalau isinya cuma tipe-tipe yang juga mengimplementasikan Copy. Contohnya, (i32, i32) mengimplementasikan Copy, tapi (i32, String) nggak.

Ownership dan Fungsi

Mekanisme masukin nilai ke sebuah fungsi mirip sama pas kita ngasih nilai ke sebuah variabel. Masukin variabel ke fungsi bakal nge-move atau copy, sama kayak assignment. Listing 4-3 punya contoh dengan beberapa anotasi yang nunjukin di mana variabel masuk dan keluar dari scope.

Filename: src/main.rs
fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // Because i32 implements the Copy trait,
                                    // x does NOT move into the function,
                                    // so it's okay to use x afterward.

} // Here, x goes out of scope, then s. However, because s's value was moved,
  // nothing special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{some_string}");
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{some_integer}");
} // Here, some_integer goes out of scope. Nothing special happens.
Listing 4-3: Fungsi dengan ownership dan scope yang dianotasi

Kalau kita nyoba pake s setelah manggil takes_ownership, Rust bakal ngelepar compile-time error. Pengecekan statis ini ngelindungin kita dari kesalahan. Coba tambahin kode ke main yang pake s sama x buat liat di mana kita bisa pake mereka dan di mana aturan ownership ngelarang kita buat ngelakuin itu.

Nilai Return dan Scope

Balikin nilai (returning values) juga bisa mentransfer ownership. Listing 4-4 nunjukin contoh fungsi yang balikin sebuah nilai, dengan anotasi yang mirip kayak di Listing 4-3.

Filename: src/main.rs
fn main() {
    let s1 = gives_ownership();        // gives_ownership moves its return
                                       // value into s1

    let s2 = String::from("hello");    // s2 comes into scope

    let s3 = takes_and_gives_back(s2); // s2 is moved into
                                       // takes_and_gives_back, which also
                                       // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {       // gives_ownership will move its
                                       // return value into the function
                                       // that calls it

    let some_string = String::from("yours"); // some_string comes into scope

    some_string                        // some_string is returned and
                                       // moves out to the calling
                                       // function
}

// This function takes a String and returns a String.
fn takes_and_gives_back(a_string: String) -> String {
    // a_string comes into
    // scope

    a_string  // a_string is returned and moves out to the calling function
}
Listing 4-4: Transfer ownership dari nilai return

Ownership sebuah variabel ngikutin pola yang sama tiap kalinya: ngasih nilai ke variabel lain bakal nge-move nilainya. Pas sebuah variabel yang isinya data di heap keluar dari scope, nilainya bakal dibersihin sama drop kecuali kalau ownership datanya udah di-move ke variabel lain.

Walaupun ini jalan, ngambil ownership terus balikin lagi di tiap fungsi itu agak ribet. Gimana kalau kita mau ngebolehin sebuah fungsi pake sebuah nilai tapi nggak usah ngambil ownership-nya? Agak nyebelin kan kalau apa pun yang kita masukin juga harus dibalikin lagi kalau kita mau pake lagi, ditambah data apa pun hasil dari body fungsinya yang mungkin juga mau kita balikin.

Rust ngebolehin kita buat balikin banyak nilai pake tuple, kayak yang ditunjukin di Listing 4-5.

Filename: src/main.rs
fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{s2}' is {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}
Listing 4-5: Balikin ownership dari parameter

Tapi ini terlalu banyak upacaranya (ceremony) dan kerjaan sekali buat konsep yang harusnya umum. Untungnya buat kita, Rust punya fitur buat pake sebuah nilai tanpa mentransfer ownership, namanya references (referensi).

References (Referensi) dan Borrowing (Peminjaman)

Referensi dan Borrowing

Masalah dari kode tuple di Listing 4-5 adalah kita harus balikin String-nya ke fungsi pemanggil biar kita tetep bisa pake String-nya setelah manggil calculate_length, soalnya String-nya udah di-move ke dalem calculate_length. Sebagai gantinya, kita bisa ngasih sebuah referensi ke nilai String itu. Sebuah reference (referensi) itu kayak pointer karena dia adalah alamat yang bisa kita ikutin buat akses data yang disimpan di alamat itu; data itu dimiliki sama variabel lain. Beda sama pointer, sebuah referensi dijamin bakal nunjuk ke sebuah nilai yang valid dari tipe tertentu selama masa hidup referensi itu.

Ini cara kita mendefinisikan dan pake fungsi calculate_length yang punya referensi ke sebuah objek sebagai parameter bukannya ngambil ownership dari nilainya:

Filename: src/main.rs
fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

Pertama, perhatiin kalau semua kode tuple di deklarasi variabel sama nilai return fungsi udah nggak ada. Kedua, perhatiin kalau kita masukin &s1 ke calculate_length dan, di definisinya, kita nerima &String bukannya String. Tanda ampersand ini merepresentasikan references, dan mereka ngebolehin kita buat ngerujuk ke suatu nilai tanpa ngambil ownership-nya. Gambar 4-6 ngeliatin konsep ini.

Tiga tabel: tabel buat s isinya cuma pointer ke tabel buat s1. Tabel 
buat s1 isinya data stack buat s1 dan nunjuk ke data string di heap.

Gambar 4-6: Diagram &String s yang nunjuk ke String s1

Catatan: Kebalikan dari bikin referensi pake & adalah dereferencing, yang dilakuin pake operator dereference, *. Kita bakal liat beberapa penggunaan operator dereference di Bab 8 dan bahas detail soal dereferencing di Bab 15.

Yuk kita liat lebih deket pemanggilan fungsinya di sini:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

Sintaks &s1 ngebolehin kita bikin sebuah referensi yang ngerujuk ke nilai dari s1 tapi nggak memilikinya. Karena referensinya nggak memiliki nilainya, nilai yang dia tunjuk nggak bakal di-drop pas referensinya udah nggak dipake lagi.

Begitu juga sama signature fungsinya yang pake & buat nunjukin kalau tipe parameternya s itu adalah sebuah referensi. Yuk kita tambahin beberapa anotasi penjelasan:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize { // s is a reference to a String
    s.len()
} // Here, s goes out of scope. But because s does not have ownership of what
  // it refers to, the String is not dropped.

Scope di mana variabel s itu valid sama kayak scope parameter fungsi mana pun, tapi nilai yang ditunjuk sama referensinya nggak bakal di-drop pas s udah nggak dipake lagi, karena s nggak punya ownership. Pas fungsi punya referensi sebagai parameter bukannya nilai aslinya, kita nggak perlu balikin nilai-nilainya buat ngasih balik ownership, karena emang kita nggak pernah punya ownership-nya dari awal.

Kita sebut aksi bikin referensi ini sebagai borrowing (meminjam). Kayak di dunia nyata, kalau seseorang punya sesuatu, kita bisa pinjem dari mereka. Pas udah selese, kita harus balikin. Kita nggak memilikinya.

Jadi, apa yang terjadi kalau kita nyoba ngerubah sesuatu yang kita pinjem? Coba kode di Listing 4-6. Spoiler: kodenya nggak bakal jalan!

Filename: src/main.rs
fn main() {
    let s = String::from("hello");

    change(&s);
}

fn change(some_string: &String) {
    some_string.push_str(", world");
}
Listing 4-6: Nyoba ngerubah nilai yang dipinjem

Ini error-nya:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
 --> src/main.rs:8:5
  |
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
  |
help: consider changing this to be a mutable reference
  |
7 | fn change(some_string: &mut String) {
  |                         +++

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

Sama kayak variabel yang immutable secara default, referensi juga gitu. Kita nggak dibolehin ngerubah sesuatu yang kita punya referensinya.

Mutable References

Kita bisa benerin kode dari Listing 4-6 biar kita dibolehin ngerubah nilai yang dipinjem dengan cuma beberapa perubahan kecil yang pake mutable reference sebagai gantinya:

Filename: src/main.rs
fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

Pertama kita ubah s jadi mut. Terus kita bikin sebuah mutable reference pake &mut s pas kita manggil fungsi change, terus update signature fungsinya biar nerima sebuah mutable reference pake some_string: &mut String. Ini bikin keliatan jelas sekali kalau fungsi change bakal ngerubah (mutate) nilai yang dia pinjem.

Mutable references punya satu larangan gede: kalau kita punya sebuah mutable reference ke sebuah nilai, kita nggak boleh punya referensi lain ke nilai itu. Kode ini yang nyoba bikin dua mutable reference ke s bakal gagal:

Filename: src/main.rs
fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;

    println!("{r1}, {r2}");
}

Ini error-nya:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:5:14
  |
4 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
5 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
6 |
7 |     println!("{r1}, {r2}");
  |                -- first borrow later used here

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

Error ini bilang kalau kodenya nggak valid karena kita nggak bisa minjem s sebagai mutable lebih dari sekali dalam satu waktu. Mutable borrow yang pertama ada di r1 dan harus bertahan sampe dia dipake di println!, tapi di antara pembuatan mutable reference itu sampe penggunaannya, kita nyoba bikin mutable reference lain di r2 yang minjem data yang sama kayak r1.

Larangan yang nyegah banyak mutable reference ke data yang sama di waktu yang bersamaan ini ngebolehin adanya mutasi tapi dengan cara yang sangat terkontrol. Ini hal yang biasanya bikin Rustacean baru rada pusing karena kebanyakan bahasa ngebolehin kita ngerubah nilai kapan pun kita mau. Keuntungan punya larangan ini adalah Rust bisa nyegah data races pas compile time. Sebuah data race itu mirip kayak race condition dan terjadi pas tiga perilaku ini muncul:

  • Dua atau lebih pointer akses data yang sama di waktu yang sama.
  • Minimal salah satu dari pointer-nya dipake buat nulis ke datanya.
  • Nggak ada mekanisme yang dipake buat sinkronisasi akses ke datanya.

Data races bikin perilaku yang nggak terdefinisi (undefined behavior) dan bisa susah buat didiagnosa dan diperbaiki pas kita nyoba nyari tau pas runtime; Rust nyegah masalah ini dengan nolak buat nge-compile kode yang punya data races!

Kayak biasa, kita bisa pake kurung kurawal buat bikin scope baru, yang ngebolehin adanya banyak mutable reference, cuma bukan yang bersamaan:

fn main() {
    let mut s = String::from("hello");

    {
        let r1 = &mut s;
    } // r1 goes out of scope here, so we can make a new reference with no problems.

    let r2 = &mut s;
}

Rust juga nerapin aturan yang mirip buat ngelempokin mutable sama immutable references. Kode ini ngasilin error:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    let r3 = &mut s; // BIG PROBLEM

    println!("{r1}, {r2}, and {r3}");
}

Ini error-nya:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s; // no problem
  |              -- immutable borrow occurs here
5 |     let r2 = &s; // no problem
6 |     let r3 = &mut s; // BIG PROBLEM
  |              ^^^^^^ mutable borrow occurs here
7 |
8 |     println!("{r1}, {r2}, and {r3}");
  |                -- immutable borrow later used here

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

Fiuh! Kita juga nggak bisa punya sebuah mutable reference pas kita lagi punya sebuah immutable reference ke nilai yang sama.

User dari sebuah immutable reference nggak bakal nyangka kalau nilainya tiba- tiba berubah gitu aja! Tapi, banyak immutable references diperbolehkan karena nggak ada orang yang cuma baca datanya punya kemampuan buat ngaruhin bacaan data orang lain.

Perhatiin ya kalau scope sebuah referensi dimulai dari tempat dia dikenalin sampe terakhir kali referensi itu dipake. Misalnya, kode ini bakal ke-compile karena penggunaan terakhir dari immutable references ada di println!, sebelum mutable reference-nya dikenalin:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    println!("{r1} and {r2}");
    // Variables r1 and r2 will not be used after this point.

    let r3 = &mut s; // no problem
    println!("{r3}");
}

Scope dari immutable references r1 sama r2 abis setelah println! di mana mereka terakhir dipake, yang mana itu sebelum mutable reference r3 dibuat. Scope-scope ini nggak tumpang tindih, jadi kode ini diperbolehkan: compiler bisa tau kalau referensinya udah nggak dipake lagi di titik sebelum akhir dari scope-nya.

Walaupun error borrowing kadang bikin kesel, inget ya kalau itu adalah compiler Rust yang lagi nunjukin potensi bug dari awal (pas compile time bukannya pas runtime) dan nunjukin tepat di mana letak masalahnya. Jadi kita nggak perlu repot-repot nyari tau kenapa data kita nggak sesuai sama apa yang kita pikirkan.

Dangling References

Di bahasa yang punya pointer, gampang sekali buat nggak sengaja bikin sebuah dangling pointer—sebuah pointer yang ngerujuk ke sebuah lokasi di memori yang mungkin udah dikasih ke orang lain—dengan cara ngebebasin sejumlah memori tapi tetep nyimpen pointer ke memori itu. Di Rust, sebaliknya, compiler ngejamin kalau referensi nggak bakal pernah jadi dangling references: kalau kita punya sebuah referensi ke suatu data, compiler bakal mastiin kalau datanya nggak bakal keluar dari scope sebelum referensi ke datanya keluar duluan.

Yuk kita coba bikin sebuah dangling reference buat liat gimana Rust nyegah mereka pake compile-time error:

Filename: src/main.rs
fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

Ini error-nya:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
 --> src/main.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
  |
5 | fn dangle() -> &'static String {
  |                 +++++++
help: instead, you are more likely to want to return an owned value
  |
5 - fn dangle() -> &String {
5 + fn dangle() -> String {
  |

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

Pesan error ini ngerujuk ke fitur yang belum kita bahas: lifetimes. Kita bakal bahas lifetimes secara detail di Bab 10. Tapi, kalau kita cuekin bagian soal lifetimes-nya, pesannya emang isinya kunci kenapa kode ini bermasalah:

this function's return type contains a borrowed value, but there is no value
for it to be borrowed from

Yuk kita liat lebih deket apa sebenernya yang terjadi di tiap tahap kode dangle kita:

Filename: src/main.rs
fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String { // dangle returns a reference to a String

    let s = String::from("hello"); // s is a new String

    &s // we return a reference to the String, s
} // Here, s goes out of scope and is dropped, so its memory goes away.
  // Danger!

Karena s dibuat di dalem dangle, pas kode dangle selesai, s bakal di-dealokasi. Tapi kita nyoba buat balikin sebuah referensi kepadanya. Itu artinya referensi ini bakal nunjuk ke sebuah String yang nggak valid. Itu nggak oke sekali! Rust nggak bakal ngebolehin kita ngelakuin ini.

Solusinya di sini adalah dengan balikin String-nya secara langsung:

fn main() {
    let string = no_dangle();
}

fn no_dangle() -> String {
    let s = String::from("hello");

    s
}

Ini jalan tanpa masalah apa pun. Ownership di-move keluar, dan nggak ada apa pun yang di-dealokasi.

Aturan Referensi

Yuk kita ringkas apa yang udah kita bahas soal referensi:

  • Dalam satu waktu, kita bisa punya antara satu mutable reference atau sejumlah berapa pun immutable references.
  • Referensi harus selalu valid.

Selanjutnya, kita bakal liat jenis referensi yang beda: slices.

Tipe Slice

Tipe Slice

Slices ngebolehin kita buat ngerujuk ke serangkaian elemen yang berurutan di sebuah koleksi. Slice itu sejenis referensi, jadi dia nggak punya ownership.

Ini ada masalah pemrograman kecil: bikin sebuah fungsi yang nerima sebuah string kata-kata yang dipisahin spasi terus balikin kata pertama yang dia nemu di string itu. Kalau fungsinya nggak nemu spasi di string-nya, berarti seluruh string-nya adalah satu kata, jadi seluruh string-nya harus dibalikin.

Catatan: Buat tujuan ngenalin string slices, kita asumsikan cuma pake ASCII aja di bagian ini; pembahasan lebih mendalam soal penanganan UTF-8 ada di bagian “Menyimpan Teks Berkode UTF-8 dengan Strings” di Bab 8.

Yuk kita pelajari gimana cara kita nulis signature fungsi ini tanpa pake slices, biar paham masalah apa yang bakal diselesain sama slices:

fn first_word(s: &String) -> ?

Fungsi first_word punya parameter tipe &String. Kita nggak butuh ownership, jadi ini oke-oke aja. (Di Rust yang idiomatik, fungsi nggak ngambil ownership dari argumennya kecuali emang butuh, dan alasannya bakal makin jelas seiring kita lanjut.) Tapi apa yang harus kita balikin? Kita sebenernya nggak punya cara buat nyebut sebagian dari sebuah string. Tapi, kita bisa balikin indeks dari akhir katanya, yang ditandain sama spasi. Yuk kita cobain, kayak yang ditunjukin di Listing 4-7.

Filename: src/main.rs
fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}
Listing 4-7: Fungsi first_word yang balikin nilai indeks byte ke dalam parameter String

Karena kita perlu nelusurin String-nya elemen demi elemen terus cek apakah sebuah nilai itu spasi, kita bakal convert String kita jadi sebuah array dari byte pake method as_bytes.

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Selanjutnya, kita bikin sebuah iterator lewat array byte tadi pake method iter:

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Kita bakal bahas iterator lebih detail di Bab 13. Buat sekarang, tau aja kalau iter itu method yang balikin tiap elemen di sebuah koleksi dan enumerate ngebungkus hasil dari iter terus balikin tiap elemen sebagai bagian dari sebuah tuple. Elemen pertama dari tuple yang dibalikin sama enumerate itu adalah indeksnya, dan elemen keduanya adalah referensi ke elemennya. Ini lumayan lebih nyaman daripada ngitung indeksnya sendiri.

Karena method enumerate balikin tuple, kita bisa pake pattern buat destructure tuple itu. Kita bakal bahas pattern lebih banyak di Bab 6. Di dalem for loop, kita nentuin pattern yang punya i buat indeks di tuple dan &item buat satu byte di tuple. Karena kita dapet referensi ke elemennya dari .iter().enumerate(), kita pake & di pattern-nya.

Di dalem for loop, kita cari byte yang merepresentasikan spasi pake sintaks literal byte. Kalau kita nemu spasi, kita balikin posisinya. Kalau nggak, kita balikin panjang string-nya pake s.len().

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Kita sekarang punya cara buat nemuin indeks akhir dari kata pertama di string, tapi ada masalah. Kita balikin usize sendirian, tapi itu cuma angka yang bermakna di konteks &String. Dengan kata lain, karena itu nilai yang terpisah dari String, nggak ada jaminan kalau dia tetep bakal valid di masa depan. Coba liat program di Listing 4-8 yang pake fungsi first_word dari Listing 4-7.

Filename: src/main.rs
fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s); // word will get the value 5

    s.clear(); // this empties the String, making it equal to ""

    // word still has the value 5 here, but s no longer has any content that we
    // could meaningfully use with the value 5, so word is now totally invalid!
}
Listing 4-8: Nyimpen hasil dari manggil fungsi first_word terus ngerubah isi String

Program ini berhasil di-compile tanpa error apa pun dan bakal tetep gitu kalau kita pake word setelah manggil s.clear(). Karena word sama sekali nggak hubung sama state dari s, word tetep isinya nilai 5. Kita bisa pake nilai 5 itu bareng variabel s buat nyoba ngambil kata pertamanya, tapi ini bakal jadi sebuah bug karena isi dari s udah berubah sejak kita nyimpen 5 di word.

Harus pusing mikirin indeks di word yang bisa nggak sinkron sama data di s itu ribet dan gampang bikin error! Ngelola indeks-indeks ini bakal makin rapuh lagi kalau kita nulis fungsi second_word. Signature-nya harusnya kayak gini:

fn second_word(s: &String) -> (usize, usize) {

Sekarang kita mantau indeks awal dan akhir, dan kita punya makin banyak nilai yang dihitung dari data di state tertentu tapi nggak terikat sama state itu sama sekali. Kita punya tiga variabel nggak berhubungan yang melayang-layang yang perlu dijaga biar tetep sinkron.

Untungnya, Rust punya solusi buat masalah ini: string slices.

String Slices

Sebuah string slice adalah referensi ke serangkaian elemen yang berurutan dari sebuah String, dan bentuknya kayak gini:

fn main() {
    let s = String::from("hello world");

    let hello = &s[0..5];
    let world = &s[6..11];
}

Bukannya referensi ke seluruh String, hello adalah referensi ke sebagian dari String, yang ditentuin di bagian tambahan [0..5]. Kita bikin slices pake range di dalem kurung siku dengan nentuin [indeks_awal..indeks_akhir], di mana indeks_awal itu posisi pertama di slice-nya dan indeks_akhir itu satu lebih banyak dari posisi terakhir di slice-nya. Secara internal, struktur data slice nyimpen posisi awal sama panjang slice-nya, yang sesuai sama indeks_akhir dikurang indeks_awal. Jadi, di kasus let world = &s[6..11];, world bakal jadi slice yang isinya sebuah pointer ke byte di indeks 6 dari s dengan nilai panjang 5.

Gambar 4-7 nunjukin ini di sebuah diagram.

Tiga tabel: tabel yang merepresentasikan data stack dari s, yang 
nunjuk ke byte di indeks 0 di tabel data string "hello world" di 
heap. Tabel ketiga merepresentasikan data stack dari slice world, yang punya 
nilai panjang 5 dan nunjuk ke byte 6 dari tabel data heap.

Gambar 4-7: String slice yang ngerujuk ke bagian dari sebuah String

Dengan sintaks range .. di Rust, kalau kita mau mulai di indeks 0, kita bisa ngilangin nilai sebelum dua titik itu. Dengan kata lain, ini sama aja:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2];
}

Sama juga halnya, kalau slice kita masukin byte terakhir dari String, kita bisa ngilangin angka belakangnya. Itu artinya ini sama aja:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];
}

Kita juga bisa ngilangin kedua nilainya buat ngambil slice dari seluruh string. Jadi ini juga sama aja:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];
}

Catatan: Indeks range string slice harus ada di batas karakter UTF-8 yang valid. Kalau kita nyoba bikin string slice di tengah-tengah karakter multi-byte, program kita bakal exit dengan error.

Dengan semua info ini, yuk kita tulis ulang first_word biar balikin sebuah slice. Tipe yang nandain “string slice” ditulisnya &str:

Filename: src/main.rs
fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {}

Kita dapet indeks buat akhir katanya dengan cara yang sama kayak di Listing 4-7, dengan nyari spasi pertama. Pas kita nemu spasi, kita balikin sebuah string slice pake awal string-nya sama indeks spasi tadi sebagai indeks awal dan akhirnya.

Sekarang pas kita manggil first_word, kita dapet balik satu nilai tunggal yang terikat ke data aslinya. Nilainya disusun dari referensi ke titik awal slice-nya sama jumlah elemen di slice-nya.

Balikin sebuah slice juga bakal jalan buat fungsi second_word:

fn second_word(s: &String) -> &str {

Kita sekarang punya API yang jelas yang jauh lebih susah buat salah pakenya karena compiler bakal mastiin kalau referensi ke dalem String-nya tetep valid. Inget kan bug di program di Listing 4-8, pas kita dapet indeks buat akhir kata pertama tapi terus nge-clear string-nya sampe indeks kita jadi nggak valid? Kode itu secara logis salah tapi nggak nunjukin error langsung. Masalahnya baru muncul nanti kalau kita terus nyoba pake indeks kata pertama sama string yang udah kosong. Slices bikin bug ini jadi mustahil dan ngasih tau kita kalau ada masalah sama kode kita jauh lebih awal. Pake versi slice dari first_word bakal ngelepar compile-time error:

Filename: src/main.rs
fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s);

    s.clear(); // error!

    println!("the first word is: {word}");
}

Ini error compiler-nya:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
  --> src/main.rs:18:5
   |
16 |     let word = first_word(&s);
   |                           -- immutable borrow occurs here
17 |
18 |     s.clear(); // error!
   |     ^^^^^^^^^ mutable borrow occurs here
19 |
20 |     println!("the first word is: {word}");
   |                                   ---- immutable borrow later used here

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

Inget kan dari aturan borrowing kalau kita punya immutable reference ke sesuatu, kita nggak boleh juga ngambil mutable reference. Karena clear perlu memotong String-nya, dia perlu dapet mutable reference. println! setelah manggil clear pake referensi di word, jadi immutable reference-nya harus tetep aktif di titik itu. Rust ngelarang mutable reference di clear sama immutable reference di word buat ada di waktu yang sama, makanya kompilasinya gagal. Nggak cuma Rust bikin API kita lebih gampang dipake, tapi dia juga ngilangin seluruh kelas error pas compile time!

Literal String sebagai Slices

Inget kan kita pernah bahas soal literal string yang disimpan di dalem biner. Sekarang setelah kita tau soal slices, kita bisa paham literal string dengan bener:

#![allow(unused)]
fn main() {
let s = "Hello, world!";
}

Tipe s di sini adalah &str: dia adalah sebuah slice yang nunjuk ke titik spesifik di binernya. Ini juga kenapa literal string itu immutable; &str adalah sebuah immutable reference.

String Slices sebagai Parameter

Tau kalau kita bisa ngambil slices dari literal sama nilai String bawa kita ke satu lagi peningkatan buat first_word, yaitu di signature-nya:

fn first_word(s: &String) -> &str {

Seorang Rustacean yang lebih berpengalaman bakal nulis signature yang ditunjukin di Listing 4-9 sebagai gantinya karena dia ngebolehin kita pake fungsi yang sama buat baik nilai &String maupun nilai &str.

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // `first_word` works on slices of `String`s, whether partial or whole.
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s.
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or
    // whole.
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}
Listing 4-9: Ningkatin fungsi first_word dengan pake string slice buat tipe parameter s-nya

Kalau kita punya sebuah string slice, kita bisa masukin itu langsung. Kalau kita punya sebuah String, kita bisa masukin slice dari String-nya atau referensi ke String-nya. Fleksibilitas ini manfaatin deref coercions, sebuah fitur yang bakal kita bahas di bagian “Implicit Deref Coercions dengan Fungsi dan Method” di Bab 15.

Mendefinisikan fungsi buat nerima string slice bukannya referensi ke sebuah String bikin API kita jadi lebih umum dan berguna tanpa ngurangin fungsionalitas apa pun:

Filename: src/main.rs
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // `first_word` works on slices of `String`s, whether partial or whole.
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s.
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or
    // whole.
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

Slices Lainnya

String slices, kayak yang bisa kita bayangin, itu spesifik buat string. Tapi ada tipe slice yang lebih umum juga. Coba liat array ini:

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];
}

Sama kayak kita mungkin mau ngerujuk ke bagian dari sebuah string, kita mungkin juga mau ngerujuk ke bagian dari sebuah array. Kita lakuin kayak gini:

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];

assert_eq!(slice, &[2, 3]);
}

Slice ini punya tipe &[i32]. Dia cara kerjanya sama kayak string slices, dengan nyimpen referensi ke elemen pertama sama sebuah panjangnya. Kita bakal pake jenis slice ini buat macem-macem koleksi lainnya. Kita bakal bahas koleksi ini secara detail pas kita bahas vector di Bab 8.

Ringkasan

Konsep ownership, borrowing, sama slices ngejamin keamanan memori di program Rust pas compile time. Bahasa Rust ngasih kita kontrol atas penggunaan memori dengan cara yang sama kayak bahasa pemrograman sistem lainnya, tapi dengan adanya pemilik data yang otomatis ngebersihin data itu pas pemiliknya keluar dari scope, artinya kita nggak perlu nulis dan debug kode tambahan buat dapet kontrol ini.

Ownership ngaruh ke banyak bagian Rust lainnya, jadi kita bakal bahas konsep- konsep ini lebih lanjut di sepanjang sisa bukunya. Yuk kita lanjut ke Bab 5 buat liat gimana ngelempokin potongan-potongan data jadi satu di sebuah struct.

Pake Structs buat Ngatur Data Terkait

Sebuah struct, atau structure, adalah tipe data kustom yang ngebolehin kita ngebungkus dan ngasih nama ke beberapa nilai terkait yang ngebentuk sebuah grup yang bermakna. Kalau kita udah kenal sama bahasa pemrograman berbasis objek (object-oriented), sebuah struct itu kayak atribut data dari sebuah objek. Di bab ini, kita bakal bandingin tuple sama struct buat ngembangin apa yang udah kita tau dan nunjukin kapan struct jadi cara yang lebih oke buat ngelempokin data.

Kita bakal praktekin gimana cara mendefinisikan sama menginisialisasi struct. Kita bakal bahas gimana cara mendefinisikan associated functions, terutama jenis associated functions yang disebut methods, buat nentuin perilaku yang terkait sama sebuah tipe struct. Struct sama enum (yang dibahas di Bab 6) adalah blok dasar buat bikin tipe-tipe baru di domain program kita buat manfaatin pengecekan tipe compile-time Rust secara maksimal.

Mendefinisikan dan Membikin Instance Structs

Mendefinisikan sama Menginisialisasi Structs

Struct itu mirip sama tuple yang udah kita bahas di bagian “Tipe Tuple”, karena keduanya sama-sama nampung banyak nilai yang terkait. Kayak tuple, bagian-bagian dari sebuah struct bisa punya tipe yang beda-beda. Bedanya sama tuple, di dalem struct kita ngasih nama ke tiap potongan datanya biar jelas apa makna dari nilai-nilai itu. Dengan adanya nama-nama ini, struct jadi lebih fleksibel daripada tuple: kita nggak perlu ngandelin urutan datanya buat nentuin atau akses nilai dari sebuah instance.

Buat mendefinisikan sebuah struct, kita tulis keyword struct terus kasih nama ke seluruh struct-nya. Nama struct harusnya ngejelasin seberapa penting potongan data yang lagi dikelompokin itu. Terus, di dalem kurung kurawal, kita mendefinisikan nama sama tipe dari potongan datanya, yang kita sebut sebagai fields. Contohnya, Listing 5-1 nunjukin sebuah struct yang nyimpen info soal akun user.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {}
Listing 5-1: Definisi struct User

Buat pake sebuah struct setelah kita definisikan, kita bikin sebuah instance dari struct itu dengan nentuin nilai konkret buat tiap field-nya. Kita bikin sebuah instance dengan nulis nama struct-nya terus nambahin kurung kurawal yang isinya pasangan key: value, di mana key-nya adalah nama field-nya dan value-nya adalah data yang mau kita simpen di field itu. Kita nggak harus nentuin field-nya sesuai urutan pas kita mendeklarasikannya di struct-nya. Dengan kata lain, definisi struct itu kayak template umum buat tipenya, dan instance ngisi template itu dengan data tertentu buat bikin nilai dari tipe tersebut. Contohnya, kita bisa mendeklarasikan seorang user tertentu kayak yang ditunjukin di Listing 5-2.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        active: true,
        username: String::from("someusername123"),
        email: String::from("someone@example.com"),
        sign_in_count: 1,
    };
}
Listing 5-2: Bikin instance dari struct User

Buat dapet nilai spesifik dari sebuah struct, kita pake notasi titik (dot notation). Misalnya, buat akses alamat email user ini, kita pake user1.email. Kalau instance-nya mutable, kita bisa ngerubah nilainya pake notasi titik terus di-assign ke field tertentu. Listing 5-3 nunjukin gimana cara ngerubah nilai di field email dari sebuah instance User yang mutable.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let mut user1 = User {
        active: true,
        username: String::from("someusername123"),
        email: String::from("someone@example.com"),
        sign_in_count: 1,
    };

    user1.email = String::from("anotheremail@example.com");
}
Listing 5-3: Ngerubah nilai di field email dari sebuah instance User yang mutable

Perhatiin ya kalau seluruh instance-nya harus mutable; Rust nggak ngebolehin kita nandain cuma field tertentu doang sebagai mutable. Sama kayak ekspresi mana pun, kita bisa ngekonstruksi instance baru dari struct-nya sebagai ekspresi terakhir di body fungsi buat secara implisit balikin instance baru itu.

Listing 5-4 nunjukin sebuah fungsi build_user yang balikin instance User dengan email sama username yang dikasih. Field active dapet nilai true, dan sign_in_count dapet nilai 1.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username: username,
        email: email,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("someone@example.com"),
        String::from("someusername123"),
    );
}
Listing 5-4: Fungsi build_user yang nerima email sama username terus balikin instance User

Masuk akal sekali kan buat ngasih nama parameter fungsi sama kayak nama field struct-nya, tapi harus ngulang-ngulang nama field email sama username dan variabelnya itu agak ribet. Kalau struct-nya punya lebih banyak field lagi, ngulangin tiap namanya bakal makin nyebelin. Untungnya, ada cara singkat yang nyaman!

Pake Shorthand Inisialisasi Field (Field Init Shorthand)

Karena nama parameternya sama nama field struct-nya persis sama di Listing 5-4, kita bisa pake sintaks field init shorthand buat nulis ulang build_user biar perilakunya persis sama tapi nggak ada pengulangan username sama email, kayak yang ditunjukin di Listing 5-5.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username,
        email,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("someone@example.com"),
        String::from("someusername123"),
    );
}
Listing 5-5: Fungsi build_user yang pake field init shorthand karena parameter username sama email punya nama yang sama kayak field struct-nya

Di sini, kita lagi bikin instance baru dari struct User, yang punya field namanya email. Kita mau set nilai field email ke nilai di parameter email dari fungsi build_user. Karena field email sama parameter email punya nama yang sama, kita cuma perlu nulis email doang bukannya email: email.

Bikin Instance dari Instance Lain pake Sintaks Update Struct (Struct Update Syntax)

Sering kali berguna buat bikin instance baru dari sebuah struct yang isinya kebanyakan nilai dari instance lain dengan tipe yang sama, tapi ngerubah beberapa nilainya. Kita bisa lakuin ini pake struct update syntax.

Pertama, di Listing 5-6 kita liat cara bikin instance User baru di user2 secara biasa, tanpa pake update syntax. Kita set nilai baru buat email tapi selain itu pake nilai-nilai yang sama dari user1 yang udah kita bikin di Listing 5-2.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    // --snip--

    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        active: user1.active,
        username: user1.username,
        email: String::from("another@example.com"),
        sign_in_count: user1.sign_in_count,
    };
}
Listing 5-6: Bikin instance User baru pake hampir semua nilai dari user1 kecuali satu

Pake struct update syntax, kita bisa dapet efek yang sama dengan kode yang lebih dikit, kayak yang ditunjukin di Listing 5-7. Sintaks .. nentuin kalau sisa field yang nggak di-set secara eksplisit harusnya punya nilai yang sama kayak field di instance yang dikasih.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    // --snip--

    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        email: String::from("another@example.com"),
        ..user1
    };
}
Listing 5-7: Pake struct update syntax buat set nilai email baru buat instance User tapi pake sisa nilai dari user1 lainnya

Kode di Listing 5-7 juga bikin instance di user2 yang punya nilai beda buat email tapi punya nilai yang sama buat field username, active, dan sign_in_count dari user1. Tanda ..user1 harus ditaruh di paling akhir buat nentuin kalau sisa field apa pun harus dapet nilainya dari field yang terkait di user1, tapi kita bebas nentuin nilai buat sebanyak apa pun field yang kita mau dengan urutan apa pun, nggak peduli urutan field-nya di definisi struct-nya.

Perhatiin ya kalau struct update syntax pake = kayak sebuah assignment; ini karena dia nge-move datanya, sama kayak yang kita liat di bagian “Interaksi Variabel dan Data dengan Move”. Di contoh ini, kita udah nggak bisa lagi pake user1 setelah bikin user2 karena String di field username dari user1 udah di-move ke dalem user2. Kalau kita ngasih user2 nilai String baru buat baik email maupun username, dan makanya cuma pake nilai active sama sign_in_count dari user1, berarti user1 bakal tetep valid setelah bikin user2. Baik active maupun sign_in_count adalah tipe yang mengimplementasikan trait Copy, jadi perilaku yang kita bahas di bagian “Data Khusus Stack: Copy” bakal berlaku. Kita juga tetep bisa pake user1.email di contoh ini, karena nilainya nggak di-move keluar dari user1.

Pake Tuple Structs tanpa Field Bernama buat Bikin Tipe yang Beda

Rust juga support struct yang tampilannya mirip sama tuple, namanya tuple structs. Tuple structs punya makna tambahan yang dikasih sama nama struct-nya tapi nggak punya nama yang terkait sama field-field-nya; sebaliknya, mereka cuma punya tipe dari field-field-nya aja. Tuple structs berguna pas kita mau ngasih nama ke seluruh tuple-nya dan bikin tuple itu jadi tipe yang beda dari tuple lainnya, dan pas ngasih nama ke tiap field kayak di struct biasa bakal terasa terlalu panjang (verbose) atau redundan.

Buat mendefinisikan sebuah tuple struct, mulai pake keyword struct dan nama struct-nya diikuti sama tipe-tipe di dalem tuple-nya. Misalnya, di sini kita mendefinisikan dan pake dua tuple structs namanya Color dan Point:

Filename: src/main.rs
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

fn main() {
    let black = Color(0, 0, 0);
    let origin = Point(0, 0, 0);
}

Perhatiin ya kalau nilai black sama origin itu tipe yang beda karena mereka adalah instance dari tuple structs yang beda. Tiap struct yang kita definisikan itu adalah tipenya sendiri, walaupun field-field di dalem struct-nya mungkin punya tipe yang sama. Misalnya, sebuah fungsi yang nerima parameter tipe Color nggak bisa nerima sebuah Point sebagai argumen, walaupun kedua tipenya sama- sama disusun dari tiga nilai i32. Selain itu, instance tuple struct mirip sama tuple karena kita bisa destructure mereka jadi bagian-bagian individunya, dan kita bisa pake tanda . diikuti indeks buat akses nilai individunya. Beda sama tuple, tuple struct nuntut kita buat nulis nama tipe struct-nya pas kita destructure mereka. Misalnya, kita bakal nulis let Point(x, y, z) = origin; buat destructure nilai di titik origin jadi variabel namanya x, y, dan z.

Struct Mirip-Unit (Unit-Like Structs) tanpa Field Apa pun

Kita juga bisa mendefinisikan struct yang nggak punya field apa pun! Ini namanya unit-like structs karena perilakunya mirip sama (), yaitu tipe unit yang pernah kita sebutin di bagian “Tipe Tuple”. Unit-like structs bisa berguna pas kita perlu mengimplementasikan sebuah trait pada suatu tipe tapi nggak punya data apa pun yang mau kita simpen di tipe itu sendiri. Kita bakal bahas traits di Bab 10. Ini contoh deklarasi sama inisialisasi struct unit namanya AlwaysEqual:

Filename: src/main.rs
struct AlwaysEqual;

fn main() {
    let subject = AlwaysEqual;
}

Buat mendefinisikan AlwaysEqual, kita pake keyword struct, nama yang kita mau, terus tanda titik koma. Nggak perlu kurung kurawal atau tanda kurung! Terus kita bisa dapet instance dari AlwaysEqual di variabel subject dengan cara yang mirip: pake nama yang udah kita definisikan, tanpa kurung kurawal atau tanda kurung apa pun. Bayangin kalau nanti kita bakal mengimplementasikan perilaku buat tipe ini biar tiap instance dari AlwaysEqual itu selalu sama dengan tiap instance dari tipe lainnya, mungkin buat punya hasil yang udah tau buat tujuan testing. Kita nggak bakal butuh data apa pun buat mengimplementasikan perilaku itu! Kita bakal liat di Bab 10 gimana cara mendefinisikan traits dan mengimplementasikannya pada tipe apa pun, termasuk unit-like structs.

Ownership dari Data Struct

Di definisi struct User di Listing 5-1, kita pake tipe String yang dimiliki (owned) bukannya tipe string slice &str. Ini pilihan yang disengaja karena kita mau tiap instance dari struct ini punya semua datanya sendiri dan biar datanya valid selama seluruh struct-nya juga valid.

Mungkin juga buat struct nyimpen referensi ke data yang dimiliki sama hal lain, tapi buat lakuin itu butuh penggunaan lifetimes, sebuah fitur Rust yang bakal kita bahas di Bab 10. Lifetimes mastiin kalau data yang dirujuk sama sebuah struct itu valid selama struct-nya masih ada. Katakanlah kita nyoba nyimpen sebuah referensi di sebuah struct tanpa nentuin lifetimes, kayak berikut; ini nggak bakal jalan:

Filename: src/main.rs
struct User {
    active: bool,
    username: &str,
    email: &str,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        active: true,
        username: "someusername123",
        email: "someone@example.com",
        sign_in_count: 1,
    };
}

Compiler bakal protes kalau dia butuh penentu lifetime (lifetime specifiers):

$ cargo run
   Compiling structs v0.1.0 (file:///projects/structs)
error[E0106]: missing lifetime specifier
 --> src/main.rs:3:15
  |
3 |     username: &str,
  |               ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct User<'a> {
2 |     active: bool,
3 ~     username: &'a str,
  |

error[E0106]: missing lifetime specifier
 --> src/main.rs:4:12
  |
4 |     email: &str,
  |            ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct User<'a> {
2 |     active: bool,
3 |     username: &str,
4 ~     email: &'a str,
  |

For more information about this error, try `rustc --explain E0106`.
error: could not compile `structs` (bin "structs") due to 2 previous errors

Di Bab 10, kita bakal bahas gimana cara benerin error-error ini biar kita bisa nyimpen referensi di struct, tapi buat sekarang, kita bakal benerin error kayak gini pake tipe owned kayak String bukannya referensi kayak &str.

Contoh Program Memakai Structs

Contoh Program pake Structs

Buat mahamin kapan kita mungkin mau pake struct, yuk kita tulis program yang ngitung luas (area) dari sebuah persegi panjang. Kita bakal mulai dengan pake variabel satu-satu, terus kita refactor programnya sampe kita pake struct sebagai gantinya.

Yuk kita bikin project biner baru pake Cargo namanya rectangles yang bakal nerima lebar (width) sama tinggi (height) dari persegi panjang dalam pixel terus ngitung luasnya. Listing 5-8 nunjukin program pendek dengan satu cara buat ngelakuin itu di file src/main.rs project kita.

Filename: src/main.rs
fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}
Listing 5-8: Ngitung luas persegi panjang yang ditentuin pake variabel lebar sama tinggi yang terpisah

Sekarang, jalanin program ini pake cargo run:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/rectangles`
The area of the rectangle is 1500 square pixels.

Kode ini berhasil nyari tau luas persegi panjangnya dengan manggil fungsi area pake tiap dimensinya, tapi kita bisa lakuin lebih banyak lagi buat bikin kode ini lebih jelas dan enak dibaca.

Masalah dari kode ini keliatan sekali di signature fungsi area:

fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}

Fungsi area harusnya ngitung luas dari satu persegi panjang, tapi fungsi yang kita tulis punya dua parameter, dan nggak jelas di mana pun di program kita kalau parameter-parameter itu sebenernya berhubungan. Bakal lebih enak dibaca dan lebih gampang dikelola kalau kita ngelempokin lebar sama tinggi jadi satu. Kita udah bahas salah satu cara buat lakuin itu di bagian “Tipe Tuple” di Bab 3: yaitu pake tuple.

Refactoring pake Tuples

Listing 5-9 nunjukin versi lain dari program kita yang pake tuple.

Filename: src/main.rs
fn main() {
    let rect1 = (30, 50);

    println!(
        "The area of the rectangle is {} square pixels.",
        area(rect1)
    );
}

fn area(dimensions: (u32, u32)) -> u32 {
    dimensions.0 * dimensions.1
}
Listing 5-9: Nentuin lebar sama tinggi persegi panjang pake sebuah tuple

Dalam satu sisi, program ini lebih baik. Tuple ngebolehin kita nambahin sedikit struktur, dan kita sekarang cuma masukin satu argumen doang. Tapi di sisi lain, versi ini kurang jelas: tuple nggak ngasih nama ke elemen-elemennya, jadi kita harus ngindeks ke bagian-bagian tuple-nya, yang bikin kalkulasi kita jadi kurang gamblang.

Ketuker antara lebar sama tinggi nggak bakal ngaruh buat kalkulasi luas, tapi kalau kita mau gambar persegi panjangnya di layar, itu baru ngaruh sekali! Kita harus terus inget kalau width itu indeks tuple 0 dan height itu indeks tuple 1. Ini bakal makin susah buat orang lain buat cari tau dan diinget-inget kalau mereka mau pake kode kita. Karena kita nggak nyampein makna dari data kita di kode, sekarang jadi lebih gampang buat masukin error.

Refactoring pake Structs: Nambahin Lebih Banyak Makna

Kita pake struct buat nambahin makna dengan ngasih label ke datanya. Kita bisa ngubah tuple yang kita pake jadi sebuah struct dengan nama buat keseluruhannya sama nama buat tiap bagiannya, kayak yang ditunjukin di Listing 5-10.

Filename: src/main.rs
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        area(&rect1)
    );
}

fn area(rectangle: &Rectangle) -> u32 {
    rectangle.width * rectangle.height
}
Listing 5-10: Mendefinisikan struct Rectangle

Di sini, kita udah mendefinisikan sebuah struct terus dikasih nama Rectangle. Di dalem kurung kurawal, kita mendefinisikan field-field-nya sebagai width sama height, yang keduanya punya tipe u32. Terus, di main, kita bikin instance tertentu dari Rectangle yang punya lebar 30 sama tinggi 50.

Fungsi area kita sekarang didefinisikan dengan satu parameter, yang kita kasih nama rectangle, yang tipenya adalah immutable borrow dari sebuah instance struct Rectangle. Kayak yang udah disebutin di Bab 4, kita mau minjem (borrow) struct-nya bukannya ngambil ownership-nya. Dengan cara ini, main tetep megang ownership-nya dan bisa lanjut pake rect1, yang merupakan alasan kenapa kita pake & di signature fungsi sama pas kita manggil fungsinya.

Fungsi area akses field width sama height dari instance Rectangle (perhatiin ya kalau akses field dari instance struct yang dipinjem nggak bakal nge-move nilai field-nya, makanya kita sering liat peminjaman struct). Signature fungsi kita buat area sekarang bilang persis apa yang kita maksud: itung luas dari Rectangle, pake field width sama height-nya. Ini nyampein kalau lebar sama tinggi itu berhubungan satu sama lain, dan ngasih nama deskriptif ke nilai-nilainya bukannya pake nilai indeks tuple 0 sama 1. Ini kemenangan buat kejelasan kodenya.

Nambahin Fungsionalitas Berguna pake Derived Traits

Bakal sangat berguna kalau kita bisa nyetak sebuah instance dari Rectangle pas lagi debugging program kita dan liat nilai buat semua field-nya. Listing 5-11 nyoba pake macro println! kayak yang udah kita pake di bab-bab sebelumnya. Tapi, ini nggak bakal jalan.

Filename: src/main.rs
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {rect1}");
}
Listing 5-11: Nyoba nyetak instance Rectangle

Pas kita compile kode ini, kita dapet error dengan pesan inti kayak gini:

error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`

Macro println! bisa ngelakuin banyak jenis format, dan secara default, kurung kurawal ngasih tau println! buat pake format yang dikenal sebagai Display: output yang tujuannya buat dikonsumsi langsung sama end user. Tipe-tipe primitif yang udah kita liat sejauh ini mengimplementasikan Display secara default karena cuma ada satu cara kita mau nunjukin angka 1 atau tipe primitif lainnya ke user. Tapi sama struct, gimana cara println! harus format output-nya itu kurang jelas karena ada banyak kemungkinan tampilan: Mau pake koma apa nggak? Mau nyetak kurung kurawal-nya juga? Apakah semua field harus ditunjukin? Karena ambiguitas ini, Rust nggak nyoba buat nebak apa yang kita mau, dan struct nggak dikasih implementasi bawaan dari Display buat dipake bareng println! sama placeholder {}.

Kalau kita lanjut baca error-nya, kita bakal nemu catatan berguna ini:

   |                        |`Rectangle` cannot be formatted with the default formatter
   |                        required by this formatting parameter

Yuk kita coba! Pemanggilan macro println! sekarang bakal keliatan kayak println!("rect1 is {rect1:?}");. Naruh penentu :? di dalem kurung kurawal ngasih tau println! kalau kita mau pake format output namanya Debug. Trait Debug ngebolehin kita nyetak struct kita dengan cara yang berguna buat developer biar kita bisa liat nilainya pas lagi debugging kode kita.

Compile kodenya dengan perubahan ini. Yah! Masih dapet error:

error[E0277]: `Rectangle` doesn't implement `Debug`

Tapi lagi-lagi, compiler-nya ngasih catatan yang ngebantu:

   |                        required by this formatting parameter
   |

Rust emang masukin fungsionalitas buat nyetak info debugging, tapi kita harus secara eksplisit milih (opt in) buat bikin fungsionalitas itu tersedia buat struct kita. Caranya, kita tambahin atribut luar #[derive(Debug)] tepat sebelum definisi struct-nya, kayak yang ditunjukin di Listing 5-12.

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {rect1:?}");
}
Listing 5-12: Nambahin atribut buat derive trait Debug terus nyetak instance Rectangle pake debug formatting

Sekarang pas kita jalanin programnya, kita nggak bakal dapet error apa-apa, dan kita bakal liat output berikut:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle { width: 30, height: 50 }

Mantap! Emang bukan output yang paling cakep sih, tapi dia nunjukin nilai dari semua field buat instance ini, yang pasti bakal ngebantu sekali pas lagi debugging. Pas kita punya struct yang lebih gede, bakal berguna kalau punya output yang sedikit lebih gampang dibaca; di kasus kayak gitu, kita bisa pake {:#?} bukannya {:?} di string println!. Di contoh ini, pake gaya {:#?} bakal ngeluarin output kayak gini:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle {
    width: 30,
    height: 50,
}

Cara lain buat nyetak sebuah nilai pake format Debug itu pake macro dbg!, yang ngambil ownership dari sebuah ekspresi (beda sama println!, yang ngambil referensi), nyetak nama file sama nomor baris di mana pemanggilan macro dbg! itu ada di kode kita barengan sama nilai hasil dari ekspresi itu, terus balikin ownership nilainya.

Catatan: Manggil macro dbg! itu nyetaknya ke stream konsol standard error (stderr), beda sama println!, yang nyetaknya ke stream konsol standard output (stdout). Kita bakal bahas lebih banyak soal stderr sama stdout di bagian “Menulis Pesan Error ke Standard Error Bukannya Standard Output” di Bab 12.

Ini contoh di mana kita tertarik sama nilai yang di-assign ke field width, sekaligus nilai dari seluruh struct di rect1:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let scale = 2;
    let rect1 = Rectangle {
        width: dbg!(30 * scale),
        height: 50,
    };

    dbg!(&rect1);
}

Kita bisa naruh dbg! di sekitar ekspresi 30 * scale dan, karena dbg! balikin ownership dari nilai ekspresinya, field width bakal dapet nilai yang sama kayak kalau kita nggak ada pemanggilan dbg! di situ. Kita nggak mau dbg! ngambil ownership dari rect1, jadi kita pake sebuah referensi ke rect1 di pemanggilan selanjutnya. Ini penampakan output dari contoh ini:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/rectangles`
[src/main.rs:10:16] 30 * scale = 60
[src/main.rs:14:5] &rect1 = Rectangle {
    width: 60,
    height: 50,
}

Kita bisa liat bagian output pertama dateng dari src/main.rs baris 10 di mana kita lagi debugging ekspresi 30 * scale, dan nilai hasilnya adalah 60 (formatting Debug yang diimplementasikan buat integer itu nyetak nilainya doang). Pemanggilan dbg! di baris 14 dari src/main.rs ngeluarin nilai dari &rect1, yaitu struct Rectangle. Output ini pake formatting Debug yang rapi dari tipe Rectangle. Macro dbg! ini bisa ngebantu sekali pas kita lagi nyoba cari tau apa yang sebenernya lagi dilakuin kode kita!

Selain trait Debug, Rust juga nyediain sejumlah traits buat kita pake bareng atribut derive yang bisa nambahin perilaku berguna ke tipe data kustom kita. Traits itu sama perilakunya ada di daftar di Lampiran C. Kita bakal bahas gimana cara mengimplementasikan traits ini dengan perilaku kustom sekaligus gimana cara bikin traits kita sendiri di Bab 10. Ada juga banyak atribut lain selain derive; buat info lebih lanjut, liat bagian “Attributes” di Rust Reference.

Fungsi area kita itu sangat spesifik: dia cuma ngitung luas persegi panjang. Bakal ngebantu kalau kita ngiket perilaku ini lebih deket ke struct Rectangle kita karena dia nggak bakal jalan sama tipe lainnya. Yuk kita liat gimana kita bisa lanjut refactor kode ini dengan ngerubah fungsi area jadi sebuah method area yang didefinisikan pada tipe Rectangle kita.

Sintaks Method

Sintaks Method

Methods itu mirip sama fungsi: kita mendeklarasikan mereka pake keyword fn sama sebuah nama, mereka bisa punya parameter sama nilai return, dan mereka isinya sejumlah kode yang dijalanin pas method-nya dipanggil dari tempat lain. Beda sama fungsi, method didefinisikan di dalem konteks sebuah struct (atau enum atau trait object, yang bakal kita bahas masing-masing di Bab 6 sama Bab 18), dan parameter pertamanya selalu self, yang merepresentasikan instance dari struct tempat method itu dipanggil.

Mendefinisikan Methods

Yuk kita ubah fungsi area yang punya instance Rectangle sebagai parameter terus dijadiin sebuah method area yang didefinisikan pada struct Rectangle, kayak yang ditunjukin di Listing 5-13.

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        rect1.area()
    );
}
Listing 5-13: Mendefinisikan method area pada struct Rectangle

Buat mendefinisikan fungsi di dalem konteks Rectangle, kita mulai blok impl (implementasi) buat Rectangle. Segala hal di dalem blok impl ini bakal terkait sama tipe Rectangle. Terus kita pindahin fungsi area ke dalem kurung kurawal impl dan ngubah parameter pertama (dan di kasus ini, satu- satunya parameter) jadi self di signature sama di mana-mana di dalem body-nya. Di main, tempat kita manggil fungsi area terus masukin rect1 sebagai argumen, kita sekarang bisa pake sintaks method buat manggil method area pada instance Rectangle kita. Sintaks method ditaruh setelah sebuah instance: kita tambahin titik diikuti sama nama method, tanda kurung, sama argumen apa pun.

Di signature buat area, kita pake &self bukannya rectangle: &Rectangle. &self sebenernya singkatan dari self: &Self. Di dalem blok impl, tipe Self adalah alias buat tipe yang lagi diimplementasikan sama blok impl itu. Methods harus punya parameter namanya self bertipe Self buat parameter pertama mereka, jadi Rust ngebolehin kita nyingkat ini dengan cuma nama self di tempat parameter pertama. Perhatiin ya kalau kita tetep perlu pake & di depan singkatan self buat nunjukin kalau method ini minjem (borrows) instance Self, sama kayak pas kita nulis rectangle: &Rectangle. Methods bisa ngambil ownership dari self, minjem self secara immutable, kayak yang kita lakuin di sini, atau minjem self secara mutable, sama kayak parameter lainnya.

Kita milih &self di sini dengan alasan yang sama kayak kenapa kita pake &Rectangle di versi fungsinya: kita nggak mau ngambil ownership, dan kita cuma mau baca data di struct-nya, bukan nulis ke sana. Kalau kita mau ngerubah instance yang kita panggil method-nya sebagai bagian dari apa yang dilakuin method-nya, kita bakal pake &mut self sebagai parameter pertamanya. Punya method yang ngambil ownership dari instance dengan cuma pake self sebagai parameter pertama itu jarang; teknik ini biasanya dipake pas method-nya ngerubah (transform) self jadi sesuatu yang lain terus kita mau nyegah pemanggilnya buat pake instance aslinya setelah transformasi itu.

Alasan utama buat pake method bukannya fungsi, selain ngasih sintaks method dan nggak perlu ngulang-ngulang nulis tipe self di tiap signature method, adalah buat pengaturan kode (organization). Kita naruh semua hal yang bisa kita lakuin sama sebuah instance dari suatu tipe di dalem satu blok impl bukannya bikin orang yang nanti pake kode kita harus nyari-nyari kemampuan dari Rectangle di berbagai tempat di library yang kita kasih.

Perhatiin ya kalau kita bisa milih buat ngasih nama method sama kayak salah satu nama field struct-nya. Misalnya, kita bisa mendefinisikan method di Rectangle yang juga namanya width:

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn width(&self) -> bool {
        self.width > 0
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    if rect1.width() {
        println!("The rectangle has a nonzero width; it is {}", rect1.width);
    }
}

Di sini, kita milih buat bikin method width balikin true kalau nilai di field width dari instance-nya lebih gede dari 0 dan false kalau nilainya 0: kita bisa pake field di dalem method dengan nama yang sama buat tujuan apa pun. Di main, pas kita ngikutin rect1.width pake tanda kurung, Rust tau kita maksudnya method width. Pas kita nggak pake tanda kurung, Rust tau maksudnya field width.

Sering kali, tapi nggak selalu, pas kita ngasih method nama yang sama kayak sebuah field, kita mau method itu cuma balikin nilai di field-nya dan nggak ngelakuin hal lain. Method kayak gini namanya getters, dan Rust nggak mengimplementasikan mereka secara otomatis buat field struct kayak yang dilakuin beberapa bahasa lain. Getters itu berguna karena kita bisa bikin field-nya jadi private tapi method-nya public, dan dengan gitu ngasih akses read-only ke field itu sebagai bagian dari API public tipe tersebut. Kita bakal bahas apa itu public dan private dan gimana cara nandain field atau method sebagai public atau private di Bab 7.

Ke Mana Perginya Operator ->?

Di C sama C++, dua operator yang beda dipake buat manggil method: kita pake . kalau kita manggil method di objeknya secara langsung dan -> kalau kita manggil method di sebuah pointer ke objeknya dan perlu nge-dereference pointer-nya dulu. Dengan kata lain, kalau object itu sebuah pointer, object->something() itu mirip sama (*object).something().

Rust nggak punya padanan buat operator ->; sebaliknya, Rust punya fitur namanya automatic referencing and dereferencing (referencing dan dereferencing otomatis). Manggil method adalah salah satu dari sedikit tempat di Rust yang punya perilaku ini.

Ini cara kerjanya: pas kita manggil sebuah method pake object.something(), Rust secara otomatis nambahin &, &mut, atau * biar object cocok sama signature dari method-nya. Dengan kata lain, dua baris berikut itu sama aja:

#![allow(unused)]
fn main() {
#[derive(Debug,Copy,Clone)]
struct Point {
    x: f64,
    y: f64,
}

impl Point {
   fn distance(&self, other: &Point) -> f64 {
       let x_squared = f64::powi(other.x - self.x, 2);
       let y_squared = f64::powi(other.y - self.y, 2);

       f64::sqrt(x_squared + y_squared)
   }
}
let p1 = Point { x: 0.0, y: 0.0 };
let p2 = Point { x: 5.0, y: 6.5 };
p1.distance(&p2);
(&p1).distance(&p2);
}

Yang pertama keliatan jauh lebih bersih. Perilaku automatic referencing ini bisa jalan karena method punya penerima (receiver) yang jelas—yaitu tipe dari self. Berdasarkan penerima dan nama method-nya, Rust bisa tau secara definitif apakah method itu lagi baca (&self), nge-mutasi (&mut self), atau ngonsumsi (self). Fakta kalau Rust bikin borrowing jadi implisit buat penerima method adalah bagian gede dari kenapa ownership terasa ergonomis di praktiknya.

Methods dengan Lebih Banyak Parameter

Yuk kita latihan pake method dengan mengimplementasikan method kedua di struct Rectangle. Kali ini kita mau sebuah instance Rectangle nerima instance Rectangle lainnya dan balikin true kalau Rectangle yang kedua bisa muat sepenuhnya di dalem self (Rectangle yang pertama); kalau nggak, dia harus balikin false. Jadi, setelah kita mendefinisikan method can_hold, kita mau bisa nulis program kayak yang ditunjukin di Listing 5-14.

Filename: src/main.rs
fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}
Listing 5-14: Pake method can_hold yang belum ditulis

Output yang diharepin bakal keliatan kayak gini karena kedua dimensi rect2 itu lebih kecil dari dimensi rect1, tapi rect3 lebih lebar dari rect1:

Can rect1 hold rect2? true
Can rect1 hold rect3? false

Kita tau kita mau mendefinisikan sebuah method, jadi dia bakal ada di dalem blok impl Rectangle. Nama method-nya adalah can_hold, dan dia bakal nerima immutable borrow dari Rectangle lainnya sebagai parameter. Kita bisa tau apa tipe parameternya dengan ngeliat kode yang manggil method-nya: rect1.can_hold(&rect2) masukin &rect2, yang merupakan immutable borrow ke rect2, sebuah instance dari Rectangle. Ini masuk akal karena kita cuma perlu baca rect2 (bukannya nulis, yang bakal berarti kita butuh mutable borrow), dan kita mau main tetep punya ownership dari rect2 biar kita bisa pake lagi setelah manggil method can_hold. Nilai return dari can_hold bakal berupa Boolean, dan implementasinya bakal nge-cek apakah lebar sama tinggi dari self lebih gede dari lebar sama tinggi dari Rectangle yang satunya. Yuk kita tambahin method can_hold baru ini ke blok impl dari Listing 5-13, yang ditunjukin di Listing 5-15.

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}
Listing 5-15: Mengimplementasikan method can_hold di Rectangle yang nerima instance Rectangle lainnya sebagai parameter

Pas kita jalanin kode ini sama fungsi main di Listing 5-14, kita bakal dapet output yang kita mau. Method bisa nerima banyak parameter yang kita tambahin di signature setelah parameter self, dan parameter-parameter itu cara kerjanya persis sama kayak parameter di fungsi biasa.

Associated Functions

Semua fungsi yang didefinisikan di dalem blok impl disebut associated functions (fungsi terkait) karena mereka terkait sama tipe yang dinamain setelah kata impl. Kita bisa mendefinisikan associated functions yang nggak punya self sebagai parameter pertamanya (dan makanya bukan methods) karena mereka nggak butuh instance dari tipe itu buat jalan. Kita udah pake salah satu fungsi kayak gini: fungsi String::from yang didefinisikan pada tipe String.

Associated functions yang bukan methods sering dipake buat constructors yang bakal balikin instance baru dari struct-nya. Ini sering dikasih nama new, tapi new bukan nama khusus dan nggak bawaan dari bahasanya. Misalnya, kita bisa milih buat nyediain associated function namanya square yang punya satu parameter dimensi dan pakenya buat lebar sama tingginya, jadi lebih gampang buat bikin Rectangle bentuk persegi bukannya harus nentuin nilai yang sama dua kali:

Nama file: src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn square(size: u32) -> Self {
        Self {
            width: size,
            height: size,
        }
    }
}

fn main() {
    let sq = Rectangle::square(3);
}

Keyword Self di tipe return sama di dalem body fungsinya itu alias buat tipe yang muncul setelah keyword impl, yang di kasus ini adalah Rectangle.

Buat manggil associated function ini, kita pake sintaks :: bareng nama struct-nya; let sq = Rectangle::square(3); adalah contohnya. Fungsi ini punya namespace oleh struct-nya: sintaks :: dipake buat baik associated functions maupun namespaces yang dibuat sama modul. Kita bakal bahas modul di Bab 7.

Banyak Blok impl

Tiap struct dibolehin buat punya banyak blok impl. Contohnya, Listing 5-15 itu ekuivalen sama kode yang ditunjukin di Listing 5-16, yang punya tiap method di blok impl-nya masing-masing.

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}
Listing 5-16: Nulis ulang Listing 5-15 pake banyak blok impl

Nggak ada alesan khusus buat misahin method-method ini ke dalem banyak blok impl di sini, tapi ini sintaks yang valid. Kita bakal liat kasus di mana banyak blok impl berguna di Bab 10, pas kita bahas soal generic types dan traits.

Ringkasan

Structs ngebolehin kita bikin tipe kustom yang bermakna buat domain kita. Dengan pake struct, kita bisa nyimpen potongan data yang terkait tetep nyambung satu sama lain dan ngasih nama ke tiap potongannya buat bikin kode kita jelas. Di dalem blok impl, kita bisa mendefinisikan fungsi-fungsi yang terkait sama tipe kita, dan methods adalah jenis associated function yang ngebolehin kita nentuin perilaku yang dimiliki sama instance dari struct kita.

Tapi struct bukan satu-satunya cara kita bisa bikin tipe kustom: yuk kita beralih ke fitur enum di Rust buat nambahin tool lain ke toolbox kita.

Enum dan Pattern Matching

Di bab ini, kita bakal liat enumerations, yang juga sering disebut sebagai enums. Enum ngebolehin kita buat mendefinisikan sebuah tipe dengan menjabarkan kemungkinan variants-nya (varian). Pertama kita bakal mendefinisikan dan pake sebuah enum buat nunjukin gimana enum bisa nyimpen makna barengan sama data. Selanjutnya, kita bakal eksplor enum yang kepake sekali, namanya Option, yang mengekspresikan kalau sebuah nilai itu bisa ada isinya (something) atau nggak ada isinya sama sekali (nothing). Terus kita bakal liat gimana pattern matching (pencocokan pola) di ekspresi match bikin gampang buat ngejalanin kode yang beda-beda buat nilai enum yang beda. Terakhir, kita bakal bahas gimana konstruk if let jadi idiom lain yang nyaman dan ringkas buat handle enum di kode kita.

Mendefinisikan sebuah Enum

Mendefinisikan sebuah Enum

Kalau struct ngasih kita cara buat ngelempokin field sama data yang terkait bareng-bareng, kayak sebuah Rectangle (persegi panjang) dengan width (lebar) sama height (tinggi)-nya, enum ngasih kita cara buat bilang kalau sebuah nilai itu adalah salah satu dari sekumpulan nilai yang mungkin. Misalnya, kita mungkin mau bilang kalau Rectangle itu salah satu dari sekumpulan bentuk yang mungkin yang juga termasuk Circle (lingkaran) sama Triangle (segitiga). Buat lakuin ini, Rust ngebolehin kita buat nyimpen (encode) kemungkinan-kemungkinan ini sebagai sebuah enum.

Yuk kita liat situasi yang mungkin mau kita ekspresikan di kode dan liat kenapa enum itu berguna dan lebih cocok daripada struct di kasus ini. Katakanlah kita perlu ngurusin IP addresses (alamat IP). Saat ini, ada dua standar utama yang dipake buat alamat IP: versi empat (v4) dan versi enam (v6). Karena cuma ini kemungkinan alamat IP yang bakal ditemuin sama program kita, kita bisa nge- enumerate (menjabarkan) semua varian yang mungkin, dari sinilah enumeration dapet namanya.

Alamat IP mana pun bisa jadi alamat versi empat atau versi enam, tapi nggak bisa dua-duanya sekaligus. Sifat alamat IP itu bikin struktur data enum cocok karena sebuah nilai enum cuma bisa jadi salah satu dari varian-variannya. Baik alamat versi empat maupun versi enam itu tetep secara fundamental adalah alamat IP, jadi mereka harus diperlakukan sebagai tipe yang sama pas kode lagi nanganin situasi yang berlaku buat jenis alamat IP apa pun.

Kita bisa ekspresikan konsep ini di kode dengan mendefinisikan sebuah enumeration IpAddrKind terus nge-list jenis-jenis alamat IP yang mungkin, yaitu V4 dan V6. Ini adalah varian-varian dari enum-nya:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

IpAddrKind sekarang adalah tipe data kustom yang bisa kita pake di tempat lain di kode kita.

Nilai Enum

Kita bisa bikin instance dari masing-masing dari dua varian IpAddrKind kayak gini:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

Perhatiin ya kalau varian dari enum-nya punya namespace di bawah nama enum-nya (identifier), dan kita pake titik dua ganda buat misahin keduanya. Ini berguna karena sekarang kedua nilai IpAddrKind::V4 sama IpAddrKind::V6 itu punya tipe yang sama: IpAddrKind. Kita terus bisa, misalnya, mendefinisikan sebuah fungsi yang nerima IpAddrKind mana pun:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

Dan kita bisa manggil fungsi ini pake varian yang mana aja:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

Pake enum punya lebih banyak keuntungan lagi. Kalau dipikir-pikir lagi soal tipe alamat IP kita, saat ini kita nggak punya cara buat nyimpen data alamat IP aslinya; kita cuma tau apa jenis-nya doang. Berhubung kita baru aja belajar soal struct di Bab 5, kita mungkin tergoda buat nyelesein masalah ini pake struct kayak yang ditunjukin di Listing 6-1.

fn main() {
    enum IpAddrKind {
        V4,
        V6,
    }

    struct IpAddr {
        kind: IpAddrKind,
        address: String,
    }

    let home = IpAddr {
        kind: IpAddrKind::V4,
        address: String::from("127.0.0.1"),
    };

    let loopback = IpAddr {
        kind: IpAddrKind::V6,
        address: String::from("::1"),
    };
}
Listing 6-1: Nyimpen data dan varian IpAddrKind dari sebuah alamat IP pake struct

Di sini, kita mendefinisikan sebuah struct IpAddr yang punya dua field: sebuah field kind yang tipenya IpAddrKind (enum yang kita definisikan sebelumnya) dan sebuah field address yang tipenya String. Kita punya dua instance dari struct ini. Yang pertama itu home, dan dia punya nilai IpAddrKind::V4 sebagai kind-nya sama data alamat terkait 127.0.0.1. Instance kedua adalah loopback. Dia punya varian lain dari IpAddrKind sebagai nilai kind-nya, yaitu V6, dan punya alamat ::1 yang terkait dengannya. Kita pake struct buat ngebungkus nilai kind sama address barengan, jadi sekarang variannya terkait sama nilainya.

Tapi, merepresentasikan konsep yang sama pake enum doang itu lebih singkat: bukannya naruh enum di dalem struct, kita bisa naruh datanya langsung ke dalem tiap varian enum. Definisi baru dari enum IpAddr ini bilang kalau baik varian V4 maupun V6 bakal punya nilai String yang terkait dengannya:

fn main() {
    enum IpAddr {
        V4(String),
        V6(String),
    }

    let home = IpAddr::V4(String::from("127.0.0.1"));

    let loopback = IpAddr::V6(String::from("::1"));
}

Kita nempelin data ke tiap varian dari enum secara langsung, jadi nggak perlu lagi struct tambahan. Di sini, juga lebih gampang buat liat detail lain soal gimana cara kerja enum: nama dari tiap varian enum yang kita definisikan juga jadi sebuah fungsi yang ngonstruksi sebuah instance dari enum itu. Yaitu, IpAddr::V4() adalah pemanggilan fungsi yang nerima argumen String terus balikin sebuah instance dari tipe IpAddr. Kita otomatis dapet fungsi constructor ini sebagai hasil dari mendefinisikan enum-nya.

Ada lagi keuntungan pake enum bukannya struct: tiap varian bisa punya tipe dan jumlah data terkait yang beda-beda. Alamat IP versi empat bakal selalu punya empat komponen numerik yang nilainya antara 0 sampe 255. Kalau kita mau nyimpen alamat V4 sebagai empat nilai u8 tapi tetep mengekspresikan alamat V6 sebagai satu nilai String, kita nggak bakal bisa lakuin itu pake struct. Enum nanganin kasus ini dengan gampang:

fn main() {
    enum IpAddr {
        V4(u8, u8, u8, u8),
        V6(String),
    }

    let home = IpAddr::V4(127, 0, 0, 1);

    let loopback = IpAddr::V6(String::from("::1"));
}

Kita udah nunjukin beberapa cara beda buat mendefinisikan struktur data buat nyimpen alamat IP versi empat sama versi enam. Tapi nyatanya, pengen nyimpen alamat IP dan nyimpen info soal jenis alamat apa mereka itu hal yang sangat umum sampe-sampe standard library punya definisi yang bisa kita pake! Yuk kita liat gimana standard library mendefinisikan IpAddr: dia punya enum dan varian yang persis sama kayak yang udah kita definisikan dan pake, tapi dia nempelin data alamat di dalem variannya dalam bentuk dua struct yang beda, yang didefinisikan secara beda buat tiap varian:

#![allow(unused)]
fn main() {
struct Ipv4Addr {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}
}

Kode ini ngegambarin kalau kita bisa masukin data jenis apa pun ke dalem varian enum: strings, tipe numerik, atau structs, misalnya. Kita bahkan bisa masukin enum lain! Selain itu, tipe-tipe standard library sering kali nggak jauh lebih ribet dari apa yang mungkin kita bikin sendiri.

Perhatiin ya walaupun standard library punya definisi buat IpAddr, kita tetep bisa bikin dan pake definisi kita sendiri tanpa bentrok karena kita belum bawa definisi dari standard library itu ke scope kita. Kita bakal bahas lebih lanjut soal bawa tipe ke scope di Bab 7.

Yuk kita liat contoh enum lain di Listing 6-2: yang ini punya macem-macem tipe yang disematkan (embedded) di variannya.

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}
Listing 6-2: Sebuah enum Message yang tiap variannya nyimpen jumlah dan tipe nilai yang beda

Enum ini punya empat varian dengan tipe yang beda-beda:

  • Quit: Nggak punya data yang terkait dengannya sama sekali.
  • Move: Punya field bernama, kayak sebuah struct.
  • Write: Termasuk sebuah String tunggal.
  • ChangeColor: Termasuk tiga nilai i32.

Mendefinisikan sebuah enum dengan varian kayak yang ada di Listing 6-2 itu mirip sama mendefinisikan berbagai macam definisi struct, bedanya enum nggak pake keyword struct dan semua variannya dikelompokin di bawah satu tipe Message. Struct-struct berikut bisa nampung data yang sama kayak varian enum di atas:

struct QuitMessage; // unit struct
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct

fn main() {}

Tapi kalau kita pake struct yang beda-beda, yang mana masing-masing punya tipenya sendiri, kita nggak bakal segampang itu mendefinisikan fungsi buat nerima semua jenis pesan ini kayak yang bisa kita lakuin sama enum Message yang didefinisikan di Listing 6-2, yang merupakan sebuah tipe tunggal.

Ada satu lagi kemiripan antara enum sama struct: sama kayak kita bisa mendefinisikan methods pada structs pake impl, kita juga bisa mendefinisikan methods pada enums. Ini sebuah method namanya call yang bisa kita definisikan pada enum Message kita:

fn main() {
    enum Message {
        Quit,
        Move { x: i32, y: i32 },
        Write(String),
        ChangeColor(i32, i32, i32),
    }

    impl Message {
        fn call(&self) {
            // method body would be defined here
        }
    }

    let m = Message::Write(String::from("hello"));
    m.call();
}

Body dari method ini bakal pake self buat dapet nilai di mana kita manggil method itu. Di contoh ini, kita bikin variabel m yang punya nilai Message::Write(String::from("hello")), dan itulah yang bakal jadi self di dalem body method call pas m.call() jalan.

Yuk kita liat enum lain di standard library yang sangat umum dan kepake sekali: Option.

Enum Option dan Keuntungannya Dibandingin Nilai Null

Bagian ini ngeksplor studi kasus Option, yang merupakan enum lain yang didefinisikan sama standard library. Tipe Option nyimpen skenario yang sangat umum di mana sebuah nilai bisa ada isinya (something) atau bisa aja kosong nggak ada isinya sama sekali (nothing).

Misalnya, kalau kita minta item pertama dari list yang nggak kosong, kita bakal dapet sebuah nilai. Kalau kita minta item pertama dari list yang kosong, kita nggak dapet apa-apa. Mengekspresikan konsep ini dalam sistem tipe artinya compiler bisa nge-cek apakah kita udah handle semua kasus yang seharusnya kita handle; fungsionalitas ini bisa nyegah bug yang bener-bener umum di bahasa pemrograman lainnya.

Desain bahasa pemrograman sering kali dipikirin dari segi fitur apa aja yang dimasukin, tapi fitur apa aja yang nggak dimasukin (di-exclude) itu juga penting. Rust nggak punya fitur null kayak yang dipunyai banyak bahasa lain. Null adalah sebuah nilai yang artinya nggak ada nilai di sana. Di bahasa yang pake null, variabel itu selalu ada di salah satu dari dua state: null atau tidak-null.

Di presentasinya tahun 2009 yang judulnya “Null References: The Billion Dollar Mistake,” Tony Hoare, penemu null, bilang gini:

Saya sebut ini kesalahan satu miliar dolar saya. Waktu itu, saya lagi desain sistem tipe komprehensif pertama buat referensi di bahasa berbasis objek. Tujuan saya adalah buat mastiin kalau semua penggunaan referensi harus bener-bener aman, dengan pengecekan yang dilakuin otomatis sama compiler. Tapi saya nggak bisa nahan godaan buat masukin referensi null, cuma karena itu gampang sekali buat diimplementasikan. Ini udah memicu error, kerentanan, dan kerusakan sistem yang nggak kehitung jumlahnya, yang mungkin udah nyebabin penderitaan dan kerugian satu miliar dolar di empat puluh tahun terakhir.

Masalah dari nilai null adalah kalau kita nyoba pake nilai null seolah-olah itu nilai yang bukan-null, kita bakal dapet semacam error. Karena properti null atau tidak-null ini ada di mana-mana (pervasive), gampang sekali buat bikin error kayak gini.

Tapi, konsep yang dicoba diekspresikan sama null itu tetep berguna: sebuah null adalah nilai yang saat ini nggak valid atau absen karena suatu alasan.

Masalahnya sebenernya bukan di konsepnya tapi di implementasinya yang spesifik. Maka dari itu, Rust nggak punya null, tapi dia punya sebuah enum yang bisa mengekspresikan konsep kalau sebuah nilai itu ada atau absen. Enum ini adalah Option<T>, dan dia didefinisikan sama standard library kayak gini:

#![allow(unused)]
fn main() {
enum Option<T> {
    None,
    Some(T),
}
}

Enum Option<T> ini saking bergunanya sampe dia dimasukkan ke dalem prelude; kita nggak perlu bawa dia ke scope secara eksplisit. Varian-variannya juga dimasukkan ke prelude: kita bisa pake Some sama None secara langsung tanpa prefix Option::. Enum Option<T> ini tetep cuma enum biasa, dan Some(T) serta None itu tetep varian dari tipe Option<T>.

Sintaks <T> adalah fitur di Rust yang belum kita bahas. Itu adalah generic type parameter (parameter tipe generik), dan kita bakal bahas generik lebih detail di Bab 10. Buat sekarang, yang perlu kita tau adalah <T> artinya varian Some dari enum Option bisa nampung satu potong data dari tipe apa pun, dan tiap tipe konkret yang dipake gantiin T bakal bikin tipe Option<T> secara keseluruhan jadi tipe yang beda. Ini beberapa contoh pake nilai Option buat nampung tipe angka sama tipe char:

fn main() {
    let some_number = Some(5);
    let some_char = Some('e');

    let absent_number: Option<i32> = None;
}

Tipe dari some_number adalah Option<i32>. Tipe dari some_char adalah Option<char>, yang merupakan tipe yang beda. Rust bisa nebak (infer) tipe-tipe ini karena kita udah nentuin nilai di dalem varian Some. Buat absent_number, Rust nuntut kita buat nganotasi tipe Option secara keseluruhan: compiler nggak bisa nebak tipe yang bakal ditampung sama varian Some pasangannya kalau cuma liat dari nilai None doang. Di sini, kita ngasih tau Rust kalau maksud kita adalah absent_number itu tipenya Option<i32>.

Pas kita punya nilai Some, kita tau kalau nilainya ada dan nilainya ditampung di dalem Some-nya. Pas kita punya nilai None, dalam arti tertentu maknanya sama kayak null: kita nggak punya nilai yang valid. Terus kenapa punya Option<T> itu lebih baik daripada punya null?

Singkatnya, karena Option<T> sama T (di mana T bisa tipe apa pun) adalah tipe yang beda, compiler nggak bakal ngebolehin kita pake nilai Option<T> seolah-olah itu pasti nilai yang valid. Misalnya, kode ini nggak bakal bisa di-compile, karena dia nyoba nambahin i8 ke dalem Option<i8>:

fn main() {
    let x: i8 = 5;
    let y: Option<i8> = Some(5);

    let sum = x + y;
}

Kalau kita jalanin kode ini, kita dapet pesan error kayak gini:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
 --> src/main.rs:5:17
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + Option<i8>`
  |
  = help: the trait `Add<Option<i8>>` is not implemented for `i8`
  = help: the following other types implement trait `Add<Rhs>`:
            `&i8` implements `Add<i8>`
            `&i8` implements `Add`
            `i8` implements `Add<&i8>`
            `i8` implements `Add`

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

Sadis ya! Intinya, pesan error ini artinya Rust nggak paham gimana cara nambahin i8 sama Option<i8>, karena mereka berdua tipe yang beda. Pas kita punya nilai dari suatu tipe kayak i8 di Rust, compiler bakal mastiin kalau kita selalu punya nilai yang valid. Kita bisa lanjut dengan pede tanpa harus nge-cek null dulu sebelum pake nilai itu. Cuma pas kita punya Option<i8> (atau tipe nilai apa pun yang lagi kita kerjain) barulah kita harus khawatir soal kemungkinan nggak punya nilai, dan compiler bakal mastiin kita handle kasus itu sebelum pake nilainya.

Dengan kata lain, kita harus convert Option<T> jadi T dulu sebelum kita bisa ngelakuin operasi T pake itu. Umumnya, ini ngebantu nangkap salah satu masalah paling umum sama null: ngasumsikan kalau sesuatu itu nggak null padahal sebenernya iya.

Ngilangin risiko salah ngasumsikan nilai nggak-null ngebantu kita biar lebih pede sama kode kita. Buat punya nilai yang mungkin bisa null, kita harus secara eksplisit milih (opt in) dengan bikin tipe dari nilai itu jadi Option<T>. Terus, pas kita pake nilai itu, kita diwajibkan buat secara eksplisit nanganin kasus pas nilainya itu null. Di mana pun ada nilai yang tipenya bukan Option<T>, kita bisa dengan aman ngasumsikan kalau nilai itu bukan null. Ini adalah keputusan desain yang disengaja buat Rust buat ngebatesin null yang ada di mana-mana dan ningkatin keamanan kode Rust.

Terus gimana cara ngeluarin nilai T dari sebuah varian Some pas kita punya nilai bertipe Option<T> biar kita bisa pake nilainya? Enum Option<T> punya sangat banyak method yang kepake di berbagai situasi; kita bisa cek mereka di dokumentasinya. Biasain diri sama method-method di Option<T> bakal sangat berguna di perjalanan kita bareng Rust.

Umumnya, buat pake sebuah nilai Option<T>, kita mau punya kode yang bakal nanganin tiap variannya. Kita mau ada kode yang bakal jalan cuma pas kita punya nilai Some(T), dan kode ini dibolehin buat pake T di dalemnya. Kita mau ada kode lain yang jalan cuma kalau kita punya nilai None, dan kode itu nggak punya nilai T yang bisa dipake. Ekspresi match adalah konstruk control flow yang ngelakuin hal ini pas dipake bareng enum: dia bakal ngejalanin kode yang beda-beda tergantung varian enum mana yang dia punya, dan kode itu bisa pake data yang ada di dalem nilai yang cocok.

Konstruk Control Flow match

Konstruk Control Flow match

Rust punya konstruk control flow yang sangat kuat (powerful) namanya match yang ngebolehin kita bandingin sebuah nilai sama serangkaian pattern (pola) terus ngejalanin kode berdasarkan pattern mana yang cocok. Pattern bisa dibuat dari nilai literal, nama variabel, wildcards, dan banyak hal lainnya; Bab 19 ngebahas semua jenis pattern yang beda dan apa fungsinya. Kekuatan dari match dateng dari ekspresifitas pattern-nya dan fakta kalau compiler mastiin semua kemungkinan kasus udah di-handle.

Bayangin ekspresi match itu kayak mesin penyortir koin: koin meluncur turun di jalur yang punya lubang dengan berbagai ukuran, dan tiap koin bakal jatuh ke lubang pertama yang pas buat dia. Dengan cara yang sama, nilai bakal ngelewatin tiap pattern di sebuah match, dan di pattern pertama di mana nilainya “pas,” nilai itu bakal masuk ke blok kode terkait buat dipake pas eksekusi.

Ngomong-ngomong soal koin, yuk kita pake mereka sebagai contoh buat match! Kita bisa nulis fungsi yang nerima koin US yang nggak tau jenisnya apa dan, dengan cara yang mirip kayak mesin penghitung, nentuin koin apa itu terus balikin nilainya dalam satuan sen (cents), kayak yang ditunjukin di Listing 6-3.

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {}
Listing 6-3: Sebuah enum dan ekspresi match yang punya varian enum sebagai pattern-nya

Yuk kita bedah ekspresi match di fungsi value_in_cents. Pertama kita nulis keyword match diikuti sama sebuah ekspresi, yang di kasus ini adalah nilai coin. Ini keliatannya mirip sekali sama ekspresi kondisional yang dipake bareng if, tapi ada perbedaan gede: kalau pake if, kondisinya harus dievaluasi jadi nilai Boolean, tapi di sini dia bisa jadi tipe apa pun. Tipe dari coin di contoh ini adalah enum Coin yang kita definisikan di baris pertama.

Selanjutnya adalah arms (lengan) dari match. Sebuah arm punya dua bagian: sebuah pattern dan sejumlah kode. Arm pertama di sini punya pattern yaitu nilai Coin::Penny dan terus operator => yang misahin pattern sama kode yang bakal dijalanin. Kode di kasus ini cuma nilai 1. Tiap arm dipisahin dari arm berikutnya pake tanda koma.

Pas ekspresi match jalan, dia bandingin nilai hasilnya sama pattern dari tiap arm, secara berurutan. Kalau ada pattern yang cocok sama nilainya, kode yang terkait sama pattern itu bakal dijalanin. Kalau pattern itu nggak cocok sama nilainya, eksekusi bakal lanjut ke arm berikutnya, sama persis kayak di mesin penyortir koin. Kita bisa punya sebanyak apa pun arm yang kita butuhin: di Listing 6-3, match kita punya empat arm.

Kode yang terkait sama tiap arm itu adalah sebuah ekspresi, dan nilai hasil dari ekspresi di arm yang cocok adalah nilai yang bakal dibalikin buat seluruh ekspresi match-nya.

Kita biasanya nggak pake kurung kurawal kalau kode match arm-nya pendek, kayak di Listing 6-3 di mana tiap arm cuma balikin sebuah nilai. Kalau kita mau ngejalanin beberapa baris kode di sebuah match arm, kita wajib pake kurung kurawal, dan koma setelah arm-nya itu jadi opsional. Misalnya, kode berikut nyetak “Lucky penny!” tiap kali method-nya dipanggil pake Coin::Penny, tapi tetep balikin nilai terakhir dari blok-nya, yaitu 1:

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => {
            println!("Lucky penny!");
            1
        }
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {}

Pattern yang Nge-bind ke Nilai

Fitur berguna lainnya dari match arms adalah mereka bisa nge-bind ke bagian- bagian nilai yang cocok sama pattern-nya. Ini cara kita ngekstrak nilai keluar dari varian enum.

Sebagai contoh, yuk kita ubah salah satu varian enum kita biar nyimpen data di dalemnya. Dari tahun 1999 sampe 2008, Amerika Serikat nyetak koin quarter (25 sen) dengan desain beda-beda buat tiap 50 negara bagian di satu sisinya. Nggak ada koin lain yang dapet desain negara bagian, jadi cuma quarter yang punya nilai ekstra ini. Kita bisa nambahin informasi ini ke enum kita dengan ngerubah varian Quarter biar masukin nilai UsState yang disimpan di dalemnya, kayak yang kita lakuin di Listing 6-4.

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {}
Listing 6-4: Sebuah enum Coin di mana varian Quarter juga nyimpen nilai UsState

Bayangin ada temen kita yang lagi nyoba ngumpulin semua 50 state quarters. Pas kita lagi nyortir uang receh berdasarkan jenis koinnya, kita juga bakal nyebutin nama negara bagian yang terkait sama tiap quarter biar kalau temen kita belum punya yang itu, dia bisa nambahin ke koleksinya.

Di ekspresi match buat kode ini, kita nambahin variabel namanya state ke pattern yang nyocokin nilai varian Coin::Quarter. Pas sebuah Coin::Quarter cocok, variabel state bakal di-bind ke nilai negara bagian dari quarter itu. Terus kita bisa pake state di kode buat arm itu, kayak gini:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("State quarter from {state:?}!");
            25
        }
    }
}

fn main() {
    value_in_cents(Coin::Quarter(UsState::Alaska));
}

Kalau kita manggil value_in_cents(Coin::Quarter(UsState::Alaska)), coin bakal jadi Coin::Quarter(UsState::Alaska). Pas kita bandingin nilai itu sama tiap match arms, nggak ada satu pun yang cocok sampe kita nyampe Coin::Quarter(state). Di titik itu, binding buat state bakal jadi nilai UsState::Alaska. Kita terus bisa pake binding itu di ekspresi println!, dan dengan gitu kita dapet nilai state dalemnya keluar dari varian enum Coin buat Quarter.

Matching dengan Option<T>

Di bagian sebelumnya, kita mau dapet nilai T di dalem dari kasus Some pas lagi pake Option<T>; kita juga bisa nanganin Option<T> pake match, sama kayak yang kita lakuin sama enum Coin! Bukannya ngebandingin koin, kita bakal ngebandingin varian Option<T>, tapi cara kerja ekspresi match-nya tetep sama.

Katakanlah kita mau nulis fungsi yang nerima sebuah Option<i32> dan, kalau ada nilai di dalemnya, nambahin 1 ke nilai itu. Kalau nggak ada nilainya, fungsi itu harus balikin nilai None dan nggak nyoba ngelakuin operasi apa pun.

Fungsi ini gampang sekali ditulis, berkat match, dan bakal keliatan kayak Listing 6-5.

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}
Listing 6-5: Fungsi yang pake ekspresi match pada sebuah Option<i32>

Yuk kita teliti eksekusi pertama dari plus_one lebih dalem. Pas kita manggil plus_one(five), variabel x di body plus_one bakal punya nilai Some(5). Kita terus bandingin itu sama tiap match arm:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Nilai Some(5) nggak cocok sama pattern None, jadi kita lanjut ke arm berikutnya:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Apakah Some(5) cocok sama Some(i)? Cocok dong! Kita dapet varian yang sama. i di-bind ke nilai yang ditampung di Some, jadi i ngambil nilai 5. Kode di match arm itu kemudian dijalanin, jadi kita nambahin 1 ke nilai i dan bikin nilai Some baru dengan total 6 kita di dalemnya.

Sekarang yuk kita pertimbangkan pemanggilan kedua dari plus_one di Listing 6-5, di mana x itu None. Kita masuk ke match dan bandingin sama arm pertama:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Cocok! Nggak ada nilai buat ditambahin, jadi programnya berhenti dan balikin nilai None di sisi kanan =>. Karena arm pertama cocok, nggak ada arm lain yang dibandingin.

Gabungin match sama enum itu berguna di banyak situasi. Kita bakal sering liat pola ini di kode Rust: nge-match terhadap sebuah enum, nge-bind variabel ke data di dalemnya, terus ngejalanin kode berdasarkan data itu. Agak ribet di awal emang, tapi sekali kita udah terbiasa, kita bakal ngarep ini ada di semua bahasa. Ini terus-terusan jadi favorit user.

Matches Itu Exhaustive (Menyeluruh)

Ada satu aspek lain dari match yang perlu kita bahas: pattern-pattern di arm-nya harus mencakup semua kemungkinan. Coba liat versi dari fungsi plus_one kita ini, yang punya bug dan nggak bakal bisa di-compile:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Kita nggak nge-handle kasus None, jadi kode ini bakal nyebabin bug. Untungnya, ini adalah bug yang Rust tau cara nangkepnya. Kalau kita nyoba compile kode ini, kita bakal dapet error kayak gini:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0004]: non-exhaustive patterns: `None` not covered
 --> src/main.rs:3:15
  |
3 |         match x {
  |               ^ pattern `None` not covered
  |
note: `Option<i32>` defined here
 --> /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/core/src/option.rs:593:1
 ::: /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/core/src/option.rs:597:5
  |
  = note: not covered
  = note: the matched value is of type `Option<i32>`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
  |
4 ~             Some(i) => Some(i + 1),
5 ~             None => todo!(),
  |

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

Rust tau kalau kita nggak mencakup semua kasus yang mungkin, dan bahkan tau pattern mana yang kita kelupaan! Matches di Rust itu exhaustive: kita harus ngabisin setiap kemungkinan yang ada biar kodenya jadi valid. Terutama buat kasus Option<T>, pas Rust nyegah kita dari lupa buat secara eksplisit nanganin kasus None, dia ngelindungin kita dari ngasumsikan kalau kita punya nilai padahal mungkin kita punya null, makanya ini ngebikin kesalahan satu miliar dolar yang dibahas sebelumnya jadi mustahil terjadi.

Catch-All Patterns dan Placeholder _

Pake enum, kita juga bisa ngambil aksi khusus buat beberapa nilai tertentu, tapi buat semua nilai lainnya kita mau ngambil satu aksi default. Bayangin kita lagi bikin game di mana kalau kita ngelempar dadu dan dapet 3, player kita nggak gerak, tapi malah dapet topi fancy baru. Kalau dapet 7, player kita kehilangan topi fancy-nya. Buat semua nilai lainnya, player kita maju sejumlah langkah sesuai angka itu di papan game. Ini ekspresi match yang mengimplementasikan logika itu, dengan hasil lemparan dadu yang di-hardcoded bukannya nilai random, dan semua logika lainnya direpresentasikan sama fungsi tanpa body karena implementasi aslinya di luar cakupan contoh ini:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        other => move_player(other),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
    fn move_player(num_spaces: u8) {}
}

Buat dua arm pertama, pattern-nya adalah nilai literal 3 dan 7. Buat arm terakhir yang nyakup semua kemungkinan nilai lainnya, pattern-nya adalah variabel yang kita pilih buat dikasih nama other. Kode yang jalan buat arm other pake variabel itu dengan masukin dia ke fungsi move_player.

Kode ini berhasil di-compile, walaupun kita belum nyebutin semua nilai yang mungkin dipunyai u8, karena pattern terakhir bakal nyocokin semua nilai yang nggak disebutin secara spesifik. Catch-all pattern ini menuhin syarat kalau match itu harus exhaustive. Perhatiin ya kalau kita harus naruh arm catch-all di paling akhir karena pattern-nya dievaluasi berurutan. Kalau kita naruh arm catch-all lebih awal, arm lainnya nggak bakal pernah jalan, jadi Rust bakal ngasih tau kita kalau kita nambahin arm setelah catch-all!

Rust juga punya pattern yang bisa kita pake pas kita butuh catch-all tapi nggak mau pake nilainya di pattern catch-all itu: _ adalah pattern khusus yang nyocokin nilai apa pun dan nggak nge-bind ke nilai itu. Ini ngasih tau Rust kalau kita nggak bakal pake nilainya, jadi Rust nggak bakal ngasih warning soal variabel yang nggak kepake.

Yuk kita ubah aturan gamenya: sekarang, kalau kita ngelempar dadu selain angka 3 atau 7, kita harus ngelempar lagi. Kita udah nggak perlu pake nilai catch-all, jadi kita bisa ubah kode kita pake _ bukannya variabel namanya other:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        _ => reroll(),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
    fn reroll() {}
}

Contoh ini juga menuhin syarat exhaustiveness karena secara eksplisit kita ngabaikan semua nilai lain di arm terakhir; kita nggak ada yang kelupaan.

Terakhir, kita bakal ubah aturan gamenya sekali lagi biar nggak ada yang terjadi pas giliran kita kalau kita ngelempar selain angka 3 atau 7. Kita bisa ekspresikan itu pake nilai unit (tipe tuple kosong yang pernah kita sebut di bagian “Tipe Tuple”) sebagai kode yang nyertain arm _:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        _ => (),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
}

Di sini, kita ngasih tau Rust secara eksplisit kalau kita nggak bakal pake nilai lain apa pun yang nggak cocok sama pattern di arm sebelumnya, dan kita nggak mau ngejalanin kode apa pun di kasus ini.

Masih banyak hal lain soal pattern dan matching yang bakal kita bahas di Bab 19. Buat sekarang, kita bakal lanjut ke sintaks if let, yang bisa kepake di situasi-situasi di mana ekspresi match dirasa agak kepanjangan (wordy).

Control Flow Singkat Memakai if let dan let...else

Control Flow yang Ringkas pake if let sama let else

Sintaks if let ngebolehin kita ngegabungin if sama let jadi cara yang lebih ringkas buat nge-handle nilai yang cocok sama satu pattern sambil nyuekin sisanya. Coba liat program di Listing 6-6 yang nge-match nilai Option<u8> di variabel config_max tapi cuma mau ngejalanin kode kalau nilainya itu varian Some.

fn main() {
    let config_max = Some(3u8);
    match config_max {
        Some(max) => println!("The maximum is configured to be {max}"),
        _ => (),
    }
}
Listing 6-6: Sebuah match yang cuma peduli buat ngejalanin kode pas nilainya Some

Kalau nilainya Some, kita nyetak nilainya di varian Some dengan nge-bind nilai itu ke variabel max di dalem pattern-nya. Kita nggak mau ngelakuin apa- apa sama nilai None. Buat menuhin syarat ekspresi match, kita harus nambahin _ => () setelah memproses cuma satu varian, yang mana ini lumayan nyebelin karena jadi boilerplate code yang harus ditambahin.

Sebagai gantinya, kita bisa nulis ini dengan cara yang lebih singkat pake if let. Kode berikut perilakunya sama persis kayak match di Listing 6-6:

fn main() {
    let config_max = Some(3u8);
    if let Some(max) = config_max {
        println!("The maximum is configured to be {max}");
    }
}

Sintaks if let nerima sebuah pattern sama sebuah ekspresi yang dipisahin sama tanda sama dengan. Dia cara kerjanya sama kayak sebuah match, di mana ekspresinya dikasih ke match dan pattern-nya itu adalah arm pertamanya. Di kasus ini, pattern-nya adalah Some(max), dan max di-bind ke nilai di dalem Some. Terus kita bisa pake max di dalem body blok if let dengan cara yang sama kayak kita pake max di arm match yang terkait. Kode di dalem blok if let cuma jalan kalau nilainya cocok sama pattern-nya.

Pake if let artinya lebih dikit ngetik, lebih dikit indentasi, dan lebih dikit boilerplate code. Tapi, kita kehilangan pengecekan exhaustive (menyeluruh) yang diterapin sama match yang mastiin kalau kita nggak lupa nge-handle kasus apa pun. Milih antara match sama if let tergantung dari apa yang lagi kita lakuin di situasi kita saat itu dan apakah dapet keringkasan itu sebuah trade-off yang pas buat ngorbanin pengecekan menyeluruh.

Dengan kata lain, kita bisa mikirin if let sebagai syntax sugar buat sebuah match yang ngejalanin kode pas nilainya cocok sama satu pattern terus nyuekin semua nilai lainnya.

Kita bisa masukin sebuah else barengan sama if let. Blok kode yang ngikutin else itu sama kayak blok kode yang ngikutin kasus _ di ekspresi match yang setara sama if let dan else itu. Inget definisi enum Coin di Listing 6-4, di mana varian Quarter juga nyimpen nilai UsState. Kalau kita mau ngitung semua koin yang bukan quarter sambil nyebutin negara bagian dari si quarter, kita bisa lakuin itu pake ekspresi match, kayak gini:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    match coin {
        Coin::Quarter(state) => println!("State quarter from {state:?}!"),
        _ => count += 1,
    }
}

Atau kita bisa pake ekspresi if let dan else, kayak gini:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    if let Coin::Quarter(state) = coin {
        println!("State quarter from {state:?}!");
    } else {
        count += 1;
    }
}

Tetep di Jalur Aman (“Happy Path”) pake let...else

Pola yang umum adalah ngelakuin sebuah komputasi pas sebuah nilai ada isinya dan balikin nilai default kalau sebaliknya. Lanjut pake contoh kita soal koin dengan nilai UsState, kalau kita mau ngomong sesuatu yang lucu tergantung seberapa tua negara bagian di koin quarter itu, kita mungkin bakal nambahin sebuah method di UsState buat nge-cek umur sebuah negara bagian, kayak gini:

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    if let Coin::Quarter(state) = coin {
        if state.existed_in(1900) {
            Some(format!("{state:?} is pretty old, for America!"))
        } else {
            Some(format!("{state:?} is relatively new."))
        }
    } else {
        None
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

Terus kita mungkin pake if let buat nge-match tipe koinnya, ngenalin variabel state di dalem body kondisinya, kayak di Listing 6-7.

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    if let Coin::Quarter(state) = coin {
        if state.existed_in(1900) {
            Some(format!("{state:?} is pretty old, for America!"))
        } else {
            Some(format!("{state:?} is relatively new."))
        }
    } else {
        None
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}
Listing 6-7: Nge-cek apakah sebuah negara bagian udah ada di tahun 1900 pake kondisional bersarang (nested) di dalem sebuah if let.

Emang beres sih kerjaannya, tapi ini ngegeser kerjaannya ke dalem body statement if let, dan kalau kerjaan yang harus dilakuin lebih ribet, bakal susah buat ngikutin persis gimana cabang-cabang top-level (tingkat atas)-nya berhubungan. Kita juga bisa manfaatin fakta kalau ekspresi ngasilin nilai buat ngasilin state dari if let atau return early (kembali lebih awal), kayak di Listing 6-8. (Kita juga bisa ngelakuin hal yang mirip pake match.)

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    let state = if let Coin::Quarter(state) = coin {
        state
    } else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} is pretty old, for America!"))
    } else {
        Some(format!("{state:?} is relatively new."))
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}
Listing 6-8: Pake if let buat ngasilin sebuah nilai atau return early.

Tapi ini agak nyebelin buat diikutin dengan caranya sendiri! Satu cabang dari if let ngasilin nilai, dan yang satunya return dari fungsi sepenuhnya.

Buat bikin pola umum ini lebih enak buat diekspresikan, Rust punya let...else. Sintaks let...else nerima sebuah pattern di sisi kiri dan sebuah ekspresi di sisi kanan, mirip sekali sama if let, tapi dia nggak punya cabang if, cuma cabang else. Kalau pattern-nya cocok, dia bakal nge-bind nilai dari pattern ke scope luar. Kalau pattern-nya nggak cocok, programnya bakal ngalir ke dalem arm else, yang harus return (kembali) dari fungsinya.

Di Listing 6-9, kita bisa liat gimana penampakan Listing 6-8 pas pake let...else sebagai ganti dari if let.

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    let Coin::Quarter(state) = coin else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} is pretty old, for America!"))
    } else {
        Some(format!("{state:?} is relatively new."))
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}
Listing 6-9: Pake let...else buat ngejelasin alur (flow) lewat fungsinya.

Perhatiin ya kalau dia tetep “on the happy path” (di jalur aman yang diharapkan) di body utama fungsinya pake cara ini, tanpa harus punya alur kontrol yang bener-bener beda jauh buat dua cabang kayak yang dilakuin sama if let.

Kalau kita ada di situasi di mana program kita punya logika yang terlalu panjang (verbose) buat diekspresikan pake match, inget ya kalau if let sama let...else juga ada di dalem toolbox Rust kita.

Ringkasan

Kita sekarang udah ngebahas gimana cara pake enum buat bikin tipe kustom yang bisa jadi salah satu dari sekumpulan nilai yang di-enumerate. Kita udah nunjukin gimana tipe Option<T> bawaan standard library ngebantu kita pake sistem tipe buat nyegah error. Pas nilai enum punya data di dalemnya, kita bisa pake match atau if let buat ngekstrak dan pake nilai-nilai itu, tergantung dari seberapa banyak kasus yang perlu kita handle.

Program Rust kita sekarang bisa mengekspresikan konsep di domain kita pake struct dan enum. Bikin tipe kustom buat dipake di API kita mastiin keamanan tipe (type safety): compiler bakal mastiin fungsi kita cuma dapet nilai dari tipe yang diharapkan sama tiap fungsinya.

Buat nyediain API yang terorganisir dengan baik ke user kita yang gampang buat dipake dan cuma nge-ekspos tepat apa yang dibutuhin sama user kita aja, yuk sekarang kita beralih ke modules (modul) di Rust.

Mengelola Project yang Makin Gede pake Packages, Crates, sama Modules

Seiring kita nulis program yang makin gede, ngatur organisasi kode kita bakal makin penting. Dengan ngelempokin fungsionalitas yang terkait dan misahin kode dengan fitur yang beda, kita bakal lebih gampang nemuin di mana kode yang mengimplementasikan fitur tertentu dan ke mana harus pergi buat ngerubah cara kerja sebuah fitur.

Program-program yang udah kita tulis sejauh ini semuanya ada di satu modul di dalem satu file. Seiring berkembangnya project, kita harus ngatur kodenya dengan mecahnya jadi banyak modul dan terus jadi banyak file. Sebuah package bisa isinya banyak binary crates dan opsionalnya satu library crate. Pas sebuah package makin gede, kita bisa ngekstrak bagian-bagiannya jadi crates terpisah yang bakal jadi dependensi eksternal. Bab ini ngebahas semua teknik ini. Buat project yang bener-bener gede yang disusun dari sekumpulan packages yang saling berhubungan dan berkembang bareng, Cargo nyediain workspaces, yang bakal kita bahas di “Cargo Workspaces” di Bab 14.

Kita juga bakal bahas gimana nyembunyiin (encapsulating) detail implementasi, yang ngebolehin kita buat pake ulang (reuse) kode di tingkat yang lebih tinggi: sekali kita udah mengimplementasikan sebuah operasi, kode lain bisa manggil kode kita lewat antarmuka public-nya tanpa harus tau gimana detail implementasinya jalan. Cara kita nulis kode bakal nentuin bagian mana yang public buat dipake kode lain dan bagian mana yang merupakan detail implementasi private yang kita punya hak buat ngubahnya kapan aja. Ini cara lain buat ngebatesin jumlah detail yang harus kita inget-inget di kepala kita.

Konsep yang terkait adalah scope (ruang lingkup): konteks bersarang (nested) di mana kode itu ditulis punya sekumpulan nama yang didefinisikan sebagai “di dalem scope.” Pas baca, nulis, dan nge-compile kode, programmer dan compiler perlu tau apakah nama tertentu di tempat tertentu itu ngerujuk ke variabel, fungsi, struct, enum, modul, konstanta, atau item lainnya dan apa makna dari item itu. Kita bisa bikin scopes dan ngerubah nama apa aja yang masuk atau keluar dari scope. Kita nggak bisa punya dua item dengan nama yang sama di scope yang sama; ada tools yang tersedia buat nyelesein konflik nama.

Rust punya sejumlah fitur yang ngebolehin kita ngatur organisasi kode kita, termasuk detail apa yang diekspos, detail apa yang private, dan nama apa aja yang ada di tiap scope di program kita. Fitur-fitur ini, yang kadang secara kolektif disebut module system (sistem modul), meliputi:

  • Packages: Fitur Cargo yang ngebolehin kita buat build, test, dan nge-share crates.
  • Crates: Struktur pohon modul yang ngasilin library atau file executable.
  • Modules dan use: Ngebolehin kita buat ngontrol organisasi, scope, dan privasi dari paths.
  • Paths: Cara buat namain sebuah item, kayak struct, fungsi, atau modul.

Di bab ini, kita bakal ngebahas semua fitur ini, liat gimana mereka berinteraksi, dan ngejelasin gimana cara pakenya buat ngelola scope. Pas selesai nanti, kita bakal punya pemahaman yang solid soal sistem modul dan bisa main-main sama scope layaknya pro!

Packages dan Crates

Packages dan Crates

Bagian pertama dari sistem modul yang bakal kita bahas adalah packages (paket) dan crates.

Sebuah crate adalah jumlah kode paling kecil yang dipertimbangkan sama compiler Rust dalam satu waktu. Walaupun kita jalanin rustc bukannya cargo terus masukin satu file source code (kayak yang kita lakuin dulu sekali di “Menulis dan Menjalankan Program Rust” di Bab 1), compiler nganggep file itu sebagai sebuah crate. Crates bisa isinya modul, dan modul itu mungkin didefinisikan di file lain yang bakal di-compile barengan sama crate-nya, kayak yang bakal kita liat di bagian-bagian selanjutnya.

Sebuah crate bisa dateng dalam salah satu dari dua bentuk: sebuah binary crate atau sebuah library crate. Binary crates adalah program yang bisa kita compile jadi executable yang bisa dijalanin, kayak program command line atau sebuah server. Masing-masing harus punya fungsi namanya main yang nentuin apa yang terjadi pas program executable itu jalan. Semua crates yang udah kita bikin sejauh ini adalah binary crates.

Library crates nggak punya fungsi main, dan mereka nggak di-compile jadi executable. Sebaliknya, mereka mendefinisikan fungsionalitas yang tujuannya buat di-share ke banyak project. Misalnya, rand crate yang kita pake di Bab 2 nyediain fungsionalitas buat nge-generate angka random. Kebanyakan waktu pas Rustacean bilang “crate,” maksudnya adalah library crate, dan mereka pake kata “crate” secara bergantian sama konsep pemrograman umum dari sebuah “library” (perpustakaan).

Crate root adalah file sumber (source file) tempat compiler Rust mulai dan ngebentuk modul root (akar) dari crate kita (kita bakal jelasin modul secara mendalam di “Mendefinisikan Modul untuk Mengontrol Scope dan Privasi”).

Sebuah package adalah bundel dari satu atau lebih crates yang nyediain sekumpulan fungsionalitas. Sebuah package punya file Cargo.toml yang ngejelasin gimana cara nge-build crates itu. Cargo sebenernya adalah sebuah package yang isinya binary crate buat tool command line yang selama ini kita pake buat nge-build kode kita. Package Cargo juga punya sebuah library crate yang bergantung pada binary crate-nya. Project lain bisa bergantung pada library crate Cargo buat pake logika yang sama kayak yang dipake sama tool command line Cargo.

Sebuah package bisa isinya sebanyak apa pun binary crates yang kita mau, tapi maksimal cuma boleh punya satu library crate. Sebuah package minimal harus punya satu crate, entah itu library crate atau binary crate.

Yuk kita telusuri apa yang terjadi pas kita bikin sebuah package. Pertama kita jalanin perintah cargo new my-project:

$ cargo new my-project
     Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs

Setelah kita jalanin cargo new my-project, kita pake ls buat liat apa yang dibuat sama Cargo. Di direktori project-nya, ada file Cargo.toml, yang ngasih kita sebuah package. Ada juga direktori src yang isinya main.rs. Buka file Cargo.toml di text editor kita, dan perhatiin kalau nggak ada sebutan soal src/main.rs. Cargo ngikutin konvensi kalau src/main.rs itu adalah crate root dari sebuah binary crate dengan nama yang sama kayak package-nya. Sama juga, Cargo tau kalau direktori package-nya punya src/lib.rs, berarti package itu punya library crate dengan nama yang sama kayak package-nya, dan src/lib.rs itu adalah crate root-nya. Cargo bakal ngasih file crate root ini ke rustc buat nge-build library atau binary-nya.

Di sini, kita punya package yang isinya cuma src/main.rs, artinya dia cuma punya sebuah binary crate namanya my-project. Kalau sebuah package punya src/main.rs sama src/lib.rs, dia punya dua crates: sebuah binary dan sebuah library, yang keduanya punya nama yang sama kayak package-nya. Sebuah package bisa punya banyak binary crates dengan naruh file-file-nya di direktori src/bin: tiap file bakal jadi binary crate yang terpisah.

Mengontrol Scope dan Privacy Memakai Modul

Mendefinisikan Modul untuk Mengontrol Scope dan Privasi

Di bagian ini, kita bakal bahas modul dan bagian lain dari sistem modul, yaitu paths (jalur), yang ngebolehin kita buat namain item; keyword use yang bawa sebuah path ke dalem scope; dan keyword pub buat bikin item jadi public. Kita juga bakal bahas keyword as, external packages (package eksternal), dan operator glob.

Contekan (Cheat Sheet) Modul

Sebelum kita masuk ke detail soal modul dan paths, di sini kita nyediain referensi cepet soal gimana cara kerja modul, paths, keyword use, sama keyword pub di compiler, dan gimana kebanyakan developer ngatur kode mereka. Kita bakal ngebahas contoh-contoh dari tiap aturan ini di sepanjang bab ini, tapi tempat ini cocok sekali buat dijadiin pengingat soal gimana modul itu jalan.

  • Mulai dari crate root: Pas nge-compile sebuah crate, compiler pertama kali nyari di file crate root (biasanya src/lib.rs buat library crate atau src/main.rs buat binary crate) buat nyari kode yang mau di-compile.
  • Mendeklarasikan modul: Di file crate root, kita bisa mendeklarasikan modul baru; katakanlah kita mendeklarasikan modul “garden” pake mod garden;. Compiler bakal nyari kode modul itu di tempat-tempat ini:
    • Inline, di dalem kurung kurawal yang nggantiin titik koma setelah mod garden
    • Di file src/garden.rs
    • Di file src/garden/mod.rs
  • Mendeklarasikan submodul: Di file mana pun selain crate root, kita bisa mendeklarasikan submodul. Misalnya, kita mungkin mendeklarasikan mod vegetables; di src/garden.rs. Compiler bakal nyari kode submodul itu di dalem direktori yang namanya sama kayak modul induk (parent)-nya di tempat-tempat ini:
    • Inline, langsung setelah mod vegetables, di dalem kurung kurawal bukannya titik koma
    • Di file src/garden/vegetables.rs
    • Di file src/garden/vegetables/mod.rs
  • Paths ke kode di modul: Begitu sebuah modul jadi bagian dari crate kita, kita bisa ngerujuk ke kode di modul itu dari mana pun di crate yang sama, selama aturan privasinya ngebolehin, pake path ke kodenya. Misalnya, tipe Asparagus di modul vegetables dari garden bakal ditemuin di crate::garden::vegetables::Asparagus.
  • Private vs. public: Kode di dalem sebuah modul itu private (privat) dari modul induknya secara default. Buat bikin modul jadi public (publik), deklarasikan pake pub mod bukannya mod. Buat bikin item di dalem modul public ikutan jadi public juga, pake pub sebelum deklarasinya.
  • Keyword use: Di dalem sebuah scope, keyword use bikin shortcut (jalan pintas) ke item buat ngurangin pengulangan paths yang panjang. Di scope mana pun yang bisa ngerujuk ke crate::garden::vegetables::Asparagus, kita bisa bikin shortcut pake use crate::garden::vegetables::Asparagus; dan dari situ kita cuma perlu nulis Asparagus buat pake tipe itu di scope tersebut.

Di sini, kita bikin sebuah binary crate namanya backyard yang nunjukin aturan-aturan ini. Direktori crate-nya, yang juga namanya backyard, punya file dan direktori ini:

backyard
├── Cargo.lock
├── Cargo.toml
└── src
    ├── garden
    │   └── vegetables.rs
    ├── garden.rs
    └── main.rs

File crate root di kasus ini adalah src/main.rs, dan isinya:

Filename: src/main.rs
use crate::garden::vegetables::Asparagus;

pub mod garden;

fn main() {
    let plant = Asparagus {};
    println!("I'm growing {plant:?}!");
}

Baris pub mod garden; ngasih tau compiler buat masukin kode yang dia nemu di src/garden.rs, yaitu:

Filename: src/garden.rs
pub mod vegetables;

Di sini, pub mod vegetables; artinya kode di src/garden/vegetables.rs juga dimasukin. Kode itu adalah:

#[derive(Debug)]
pub struct Asparagus {}

Sekarang yuk kita masuk ke detail dari aturan-aturan ini dan demonstrasikan pas lagi dipake!

Ngelempokin Kode yang Terkait di Modul

Modul ngebolehin kita ngatur kode di dalem sebuah crate buat readability (keterbacaan) dan biar gampang dipake ulang (reuse). Modul juga ngebolehin kita ngontrol privasi dari item karena kode di dalem sebuah modul itu private secara default. Item private adalah detail implementasi internal yang nggak tersedia buat dipake dari luar. Kita bisa milih buat bikin modul dan item di dalemnya jadi public, yang nge-ekspos mereka biar kode eksternal bisa pake dan bergantung pada mereka.

Sebagai contoh, yuk kita tulis sebuah library crate yang nyediain fungsionalitas dari sebuah restoran. Kita bakal mendefinisikan signature dari fungsi-fungsinya tapi ngebiarin body-nya kosong buat fokus ke organisasi kodenya bukannya implementasi dari restorannya.

Di industri restoran, beberapa bagian dari restoran disebut front of house (bagian depan) dan yang lainnya back of house (bagian dapur). Front of house itu tempat para pelanggan berada; ini nyakup tempat para host ngarahin pelanggan ke tempat duduk, pelayan nerima pesanan dan pembayaran, dan bartender bikin minuman. Back of house itu tempat para koki dan tukang masak kerja di dapur, pencuci piring bersih-bersih, dan manajer ngelakuin kerjaan administratif.

Buat menstruktur crate kita pake cara ini, kita bisa ngatur fungsi-fungsinya ke dalem modul yang bersarang. Bikin library baru namanya restaurant dengan jalanin cargo new restaurant --lib. Terus masukin kode di Listing 7-1 ke src/lib.rs buat mendefinisikan beberapa modul dan signature fungsi; kode ini adalah bagian front of house.

Filename: src/lib.rs
mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}

        fn seat_at_table() {}
    }

    mod serving {
        fn take_order() {}

        fn serve_order() {}

        fn take_payment() {}
    }
}
Listing 7-1: Sebuah modul front_of_house yang nyimpen modul lain yang terus nyimpen fungsi

Kita mendefinisikan sebuah modul pake keyword mod diikuti sama nama modulnya (di kasus ini, front_of_house). Body dari modulnya ditaruh di dalem kurung kurawal. Di dalem modul, kita bisa naruh modul lain, kayak di kasus ini pake modul hosting sama serving. Modul juga bisa nampung definisi buat item lain, kayak struct, enum, konstanta, trait, dan kayak di Listing 7-1, fungsi.

Dengan pake modul, kita bisa ngelempokin definisi yang terkait dan ngasih nama kenapa mereka terkait. Programmer yang pake kode ini bisa navigasi kodenya berdasarkan kelompok-kelompoknya bukannya harus baca semua definisinya satu-satu, bikin lebih gampang buat nemuin definisi yang relevan buat mereka. Programmer yang nambahin fungsionalitas baru ke kode ini bakal tau ke mana harus naruh kodenya biar programnya tetep teratur.

Tadi, kita sempet nyebut kalau src/main.rs sama src/lib.rs itu disebut crate roots. Alasan dinamain gitu karena isi dari salah satu dari dua file ini ngebentuk modul namanya crate di root dari struktur modul crate itu, yang dikenal sebagai module tree (pohon modul).

Listing 7-2 nunjukin pohon modul buat struktur di Listing 7-1.

crate
 └── front_of_house
     ├── hosting
     │   ├── add_to_waitlist
     │   └── seat_at_table
     └── serving
         ├── take_order
         ├── serve_order
         └── take_payment
Listing 7-2: Pohon modul buat kode di Listing 7-1

Pohon ini nunjukin gimana beberapa modul bersarang di dalem modul lain; misalnya, hosting bersarang di dalem front_of_house. Pohonnya juga nunjukin kalau beberapa modul itu saling sodaraan (siblings), artinya mereka didefinisikan di modul yang sama; hosting sama serving itu sodaraan yang didefinisikan di dalem front_of_house. Kalau modul A ada di dalem modul B, kita bilang kalau modul A itu anaknya (child) modul B dan modul B itu induknya (parent) modul A. Perhatiin ya kalau seluruh pohon modul itu berakar di bawah modul implisit yang namanya crate.

Pohon modul mungkin ngingetin kita sama pohon direktori sistem file di komputer kita; ini perbandingan yang pas sekali! Kayak direktori di sistem file, kita pake modul buat ngatur kode kita. Dan kayak file di dalem direktori, kita perlu cara buat nemuin modul kita.

Paths buat Merujuk sebuah Item di dalam Pohon Modul

Paths (Jalur) buat Ngerujuk Item di Pohon Modul

Buat ngasih tau Rust di mana harus nyari sebuah item di pohon modul, kita pake path (jalur) dengan cara yang sama kayak kita pake path pas navigasi sistem file. Buat manggil sebuah fungsi, kita harus tau path-nya.

Sebuah path bisa punya dua bentuk:

  • Absolute path (path absolut) adalah path lengkap mulai dari crate root; buat kode dari crate eksternal, absolute path dimulai pake nama crate-nya, dan buat kode dari crate saat ini, dia dimulai pake literal crate.
  • Relative path (path relatif) dimulai dari modul saat ini terus pake self, super, atau identifier (nama) di modul saat ini.

Baik absolute maupun relative path diikuti sama satu atau lebih identifier yang dipisahin pake titik dua ganda (::).

Balik lagi ke Listing 7-1, katakanlah kita mau manggil fungsi add_to_waitlist. Ini sama aja kayak nanya: apa sih path dari fungsi add_to_waitlist? Listing 7-3 isinya Listing 7-1 tapi beberapa modul sama fungsinya dihapus biar fokus.

Kita bakal nunjukin dua cara buat manggil fungsi add_to_waitlist dari fungsi baru, eat_at_restaurant, yang didefinisikan di crate root. Path-path ini udah bener, tapi ada satu masalah lagi yang bakal nyegah contoh ini buat bisa di-compile gitu aja. Kita bakal jelasin alasannya bentar lagi.

Fungsi eat_at_restaurant itu bagian dari API public dari library crate kita, jadi kita nandain dia pake keyword pub. Di bagian “Mengekspos Paths dengan Keyword pub, kita bakal bahas pub lebih detail.

Filename: src/lib.rs
mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}
Listing 7-3: Manggil fungsi add_to_waitlist pake absolute dan relative paths

Pertama kali kita manggil fungsi add_to_waitlist di eat_at_restaurant, kita pake absolute path. Fungsi add_to_waitlist didefinisikan di crate yang sama kayak eat_at_restaurant, yang artinya kita bisa pake keyword crate buat mulai absolute path-nya. Terus kita masukin tiap modul secara berurutan sampe kita nyampe ke add_to_waitlist. Bayangin aja sistem file dengan struktur yang sama: kita bakal nentuin path /front_of_house/hosting/add_to_waitlist buat jalanin program add_to_waitlist; pake nama crate buat mulai dari crate root itu kayak pake / buat mulai dari root sistem file di terminal (shell) kita.

Kedua kalinya kita manggil add_to_waitlist di eat_at_restaurant, kita pake relative path. Path-nya dimulai dari front_of_house, nama modul yang didefinisikan di level yang sama di pohon modul dengan eat_at_restaurant. Di sini, kalau di sistem file, ini sama aja kayak pake path front_of_house/hosting/add_to_waitlist. Mulai pake nama modul artinya path-nya itu relatif.

Milih buat pake relative atau absolute path itu keputusan yang bakal kita ambil berdasarkan project kita, dan itu tergantung apakah kita lebih sering mindahin kode definisi item secara terpisah atau barengan sama kode yang pake item itu. Misalnya, kalau kita mindahin modul front_of_house sama fungsi eat_at_restaurant ke dalem modul namanya customer_experience, kita harus update absolute path ke add_to_waitlist, tapi relative path-nya tetep bakal valid. Sebaliknya, kalau kita mindahin fungsi eat_at_restaurant secara terpisah ke dalem modul namanya dining, absolute path buat manggil add_to_waitlist bakal tetep sama, tapi relative path-nya harus di-update. Preferensi kita secara umum adalah nentuin pake absolute path karena biasanya kita lebih sering mindahin definisi kode sama pemanggilan item secara independen satu sama lain.

Yuk kita coba compile Listing 7-3 dan cari tau kenapa ini belum bisa di-compile! Error yang kita dapet ditunjukin di Listing 7-4.

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: module `hosting` is private
 --> src/lib.rs:9:28
  |
9 |     crate::front_of_house::hosting::add_to_waitlist();
  |                            ^^^^^^^  --------------- function `add_to_waitlist` is not publicly re-exported
  |                            |
  |                            private module
  |
note: the module `hosting` is defined here
 --> src/lib.rs:2:5
  |
2 |     mod hosting {
  |     ^^^^^^^^^^^

error[E0603]: module `hosting` is private
  --> src/lib.rs:12:21
   |
12 |     front_of_house::hosting::add_to_waitlist();
   |                     ^^^^^^^  --------------- function `add_to_waitlist` is not publicly re-exported
   |                     |
   |                     private module
   |
note: the module `hosting` is defined here
  --> src/lib.rs:2:5
   |
 2 |     mod hosting {
   |     ^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` (lib) due to 2 previous errors
Listing 7-4: Error compiler pas nge-build kode di Listing 7-3

Pesan error-nya bilang kalau modul hosting itu private. Dengan kata lain, kita udah punya path yang bener buat modul hosting sama fungsi add_to_waitlist, tapi Rust nggak ngebolehin kita pake mereka karena Rust nggak punya akses ke bagian private-nya. Di Rust, semua item (fungsi, method, struct, enum, modul, sama konstanta) itu private terhadap modul induknya secara default. Kalau kita mau bikin sebuah item kayak fungsi atau struct jadi private, kita taruh dia di dalem modul.

Item di modul induk nggak bisa pake item private di dalem anak modulnya (child modules), tapi item di anak modul bisa pake item di modul leluhurnya (ancestor modules). Ini karena anak modul ngebungkus dan nyembunyiin detail implementasinya, tapi anak modul bisa liat konteks di mana mereka didefinisikan. Lanjutin analogi kita, bayangin aturan privasi ini kayak dapur restoran (back office): apa yang terjadi di sana itu private buat pelanggan restoran, tapi manajer bisa liat dan ngelakuin apa aja di restoran yang mereka jalanin.

Rust milih buat bikin sistem modul jalan kayak gini biar nyembunyiin detail implementasi internal jadi default. Dengan gitu, kita tau bagian kode internal mana yang bisa kita ubah tanpa ngerusak kode eksternalnya. Tapi, Rust tetep ngasih kita opsi buat ngekspos bagian internal dari kode anak modul ke modul leluhurnya pake keyword pub buat bikin item jadi public.

Mengekspos Paths dengan Keyword pub

Yuk balik lagi ke error di Listing 7-4 yang ngasih tau kita kalau modul hosting itu private. Kita mau fungsi eat_at_restaurant di modul induknya punya akses ke fungsi add_to_waitlist di anak modulnya, jadi kita nandain modul hosting pake keyword pub, kayak yang ditunjukin di Listing 7-5.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        fn add_to_waitlist() {}
    }
}

// -- snip --
pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}
Listing 7-5: Mendeklarasikan modul hosting sebagai pub biar bisa dipake dari eat_at_restaurant

Sayangnya, kode di Listing 7-5 tetep ngasilin error compiler, kayak yang ditunjukin di Listing 7-6.

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:10:37
   |
10 |     crate::front_of_house::hosting::add_to_waitlist();
   |                                     ^^^^^^^^^^^^^^^ private function
   |
note: the function `add_to_waitlist` is defined here
  --> src/lib.rs:3:9
   |
 3 |         fn add_to_waitlist() {}
   |         ^^^^^^^^^^^^^^^^^^^^

error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:13:30
   |
13 |     front_of_house::hosting::add_to_waitlist();
   |                              ^^^^^^^^^^^^^^^ private function
   |
note: the function `add_to_waitlist` is defined here
  --> src/lib.rs:3:9
   |
 3 |         fn add_to_waitlist() {}
   |         ^^^^^^^^^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` (lib) due to 2 previous errors
Listing 7-6: Error compiler pas nge-build kode di Listing 7-5

Apa yang terjadi? Nambahin keyword pub di depan mod hosting bikin modul itu jadi public. Dengan perubahan ini, kalau kita bisa akses front_of_house, kita bisa akses hosting. Tapi isi dari hosting itu tetep private; bikin modul jadi public nggak bikin isinya otomatis ikutan public. Keyword pub pada sebuah modul cuma ngebolehin kode di modul leluhurnya buat ngerujuk ke dia, bukan buat akses kode di dalemnya. Karena modul itu adalah wadah (container), nggak banyak yang bisa kita lakuin dengan cuma bikin modulnya jadi public; kita perlu melangkah lebih jauh terus milih buat bikin satu atau lebih item di dalem modulnya ikutan jadi public juga.

Error di Listing 7-6 bilang kalau fungsi add_to_waitlist itu private. Aturan privasi berlaku buat struct, enum, fungsi, dan method, dan juga modul.

Yuk kita bikin fungsi add_to_waitlist jadi public juga dengan nambahin keyword pub sebelum definisinya, kayak di Listing 7-7.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

// -- snip --
pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}
Listing 7-7: Nambahin keyword pub ke mod hosting dan fn add_to_waitlist ngebolehin kita manggil fungsinya dari eat_at_restaurant

Sekarang kodenya bisa di-compile! Buat liat kenapa nambahin keyword pub ngebolehin kita pake path-path ini di eat_at_restaurant sesuai sama aturan privasi, yuk kita bahas absolute sama relative path-nya.

Di absolute path, kita mulai pake crate, yaitu akar (root) dari pohon modul crate kita. Modul front_of_house didefinisikan di crate root. Walaupun front_of_house itu bukan public, tapi karena fungsi eat_at_restaurant didefinisikan di modul yang sama kayak front_of_house (artinya eat_at_restaurant sama front_of_house itu sodaraan), kita bisa ngerujuk ke front_of_house dari eat_at_restaurant. Terus lanjut ke modul hosting yang udah ditandain pake pub. Kita bisa akses modul induk dari hosting, jadi kita bisa akses hosting. Terakhir, fungsi add_to_waitlist ditandain pake pub dan kita bisa akses modul induknya, jadi pemanggilan fungsi ini berhasil!

Di relative path, logikanya sama persis kayak absolute path kecuali buat langkah pertama: bukannya mulai dari crate root, path-nya mulai dari front_of_house. Modul front_of_house didefinisikan di dalem modul yang sama kayak eat_at_restaurant, jadi relative path yang dimulai dari modul tempat eat_at_restaurant didefinisikan itu berhasil. Terus, karena hosting sama add_to_waitlist ditandain pake pub, sisa path-nya berhasil, dan pemanggilan fungsi ini jadi valid!

Kalau kita berencana buat nge-share library crate kita biar project lain bisa pake kode kita, API public kita adalah kontrak kita sama user dari crate kita yang nentuin gimana mereka bisa berinteraksi sama kode kita. Ada banyak pertimbangan soal cara ngelola perubahan di API public kita buat ngebikin orang lebih gampang bergantung sama crate kita. Pertimbangan-pertimbangan ini di luar cakupan buku ini; kalau kita tertarik sama topik ini, cek The Rust API Guidelines.

Best Practices buat Packages yang Punya Binary sama Library

Kita sempet nyebut kalau sebuah package bisa punya baik crate root binary di src/main.rs maupun crate root library di src/lib.rs, dan kedua crates ini bakal punya nama yang sama secara default. Biasanya, packages dengan pola ini yang punya baik library maupun binary crate bakal punya kode di binary crate-nya secukupnya aja buat mulai executable yang manggil kode yang didefinisikan di library crate. Ini bikin project lain bisa dapet manfaat dari sebagian besar fungsionalitas yang disediain package-nya karena kode di library crate-nya bisa di-share.

Pohon modul harusnya didefinisikan di src/lib.rs. Terus, item public mana pun bisa dipake di binary crate dengan mulai path-nya pake nama package-nya. Binary crate itu jadi user dari library crate-nya sama kayak crate eksternal lainnya yang bakal pake library crate itu: dia cuma bisa pake API public-nya. Ini ngebantu kita desain API yang bagus; kita nggak cuma jadi author-nya, tapi kita juga jadi kliennya!

Di Bab 12, kita bakal nunjukin praktik organisasi ini pake program command line yang bakal isinya binary crate sekaligus library crate.

Memulai Relative Paths dengan super

Kita bisa ngebangun relative paths yang mulai dari modul induknya, bukannya modul saat ini atau crate root, pake super di awal path-nya. Ini kayak mulai path sistem file pake sintaks .. yang artinya naik ke direktori induknya. Pake super ngebolehin kita ngerujuk item yang kita tau ada di modul induknya, yang bisa ngebikin penataan ulang pohon modul jadi lebih gampang kalau modul itu terkait erat sama induknya tapi si induk mungkin dipindah ke tempat lain di pohon modul suatu hari nanti.

Coba liat kode di Listing 7-8 yang mensimulasikan situasi di mana seorang koki benerin pesanan yang salah terus ngasih langsung ke pelanggannya. Fungsi fix_incorrect_order yang didefinisikan di modul back_of_house manggil fungsi deliver_order yang didefinisikan di modul induknya dengan nentuin path ke deliver_order, mulai pake super.

Filename: src/lib.rs
fn deliver_order() {}

mod back_of_house {
    fn fix_incorrect_order() {
        cook_order();
        super::deliver_order();
    }

    fn cook_order() {}
}
Listing 7-8: Manggil fungsi pake relative path yang dimulai dengan super

Fungsi fix_incorrect_order ada di modul back_of_house, jadi kita bisa pake super buat pindah ke modul induk dari back_of_house, yang di kasus ini adalah crate, yaitu root-nya. Dari situ, kita nyari deliver_order dan nemuin dia. Mantap! Kita rasa modul back_of_house sama fungsi deliver_order kemungkinan bakal tetep punya hubungan yang sama satu sama lain dan bakal dipindah barengan kalau kita mutusin buat ngerombak pohon modul crate kita. Makanya, kita pake super biar lebih dikit tempat yang harus di-update nanti kalau kode ini dipindah ke modul yang beda.

Bikin Structs dan Enums Jadi Public

Kita juga bisa pake pub buat nandain structs sama enums jadi public, tapi ada beberapa detail tambahan buat penggunaan pub bareng structs sama enums. Kalau kita pake pub sebelum definisi struct, kita bikin struct-nya jadi public, tapi field-field di struct-nya bakal tetep private. Kita bisa bikin tiap field jadi public atau nggak sesuai kasusnya masing-masing. Di Listing 7-9, kita mendefinisikan sebuah struct public back_of_house::Breakfast dengan field toast yang public tapi field seasonal_fruit yang private. Ini mensimulasikan kasus di restoran di mana pelanggan bisa milih roti yang dateng bareng makanannya, tapi koki yang mutusin buah apa yang nyertain makanannya berdasarkan musim sama stoknya. Ketersediaan buah berubah-ubah dengan cepet, jadi pelanggan nggak bisa milih buahnya atau bahkan tau buah apa yang bakal mereka dapet.

Filename: src/lib.rs
mod back_of_house {
    pub struct Breakfast {
        pub toast: String,
        seasonal_fruit: String,
    }

    impl Breakfast {
        pub fn summer(toast: &str) -> Breakfast {
            Breakfast {
                toast: String::from(toast),
                seasonal_fruit: String::from("peaches"),
            }
        }
    }
}

pub fn eat_at_restaurant() {
    // Order a breakfast in the summer with Rye toast.
    let mut meal = back_of_house::Breakfast::summer("Rye");
    // Change our mind about what bread we'd like.
    meal.toast = String::from("Wheat");
    println!("I'd like {} toast please", meal.toast);

    // The next line won't compile if we uncomment it; we're not allowed
    // to see or modify the seasonal fruit that comes with the meal.
    // meal.seasonal_fruit = String::from("blueberries");
}
Listing 7-9: Sebuah struct dengan beberapa field public dan beberapa field private

Karena field toast di struct back_of_house::Breakfast itu public, di eat_at_restaurant kita bisa nulis dan baca field toast pake notasi titik. Perhatiin kalau kita nggak bisa pake field seasonal_fruit di eat_at_restaurant, karena seasonal_fruit itu private. Coba di-uncomment baris yang ngubah nilai field seasonal_fruit buat liat error apa yang bakal dapet!

Terus, perhatiin karena back_of_house::Breakfast punya field private, struct ini harus nyediain fungsi associated yang public yang ngebikin (mengkonstruksi) instance dari Breakfast (kita namain summer di sini). Kalau Breakfast nggak punya fungsi kayak gitu, kita nggak bakal bisa bikin instance dari Breakfast di eat_at_restaurant karena kita nggak bisa set nilai dari field seasonal_fruit yang private di eat_at_restaurant.

Sebaliknya, kalau kita bikin sebuah enum jadi public, semua variannya ikutan jadi public. Kita cuma perlu naruh pub sebelum keyword enum, kayak yang ditunjukin di Listing 7-10.

Filename: src/lib.rs
mod back_of_house {
    pub enum Appetizer {
        Soup,
        Salad,
    }
}

pub fn eat_at_restaurant() {
    let order1 = back_of_house::Appetizer::Soup;
    let order2 = back_of_house::Appetizer::Salad;
}
Listing 7-10: Nandain enum sebagai public bikin semua variannya ikutan public.

Karena kita bikin enum Appetizer jadi public, kita bisa pake varian Soup sama Salad di eat_at_restaurant.

Enums nggak terlalu berguna kalau variannya nggak public; bakal nyebelin sekali kalau harus nganotasi semua varian enum pake pub di setiap kasus, jadi default buat varian enum adalah public. Structs biasanya berguna walaupun field-nya nggak public, jadi field struct ngikutin aturan umum bahwa segala hal itu private secara default kecuali dianotasi pake pub.

Ada satu lagi situasi yang ngelibatin pub yang belum kita bahas, dan itu adalah fitur sistem modul kita yang terakhir: keyword use. Kita bakal ngebahas use sendirian dulu, terus kita bakal nunjukin gimana cara gabungin pub sama use.

Membawa Paths ke dalam Scope Memakai Keyword use

Bawa Paths ke Dalem Scope pake Keyword use

Harus nulis paths lengkap-lengkap buat manggil fungsi tuh rasanya kurang nyaman dan ngulang-ngulang terus. Di Listing 7-7, entah kita milih absolute atau relative path buat manggil fungsi add_to_waitlist, tiap kali kita mau manggil add_to_waitlist kita harus nulis front_of_house sama hosting juga. Untungnya, ada cara buat nyederhanain proses ini: kita bisa bikin shortcut (jalan pintas) ke sebuah path pake keyword use sekali aja, dan terus pake nama yang lebih pendek itu di mana-mana di dalem scope-nya.

Di Listing 7-11, kita bawa modul crate::front_of_house::hosting ke dalem scope dari fungsi eat_at_restaurant biar kita cuma perlu nentuin hosting::add_to_waitlist buat manggil fungsi add_to_waitlist di eat_at_restaurant.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}
Listing 7-11: Bawa sebuah modul ke dalem scope pake use

Nambahin use sama sebuah path di dalem sebuah scope itu mirip kayak bikin symbolic link di sistem file. Dengan nambahin use crate::front_of_house::hosting di crate root, hosting sekarang jadi nama yang valid di scope itu, seolah-olah modul hosting itu didefinisikan di crate root. Path yang dibawa ke scope pake use juga bakal nge-cek privasi, sama kayak path lainnya.

Perhatiin ya kalau use cuma bikin shortcut buat scope tertentu di mana use itu dipanggil. Listing 7-12 mindahin fungsi eat_at_restaurant ke dalem anak modul baru namanya customer, yang mana itu beda scope dari statement use-nya, jadi body fungsinya nggak bakal bisa di-compile.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting;

mod customer {
    pub fn eat_at_restaurant() {
        hosting::add_to_waitlist();
    }
}
Listing 7-12: Statement use cuma berlaku di scope tempat dia ditaruh.

Error compiler nunjukin kalau shortcut-nya udah nggak berlaku lagi di dalem modul customer:

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0433]: failed to resolve: use of unresolved module or unlinked crate `hosting`
  --> src/lib.rs:11:9
   |
11 |         hosting::add_to_waitlist();
   |         ^^^^^^^ use of unresolved module or unlinked crate `hosting`
   |
   = help: if you wanted to use a crate named `hosting`, use `cargo add hosting` to add it to your `Cargo.toml`
help: consider importing this module through its public re-export
   |
10 +     use crate::hosting;
   |

warning: unused import: `crate::front_of_house::hosting`
 --> src/lib.rs:7:5
  |
7 | use crate::front_of_house::hosting;
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

For more information about this error, try `rustc --explain E0433`.
warning: `restaurant` (lib) generated 1 warning
error: could not compile `restaurant` (lib) due to 1 previous error; 1 warning emitted

Perhatiin ada warning juga yang bilang kalau use-nya nggak dipake di scope-nya! Buat benerin masalah ini, pindahin use-nya ke dalem modul customer juga, atau rujuk shortcut yang ada di modul induk pake super::hosting dari dalem anak modul customer.

Bikin Paths use yang Idiomatik

Di Listing 7-11, kita mungkin mikir kenapa kita nulis use crate::front_of_house::hosting terus manggil hosting::add_to_waitlist di eat_at_restaurant, bukannya nulis path use sampe ke fungsi add_to_waitlist buat dapet hasil yang sama, kayak di Listing 7-13.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting::add_to_waitlist;

pub fn eat_at_restaurant() {
    add_to_waitlist();
}
Listing 7-13: Bawa fungsi add_to_waitlist ke dalem scope pake use, yang nggak idiomatik

Walaupun Listing 7-11 sama Listing 7-13 ngelakuin tugas yang sama, Listing 7-11 adalah cara yang idiomatik buat bawa sebuah fungsi ke dalem scope pake use. Bawa modul induk dari sebuah fungsi ke dalem scope pake use artinya kita harus nyebutin modul induknya pas manggil fungsi itu. Nyebutin modul induk pas manggil fungsi bikin jelas kalau fungsi itu nggak didefinisikan secara lokal, tapi tetep minimalisir pengulangan nulis absolute path-nya. Kode di Listing 7-13 bikin nggak jelas di mana add_to_waitlist itu sebenernya didefinisikan.

Sebaliknya, pas kita bawa structs, enums, dan item lain pake use, itu idiomatik buat nyebutin full path-nya. Listing 7-14 nunjukin cara idiomatik buat bawa struct HashMap dari standard library ke dalem scope dari sebuah binary crate.

Filename: src/main.rs
use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert(1, 2);
}
Listing 7-14: Bawa HashMap ke dalem scope pake cara yang idiomatik

Nggak ada alesan kuat sih di balik idiom ini: ini cuma konvensi yang udah muncul, dan orang-orang udah kebiasa baca sama nulis kode Rust dengan cara kayak gini.

Pengecualian buat idiom ini adalah kalau kita bawa dua item yang namanya sama ke dalem scope pake statement use, karena Rust nggak ngebolehin itu. Listing 7-15 nunjukin gimana cara bawa dua tipe Result yang namanya sama tapi modul induknya beda ke dalem scope, dan gimana cara merujuk ke mereka.

Filename: src/lib.rs
use std::fmt;
use std::io;

fn function1() -> fmt::Result {
    // --snip--
    Ok(())
}

fn function2() -> io::Result<()> {
    // --snip--
    Ok(())
}
Listing 7-15: Bawa dua tipe dengan nama yang sama ke dalem scope yang sama nuntut kita buat pake modul induk mereka.

Kayak yang bisa kita liat, pake modul induk bisa ngebedain kedua tipe Result ini. Kalau kita malah nulis use std::fmt::Result sama use std::io::Result, kita bakal punya dua tipe Result di scope yang sama, dan Rust nggak bakal tau mana yang kita maksud pas kita pake nama Result.

Ngasih Nama Baru pake Keyword as

Ada solusi lain buat masalah bawa dua tipe dengan nama yang sama ke dalem scope yang sama pake use: setelah path, kita bisa nambahin as sama nama lokal baru, atau alias, buat tipe itu. Listing 7-16 nunjukin cara lain buat nulis kode di Listing 7-15 dengan nge-rename salah satu dari tipe Result itu pake as.

Filename: src/lib.rs
use std::fmt::Result;
use std::io::Result as IoResult;

fn function1() -> Result {
    // --snip--
    Ok(())
}

fn function2() -> IoResult<()> {
    // --snip--
    Ok(())
}
Listing 7-16: Nge-rename sebuah tipe pas dibawa ke dalem scope pake keyword as

Di statement use yang kedua, kita milih nama baru IoResult buat tipe std::io::Result, yang nggak bakal bentrok sama Result dari std::fmt yang juga udah kita bawa ke dalem scope. Listing 7-15 sama Listing 7-16 sama-sama dianggap idiomatik, jadi milih yang mana itu terserah kita!

Re-exporting Nama pake pub use

Pas kita bawa sebuah nama ke dalem scope pake keyword use, nama itu private buat scope tempat kita nge-import dia. Buat ngebolehin kode di luar scope itu buat ngerujuk ke nama itu seolah-olah nama itu didefinisikan di scope tersebut, kita bisa gabungin pub sama use. Teknik ini namanya re-exporting karena kita bawa item ke dalem scope tapi juga nge-ekspos item itu biar orang lain bisa bawa item itu ke scope mereka.

Listing 7-17 nunjukin kode di Listing 7-11 dengan use di modul root diganti jadi pub use.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}
Listing 7-17: Bikin sebuah nama bisa dipake dari scope baru oleh kode lain pake pub use

Sebelum perubahan ini, kode eksternal harus manggil fungsi add_to_waitlist pake path restaurant::front_of_house::hosting::add_to_waitlist(), yang juga bakal mewajibkan modul front_of_house buat ditandain sebagai pub. Sekarang, karena pub use ini udah nge-re-export modul hosting dari modul root, kode eksternal bisa pake path restaurant::hosting::add_to_waitlist() sebagai gantinya.

Re-exporting berguna pas struktur internal kode kita itu beda dari gimana programmer yang manggil kode kita bakal mikirin soal domain-nya. Misalnya, di analogi restoran ini, orang yang jalanin restorannya mikirnya “front of house” sama “back of house.” Tapi pelanggan yang dateng ke restoran mungkin nggak bakal mikirin bagian-bagian restoran pake istilah-istilah itu. Pake pub use, kita bisa nulis kode kita pake satu struktur tapi nge-ekspos struktur yang beda. Ngelakuin ini bikin library kita terorganisir dengan baik buat programmer yang ngerjain library-nya dan juga buat programmer yang manggil library-nya. Kita bakal liat contoh lain dari pub use dan gimana pengaruhnya ke dokumentasi crate kita di “Ngekspor API Public yang Nyaman pake pub use di Bab 14.

Pake Package Eksternal

Di Bab 2, kita bikin project game tebak angka yang pake package eksternal namanya rand buat dapet angka random. Buat pake rand di project kita, kita nambahin baris ini ke Cargo.toml:

Filename: Cargo.toml
rand = "0.8.5"

Nambahin rand sebagai dependency (dependensi) di Cargo.toml ngasih tau Cargo buat download package rand sama dependensinya dari crates.io terus nyediain rand buat project kita.

Terus, buat bawa definisi rand ke dalem scope package kita, kita nambahin baris use yang dimulai dari nama crate-nya, rand, dan nge-list item-item yang mau kita bawa ke dalem scope. Inget kan di “Menghasilkan Angka Random” di Bab 2, kita bawa trait Rng ke dalem scope terus manggil fungsi rand::thread_rng:

use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Anggota komunitas Rust udah bikin sangat banyak package yang tersedia di crates.io, dan masukin salah satu dari mereka ke package kita itu ngelibatin langkah-langkah yang sama: daftarin mereka di file Cargo.toml package kita terus pake use buat bawa item dari crate mereka ke dalem scope.

Perhatiin ya kalau standard library std itu juga sebuah crate yang eksternal buat package kita. Karena standard library udah dipaket bareng bahasa Rust, kita nggak perlu ngubah Cargo.toml buat masukin std. Tapi kita tetep perlu ngerujuk ke dia pake use buat bawa item-item dari sana ke dalem scope package kita. Misalnya, buat HashMap kita bakal pake baris ini:

#![allow(unused)]
fn main() {
use std::collections::HashMap;
}

Ini adalah absolute path yang dimulai dari std, nama dari crate standard library.

Pake Nested Paths Buat Ngerapihin Daftar use yang Panjang

Kalau kita pake banyak item yang didefinisikan di crate yang sama atau modul yang sama, nulisin tiap item di barisnya sendiri-sendiri bakal menuhin tempat secara vertikal di file kita. Misalnya, dua statement use ini yang kita pake di game tebak angka di Listing 2-4 bawa item-item dari std ke dalem scope:

Filename: src/main.rs
use rand::Rng;
// --snip--
use std::cmp::Ordering;
use std::io;
// --snip--

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

Sebagai gantinya, kita bisa pake nested paths (path bersarang) buat bawa item- item yang sama ke dalem scope dalam satu baris. Kita lakuin ini dengan nulisin bagian yang sama dari path-nya, diikuti sama dua titik dua (::), terus kurung kurawal di sekitar list dari bagian-bagian path yang beda, kayak yang ditunjukin di Listing 7-18.

Filename: src/main.rs
use rand::Rng;
// --snip--
use std::{cmp::Ordering, io};
// --snip--

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse().expect("Please type a number!");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}
Listing 7-18: Nentuin nested path buat bawa beberapa item dengan awalan yang sama ke dalem scope

Di program yang lebih gede, bawa banyak item ke dalem scope dari crate atau modul yang sama pake nested paths bisa ngurangin sekali jumlah statement use terpisah yang dibutuhin!

Kita bisa pake nested path di level mana pun di dalem sebuah path, yang berguna sekali pas ngegabungin dua statement use yang nge-share subpath. Misalnya, Listing 7-19 nunjukin dua statement use: satu yang bawa std::io ke dalem scope dan satu lagi yang bawa std::io::Write ke dalem scope.

Filename: src/lib.rs
use std::io;
use std::io::Write;
Listing 7-19: Dua statement use di mana salah satunya adalah subpath dari yang lain

Bagian yang sama dari kedua path ini adalah std::io, dan itu adalah path lengkap pertama. Buat ngegabungin dua path ini jadi satu statement use, kita bisa pake self di dalem nested path, kayak yang ditunjukin di Listing 7-20.

Filename: src/lib.rs
use std::io::{self, Write};
Listing 7-20: Ngegabungin path di Listing 7-19 jadi satu statement use

Baris ini bawa std::io sama std::io::Write ke dalem scope.

Operator Glob

Kalau kita mau bawa semua item public yang didefinisikan di sebuah path ke dalem scope, kita bisa nulis path itu diikuti sama operator glob *:

#![allow(unused)]
fn main() {
use std::collections::*;
}

Statement use ini bawa semua item public yang didefinisikan di std::collections ke dalem scope saat ini. Hati-hati ya pas pake operator glob! Glob bisa bikin kita lebih susah buat tau nama apa aja yang ada di dalem scope dan di mana nama yang dipake di program kita itu didefinisikan. Selain itu, kalau dependensinya ngerubah definisi mereka, apa yang kita import juga ikut berubah, yang bisa memicu error compiler pas kita upgrade dependensi kalau dependensinya nambahin definisi dengan nama yang sama kayak definisi punya kita di scope yang sama, misalnya.

Operator glob sering sekali dipake pas lagi testing buat bawa semua hal yang mau di-test ke dalem modul tests; kita bakal bahas itu di “Gimana Cara Nulis Test” di Bab 11. Operator glob juga kadang dipake sebagai bagian dari pola prelude: liat dokumentasi standard library buat info lebih lanjut soal pola itu.

Memisahkan Modul ke dalam File Berbeda

Misahin Modul ke Dalem File yang Beda

Sejauh ini, semua contoh di bab ini mendefinisikan beberapa modul di dalem satu file. Pas modulnya jadi makin gede, kita mungkin mau mindahin definisi mereka ke file terpisah biar kodenya lebih gampang dinavigasi.

Sebagai contoh, yuk kita mulai dari kode di Listing 7-17 yang punya beberapa modul restoran. Kita bakal ngekstrak modul-modulnya ke dalem file bukannya punya semua modul didefinisikan di file crate root. Di kasus ini, file crate root-nya adalah src/lib.rs, tapi prosedur ini juga berlaku buat binary crates yang mana file crate root-nya adalah src/main.rs.

Pertama kita bakal ngekstrak modul front_of_house ke dalem filenya sendiri. Hapus kode di dalem kurung kurawal buat modul front_of_house, nyisain cuma deklarasi mod front_of_house; aja, jadi src/lib.rs isinya cuma kode yang ditunjukin di Listing 7-21. Perhatiin ya kalau ini nggak bakal bisa di-compile sampe kita bikin file src/front_of_house.rs di Listing 7-22.

Filename: src/lib.rs
mod front_of_house;

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}
Listing 7-21: Mendeklarasikan modul front_of_house yang body-nya bakal ada di src/front_of_house.rs

Selanjutnya, taruh kode yang tadinya ada di dalem kurung kurawal ke file baru namanya src/front_of_house.rs, kayak yang ditunjukin di Listing 7-22. Compiler tau buat nyari di file ini karena dia nemu deklarasi modul di crate root dengan nama front_of_house.

Filename: src/front_of_house.rs
pub mod hosting {
    pub fn add_to_waitlist() {}
}
Listing 7-22: Definisi di dalem modul front_of_house di src/front_of_house.rs

Perhatiin ya kalau kita cuma perlu load (muat) file pake deklarasi mod sekali aja di pohon modul kita. Begitu compiler tau filenya adalah bagian dari project (dan tau di mana letak kode itu di pohon modul gara-gara tempat kita naruh statement mod), file lain di project kita harus ngerujuk ke kode di file yang di-load pake path ke tempat di mana dia dideklarasikan, kayak yang udah dibahas di bagian “Paths (Jalur) buat Ngerujuk Item di Pohon Modul”. Dengan kata lain, mod itu bukan operasi “include” kayak yang mungkin kita liat di bahasa pemrograman lainnya.

Selanjutnya, kita bakal ngekstrak modul hosting ke dalem filenya sendiri. Prosesnya agak beda karena hosting adalah anak modul dari front_of_house, bukan dari modul root. Kita bakal naruh file buat hosting di direktori baru yang namanya ngikutin leluhurnya di pohon modul, di kasus ini src/front_of_house.

Buat mulai mindahin hosting, kita ubah src/front_of_house.rs biar isinya cuma deklarasi dari modul hosting aja:

Filename: src/front_of_house.rs
pub mod hosting;

Terus kita bikin direktori src/front_of_house sama file hosting.rs buat nampung definisi yang dibikin di modul hosting:

Filename: src/front_of_house/hosting.rs
pub fn add_to_waitlist() {}

Kalau kita malah naruh hosting.rs di direktori src, compiler bakal ngarepin kode hosting.rs ada di modul hosting yang dideklarasikan di crate root, dan bukan dideklarasikan sebagai anak dari modul front_of_house. Aturan compiler soal file mana yang harus dicek buat kode modul mana bikin direktori dan file jadi lebih mirip (match) sama pohon modulnya.

Alternate File Paths (Path File Alternatif)

Sejauh ini kita udah ngebahas path file yang paling idiomatik yang dipake sama compiler Rust, tapi Rust juga support gaya path file yang lebih lama. Buat modul namanya front_of_house yang dideklarasikan di crate root, compiler bakal nyari kode modulnya di:

  • src/front_of_house.rs (yang baru aja kita bahas)
  • src/front_of_house/mod.rs (gaya lama, tetep di-support path-nya)

Buat modul namanya hosting yang merupakan submodul dari front_of_house, compiler bakal nyari kode modulnya di:

  • src/front_of_house/hosting.rs (yang baru aja kita bahas)
  • src/front_of_house/hosting/mod.rs (gaya lama, tetep di-support path-nya)

Kalau kita pake kedua gaya ini buat modul yang sama, kita bakal dapet error compiler. Pake campuran kedua gaya buat modul yang beda di project yang sama itu dibolehin, tapi mungkin bakal ngebingungin orang yang lagi navigasi project kita.

Kekurangan utama dari gaya yang pake file namanya mod.rs adalah project kita bisa berakhir dengan banyak file yang namanya mod.rs, yang bisa ngebingungin pas kita ngebuka mereka semua di editor secara bersamaan.

Kita udah mindahin kode tiap modul ke file yang terpisah, dan pohon modulnya tetep sama. Pemanggilan fungsi di eat_at_restaurant bakal jalan tanpa modifikasi apa pun, walaupun definisinya sekarang tinggal di file yang beda. Teknik ini ngebolehin kita mindahin modul ke file baru seiring ukurannya makin gede.

Perhatiin ya kalau statement pub use crate::front_of_house::hosting di src/lib.rs juga nggak berubah, dan use juga nggak punya pengaruh apa pun ke file mana yang di-compile sebagai bagian dari crate-nya. Keyword mod mendeklarasikan modul, dan Rust nyari di file dengan nama yang sama kayak modulnya buat nyari kode yang masuk ke modul itu.

Ringkasan

Rust ngebolehin kita misahin sebuah package jadi beberapa crates dan sebuah crate jadi beberapa modul biar kita bisa ngerujuk ke item yang didefinisikan di satu modul dari modul lain. Kita bisa lakuin ini dengan nentuin absolute atau relative paths. Path-path ini bisa dibawa ke dalem scope pake statement use biar kita bisa pake path yang lebih pendek buat banyak pemakaian item itu di scope tersebut. Kode modul itu private secara default, tapi kita bisa bikin definisinya jadi public dengan nambahin keyword pub.

Di bab berikutnya, kita bakal liat beberapa struktur data koleksi (collection) di standard library yang bisa kita pake di dalem kode kita yang udah terorganisir dengan rapi.

Koleksi Umum (Common Collections)

Standard library Rust nyediain sejumlah struktur data yang sangat berguna yang disebut collections (koleksi). Kebanyakan tipe data lain merepresentasikan satu nilai spesifik, tapi koleksi bisa nampung banyak nilai. Beda sama tipe array sama tuple bawaan, data yang ditunjuk sama koleksi ini disimpan di heap, yang artinya jumlah datanya nggak perlu diketahuin pas compile time dan bisa nambah atau berkurang seiring programnya jalan. Tiap jenis koleksi punya kemampuan dan biaya (cost) yang beda-beda, dan milih yang paling pas buat situasi kita saat itu adalah skill yang bakal kita kembangin seiring berjalannya waktu. Di bab ini, kita bakal ngebahas tiga koleksi yang sering sekali dipake di program Rust:

  • Sebuah vector ngebolehin kita nyimpen sejumlah nilai yang jumlahnya bisa berubah-ubah dan posisinya bersebelahan satu sama lain.
  • Sebuah string adalah koleksi dari karakter-karakter. Kita udah sempet nyebut tipe String sebelumnya, tapi di bab ini kita bakal bahas lebih mendalam.
  • Sebuah hash map ngebolehin kita buat ngaitin (associate) sebuah nilai sama sebuah key tertentu. Ini adalah implementasi spesifik dari struktur data yang lebih umum yang disebut map.

Buat belajar soal jenis koleksi lain yang disediain sama standard library, cek dokumentasinya.

Kita bakal bahas gimana cara bikin dan ngubah vectors, strings, dan hash maps, serta apa yang bikin masing-masing dari mereka itu spesial.

Menyimpan Daftar Nilai Memakai Vectors

Nyimpen Daftar Nilai pake Vectors

Tipe koleksi pertama yang bakal kita liat adalah Vec<T>, yang juga dikenal sebagai vector. Vectors ngebolehin kita nyimpen lebih dari satu nilai di dalem satu struktur data tunggal yang naruh semua nilai itu bersebelahan di memori. Vectors cuma bisa nyimpen nilai dengan tipe yang sama. Mereka berguna pas kita punya daftar (list) item, kayak baris-baris teks di sebuah file atau harga-harga barang di keranjang belanja.

Bikin Vector Baru

Buat bikin vector baru yang kosong, kita manggil fungsi Vec::new, kayak yang ditunjukin di Listing 8-1.

fn main() {
    let v: Vec<i32> = Vec::new();
}
Listing 8-1: Bikin vector baru yang kosong buat nampung nilai tipe i32

Perhatiin ya kalau kita nambahin anotasi tipe di sini. Karena kita nggak masukin nilai apa pun ke vector ini, Rust nggak tau jenis elemen apa yang mau kita simpen. Ini poin penting. Vectors diimplementasikan pake generik (generics); kita bakal bahas cara pake generik bareng tipe kita sendiri di Bab 10. Buat sekarang, tau aja kalau tipe Vec<T> yang disediain sama standard library bisa nampung tipe apa pun. Pas kita bikin vector buat nampung tipe spesifik, kita bisa nentuin tipenya di dalem kurung siku. Di Listing 8-1, kita ngasih tau Rust kalau Vec<T> di variabel v bakal nampung elemen dengan tipe i32.

Biasanya, kita bakal bikin Vec<T> dengan nilai awal dan Rust bakal nebak (infer) tipe nilai yang mau kita simpen, jadi kita jarang sekali butuh anotasi tipe kayak gini. Rust nyediain macro yang praktis sekali, vec!, yang bakal bikin vector baru yang isinya nilai-nilai yang kita kasih. Listing 8-2 bikin Vec<i32> baru yang isinya nilai 1, 2, dan 3. Tipe integer-nya adalah i32 karena itu adalah tipe integer default, kayak yang kita bahas di bagian “Tipe Data” di Bab 3.

fn main() {
    let v = vec![1, 2, 3];
}
Listing 8-2: Bikin vector baru yang isinya nilai-nilai

Karena kita udah ngasih nilai awal i32, Rust bisa nebak kalau tipe dari v adalah Vec<i32>, dan anotasi tipenya nggak dibutuhin. Selanjutnya, kita bakal liat cara ngubah sebuah vector.

Ngubah Vector

Buat bikin vector terus nambahin elemen ke dalemnya, kita bisa pake method push, kayak yang ditunjukin di Listing 8-3.

fn main() {
    let mut v = Vec::new();

    v.push(5);
    v.push(6);
    v.push(7);
    v.push(8);
}
Listing 8-3: Pake method push buat nambahin nilai ke vector

Sama kayak variabel mana pun, kalau kita mau bisa ngubah nilainya, kita harus bikin variabelnya mutable pake keyword mut, kayak yang dibahas di Bab 3. Angka-angka yang kita taruh di dalemnya semuanya bertipe i32, dan Rust nebak ini dari datanya, jadi kita nggak perlu anotasi Vec<i32>.

Ngebaca Elemen dari Vectors

Ada dua cara buat ngerujuk ke nilai yang disimpan di sebuah vector: lewat indexing atau pake method get. Di contoh-contoh berikut, kita udah nganotasi tipe dari nilai yang dibalikin sama fungsi-fungsi ini biar lebih jelas.

Listing 8-4 nunjukin kedua cara buat akses nilai di dalem vector, pake sintaks indexing dan method get.

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let third: &i32 = &v[2];
    println!("The third element is {third}");

    let third: Option<&i32> = v.get(2);
    match third {
        Some(third) => println!("The third element is {third}"),
        None => println!("There is no third element."),
    }
}
Listing 8-4: Pake sintaks indexing dan pake method get buat akses item di vector

Ada beberapa detail yang perlu diperhatiin di sini. Kita pake nilai indeks 2 buat dapet elemen ketiga karena vectors diindeks pake angka, mulai dari nol. Pake & sama [] ngasih kita sebuah referensi ke elemen di nilai indeks tersebut. Pas kita pake method get dengan indeks yang dimasukin sebagai argumen, kita dapet Option<&T> yang bisa kita pake bareng match.

Rust nyediain dua cara buat ngerujuk elemen biar kita bisa milih gimana program kita bereaksi pas kita nyoba pake nilai indeks yang di luar rentang (range) elemen yang ada. Sebagai contoh, yuk kita liat apa yang terjadi pas kita punya vector isinya lima elemen terus kita nyoba akses elemen di indeks 100 pake kedua teknik ini, kayak yang ditunjukin di Listing 8-5.

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let does_not_exist = &v[100];
    let does_not_exist = v.get(100);
}
Listing 8-5: Nyoba akses elemen di indeks 100 di vector yang isinya lima elemen

Pas kita jalanin kode ini, metode pertama [] bakal bikin program panic karena dia ngerujuk ke elemen yang nggak ada. Metode ini paling pas dipake kalau kita mau program kita nge-crash kalau ada percobaan buat akses elemen ngelewatin akhir vector.

Pas method get dikasih indeks yang di luar vector, dia bakal balikin None tanpa bikin panic. Kita bakal pake metode ini kalau akses elemen di luar rentang vector mungkin sesekali kejadian di bawah kondisi normal. Kode kita terus bakal punya logika buat nanganin dapet Some(&element) atau None, kayak yang dibahas di Bab 6. Misalnya, indeksnya bisa jadi dateng dari orang yang masukin angka. Kalau mereka nggak sengaja masukin angka yang kegedean dan programnya dapet nilai None, kita bisa ngasih tau user berapa banyak item yang ada di vector saat ini dan ngasih mereka kesempatan lagi buat masukin nilai yang valid. Itu bakal lebih user-friendly daripada nge-crash-in program gara-gara typo!

Pas programnya punya referensi yang valid, borrow checker bakal nerapin aturan ownership dan borrowing (yang dibahas di Bab 4) buat mastiin referensi ini dan referensi apa pun lainnya ke isi vector tetep valid. Inget aturan yang bilang kalau kita nggak bisa punya referensi mutable sama immutable di scope yang sama. Aturan itu berlaku di Listing 8-6, di mana kita megang immutable reference ke elemen pertama di vector terus nyoba nambahin elemen di akhir vector. Program ini nggak bakal jalan kalau kita juga nyoba ngerujuk ke elemen itu nanti di fungsinya.

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];

    let first = &v[0];

    v.push(6);

    println!("The first element is: {first}");
}
Listing 8-6: Nyoba nambahin elemen ke vector sambil megang referensi ke sebuah item

Compile kode ini bakal ngasilin error ini:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:5
  |
4 |     let first = &v[0];
  |                  - immutable borrow occurs here
5 |
6 |     v.push(6);
  |     ^^^^^^^^^ mutable borrow occurs here
7 |
8 |     println!("The first element is: {first}");
  |                                      ----- immutable borrow later used here

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

Kode di Listing 8-6 mungkin keliatannya harusnya jalan: kenapa referensi ke elemen pertama harus peduli sama perubahan di akhir vector? Error ini terjadi karena cara kerja vectors: karena vectors naruh nilai bersebelahan satu sama lain di memori, nambahin elemen baru di akhir vector mungkin butuh ngalokasiin memori baru dan ngopi elemen-elemen lama ke ruang yang baru, kalau ternyata nggak ada ruang yang cukup buat naruh semua elemen bersebelahan di tempat vector itu saat ini disimpan. Di kasus itu, referensi ke elemen pertama bakal nunjuk ke memori yang udah di-dealokasi (deallocated memory). Aturan borrowing nyegah program berakhir di situasi kayak gitu.

Catatan: Buat detail implementasi lebih lanjut dari tipe Vec<T>, liat “The Rustonomicon”.

Iterasi Lewat Nilai-nilai di dalem Vector

Buat akses tiap elemen di vector secara bergiliran, kita bakal iterasi lewat semua elemennya bukannya pake indeks buat akses satu-satu. Listing 8-7 nunjukin cara pake for loop buat dapet immutable references ke tiap elemen di vector nilai i32 terus nyetak semuanya.

fn main() {
    let v = vec![100, 32, 57];
    for i in &v {
        println!("{i}");
    }
}
Listing 8-7: Nyetak tiap elemen di vector dengan iterasi lewat elemen pake for loop

Kita juga bisa iterasi lewat mutable references ke tiap elemen di mutable vector buat bikin perubahan ke semua elemen. for loop di Listing 8-8 bakal nambahin 50 ke tiap elemen.

fn main() {
    let mut v = vec![100, 32, 57];
    for i in &mut v {
        *i += 50;
    }
}
Listing 8-8: Iterasi lewat mutable references ke elemen-elemen di vector

Buat ngubah nilai yang ditunjuk sama mutable reference, kita harus pake operator dereference * buat nyampe ke nilai di i sebelum kita bisa pake operator +=. Kita bakal bahas operator dereference lebih dalem di bagian “Ngikutin Pointer ke Nilai pake Operator Dereference” di Bab 15.

Iterasi lewat sebuah vector, entah itu secara immutable atau mutable, selalu aman berkat aturan borrow checker. Kalau kita nyoba insert atau remove item di body for loop di Listing 8-7 sama Listing 8-8, kita bakal dapet error compiler yang mirip kayak yang kita dapet dari kode di Listing 8-6. Referensi ke vector yang dipegang sama for loop nyegah modifikasi keseluruhan vector di waktu yang sama.

Pake Enum Buat Nyimpen Banyak Tipe

Vectors cuma bisa nyimpen nilai yang tipenya sama. Ini bisa jadi kurang nyaman; pasti ada kasus di mana kita butuh nyimpen daftar item yang tipenya beda-beda. Untungnya, varian dari sebuah enum didefinisikan di bawah tipe enum yang sama, jadi pas kita butuh satu tipe buat ngewakilin elemen dari berbagai tipe, kita bisa bikin dan pake enum!

Misalnya, katakanlah kita mau dapet nilai dari sebuah baris di spreadsheet di mana beberapa kolom di baris itu isinya integer, beberapa angka floating-point, dan beberapa lagi strings. Kita bisa mendefinisikan enum yang varian-variannya bakal nampung tipe nilai yang beda, dan semua varian enum itu bakal dianggap sebagai tipe yang sama: yaitu tipe dari enum tersebut. Terus kita bisa bikin vector buat nampung enum itu dan akhirnya bisa nampung tipe yang beda-beda. Kita udah demonstrasikan ini di Listing 8-9.

fn main() {
    enum SpreadsheetCell {
        Int(i32),
        Float(f64),
        Text(String),
    }

    let row = vec![
        SpreadsheetCell::Int(3),
        SpreadsheetCell::Text(String::from("blue")),
        SpreadsheetCell::Float(10.12),
    ];
}
Listing 8-9: Mendefinisikan sebuah enum buat nyimpen nilai dari tipe yang beda di dalem satu vector

Rust perlu tau tipe apa aja yang bakal ada di vector pas compile time biar dia tau persis berapa banyak memori di heap yang bakal dibutuhin buat nyimpen tiap elemen. Kita juga harus eksplisit soal tipe apa aja yang dibolehin di vector ini. Kalau Rust ngebolehin sebuah vector buat nampung tipe apa aja, ada kemungkinan satu atau lebih dari tipe itu bakal nyebabin error sama operasi yang dijalanin pada elemen vector-nya. Pake enum ditambah ekspresi match artinya Rust bakal mastiin pas compile time kalau setiap kasus yang mungkin terjadi itu di-handle, kayak yang dibahas di Bab 6.

Kalau kita nggak tau daftar lengkap dari tipe-tipe yang bakal didapet program pas runtime buat disimpan di vector, teknik enum ini nggak bakal jalan. Sebagai gantinya, kita bisa pake trait object, yang bakal kita bahas di Bab 18.

Sekarang setelah kita bahas beberapa cara paling umum buat pake vectors, pastiin buat cek dokumentasi API-nya buat semua method berguna yang didefinisikan pada Vec<T> sama standard library. Misalnya, selain push, ada method pop yang ngehapus dan balikin elemen terakhir.

Nge-drop Vector Bakal Nge-drop Elemennya Juga

Kayak struct lainnya, sebuah vector bakal dibebasin (freed) pas dia keluar dari scope, kayak yang dianotasi di Listing 8-10.

fn main() {
    {
        let v = vec![1, 2, 3, 4];

        // do stuff with v
    } // <- v goes out of scope and is freed here
}
Listing 8-10: Nunjukin di mana vector dan elemennya di-drop

Pas vector di-drop, semua isinya juga ikut di-drop, artinya integer-integer yang ada di dalemnya bakal dibersihin. Borrow checker mastiin kalau referensi apa pun ke isi dari vector cuma dipake selama vector itu sendiri masih valid.

Yuk kita lanjut ke tipe koleksi berikutnya: String!

Menyimpan Teks Terenkod UTF-8 Memakai Strings

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!

Menyimpan Keys dengan Nilai Terkait di Hash Maps

Nyimpen Keys (Kunci) dengan Nilai Terkait di Hash Maps

Koleksi umum kita yang terakhir adalah hash map. Tipe HashMap<K, V> nyimpen pemetaan dari keys (kunci) bertipe K ke nilai bertipe V pake sebuah fungsi hashing, yang nentuin gimana cara naruh keys sama nilai-nilai ini ke dalem memori. Banyak bahasa pemrograman support struktur data jenis ini, tapi mereka sering pake nama yang beda-beda, kayak hash, map, object, hash table, dictionary, atau associative array, buat nyebut beberapa di antaranya.

Hash maps sangat berguna pas kita mau nyari data bukan pake indeks, kayak yang kita lakuin pake vectors, tapi pake key yang tipenya bisa apa aja. Misalnya, di dalem game, kita bisa nyatet skor tiap tim di dalem hash map di mana tiap key-nya adalah nama timnya dan nilainya adalah skor tiap tim. Kalo kita punya nama timnya, kita bisa ngambil skornya.

Kita bakal ngebahas API dasar dari hash maps di bagian ini, tapi masih banyak lagi fungsionalitas keren yang ngumpet di fungsi-fungsi yang didefinisikan pada HashMap<K, V> sama standard library. Kayak biasa, cek dokumentasi standard library buat info lebih lanjut.

Bikin Hash Map Baru

Salah satu cara buat bikin hash map kosong adalah pake new terus nambahin elemennya pake insert. Di Listing 8-20, kita lagi nyatet skor dari dua tim yang namanya Blue sama Yellow. Tim Blue mulai dengan 10 poin, dan tim Yellow mulai dengan 50 poin.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);
}
Listing 8-20: Bikin hash map baru dan nge-insert beberapa key sama nilai

Perhatiin ya kalau kita pertama-tama harus bawa HashMap dari porsi collections di standard library ke dalem scope pake use. Dari ketiga koleksi umum kita, yang satu ini paling jarang dipake, jadi dia nggak dimasukin ke fitur-fitur yang otomatis dibawa ke dalem scope lewat prelude. Hash maps juga dapet lebih dikit support dari standard library; nggak ada macro bawaan buat ngonstruksi mereka, misalnya.

Sama kayak vectors, hash maps nyimpen datanya di heap. HashMap ini punya keys tipe String sama nilai tipe i32. Sama juga kayak vectors, hash maps itu homogen: semua keys harus punya tipe yang sama, dan semua nilai harus punya tipe yang sama.

Akses Nilai di dalem Hash Map

Kita bisa ngambil nilai dari hash map dengan masukin key-nya ke method get, kayak yang ditunjukin di Listing 8-21.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    let team_name = String::from("Blue");
    let score = scores.get(&team_name).copied().unwrap_or(0);
}
Listing 8-21: Akses skor buat tim Blue yang disimpan di hash map

Di sini, score bakal punya nilai yang terkait sama tim Blue, dan hasilnya bakal 10. Method get balikin sebuah Option<&V>; kalau nggak ada nilai buat key itu di hash map-nya, get bakal balikin None. Program ini nanganin Option-nya pake manggil copied buat dapet Option<i32> bukannya Option<&i32>, terus pake unwrap_or buat nge-set score jadi nol kalau scores nggak punya entri buat key tersebut.

Kita bisa iterasi lewat tiap pasangan key-value (kunci-nilai) di hash map pake cara yang mirip kayak di vectors, pake for loop:

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    for (key, value) in &scores {
        println!("{key}: {value}");
    }
}

Kode ini bakal nyetak tiap pasangan dengan urutan yang sembarangan (arbitrary):

Yellow: 50
Blue: 10

Hash Maps dan Ownership

Buat tipe-tipe yang mengimplementasikan trait Copy, kayak i32, nilainya di-copy ke dalem hash map. Buat nilai yang dimiliki (owned values) kayak String, nilainya bakal di-move dan hash map bakal jadi pemilik (owner) dari nilai-nilai itu, kayak yang didemonstrasiin di Listing 8-22.

fn main() {
    use std::collections::HashMap;

    let field_name = String::from("Favorite color");
    let field_value = String::from("Blue");

    let mut map = HashMap::new();
    map.insert(field_name, field_value);
    // field_name and field_value are invalid at this point, try using them and
    // see what compiler error you get!
}
Listing 8-22: Nunjukin kalau keys sama nilai itu jadi milik hash map begitu mereka di-insert

Kita nggak bisa pake variabel field_name sama field_value setelah mereka di- move ke dalem hash map pake pemanggilan insert.

Kalau kita masukin referensi ke nilai ke dalem hash map, nilainya nggak bakal di-move ke dalem hash map-nya. Nilai yang ditunjuk sama referensi itu harus tetep valid setidaknya selama hash map-nya valid. Kita bakal bahas masalah ini lebih banyak di “Mervalidasi Referensi pake Lifetimes” di Bab 10.

Ngubah Hash Map

Walaupun jumlah pasangan key sama value bisa nambah, tiap unique key cuma bisa punya satu nilai yang terkait dengannya dalam satu waktu (tapi nggak berlaku sebaliknya: misalnya, baik tim Blue maupun tim Yellow bisa punya nilai 10 yang disimpan di hash map scores).

Pas kita mau ngubah data di dalem hash map, kita harus mutusin gimana cara nanganin kasus pas sebuah key udah punya nilai yang di-assign ke dia. Kita bisa nggantiin nilai lama pake nilai baru, sama sekali nyuekin nilai yang lama. Kita bisa pertahanin nilai lama dan nyuekin nilai baru, dan cuma nambahin nilai baru kalau key itu belum punya nilai. Atau kita bisa ngegabungin nilai lama sama nilai baru. Yuk kita liat gimana cara ngelakuin semua hal ini!

Nindih Nilai (Overwriting a Value)

Kalau kita nge-insert sebuah key sama nilai ke hash map terus nge-insert key yang sama pake nilai yang beda, nilai yang terkait sama key itu bakal diganti. Walaupun kode di Listing 8-23 manggil insert dua kali, hash map-nya cuma bakal punya satu pasangan key-value karena kita nge-insert nilai buat key tim Blue dua kali.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Blue"), 25);

    println!("{scores:?}");
}
Listing 8-23: Nggantiin nilai yang disimpan pake key tertentu

Kode ini bakal nyetak {"Blue": 25}. Nilai asli 10 udah ditindih.

Nambahin Key dan Nilai Cuma Kalau Key-nya Belum Ada

Sering sekali kita mau nge-cek apakah sebuah key tertentu udah ada di hash map dengan suatu nilai terus ngambil tindakan berikut: kalau key itu emang udah ada di hash map, nilai yang udah ada biarin aja kayak gitu; kalau key-nya belum ada, insert key itu bareng nilainya.

Hash maps punya API khusus buat ini namanya entry yang nerima key yang mau kita cek sebagai parameternya. Nilai return dari method entry ini adalah enum namanya Entry yang ngewakilin nilai yang mungkin udah ada atau mungkin belum. Katakanlah kita mau nge-cek apakah key buat tim Yellow punya nilai yang terkait dengannya. Kalau belum ada, kita mau nge-insert nilai 50, dan lakuin hal yang sama buat tim Blue. Pake API entry, kodenya keliatan kayak Listing 8-24.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();
    scores.insert(String::from("Blue"), 10);

    scores.entry(String::from("Yellow")).or_insert(50);
    scores.entry(String::from("Blue")).or_insert(50);

    println!("{scores:?}");
}
Listing 8-24: Pake method entry buat nge-insert cuma kalau key-nya belum punya nilai

Method or_insert pada Entry didefinisikan buat balikin sebuah mutable reference ke nilai buat key Entry yang terkait kalau key itu ada, dan kalau nggak ada, dia nge-insert parameternya sebagai nilai baru buat key ini terus balikin mutable reference ke nilai yang baru. Teknik ini jauh lebih bersih daripada nulis logikanya sendiri dan, selain itu, main lebih akur sama borrow checker.

Jalanin kode di Listing 8-24 bakal nyetak {"Yellow": 50, "Blue": 10}. Pemanggilan entry yang pertama bakal nge-insert key buat tim Yellow dengan nilai 50 karena tim Yellow belum punya nilai. Pemanggilan entry yang kedua nggak bakal ngubah hash map-nya karena tim Blue udah punya nilai 10.

Ngubah Nilai Berdasarkan Nilai Lamanya

Skenario umum lainnya buat hash maps adalah nyari nilai dari sebuah key terus ngubah nilainya berdasarkan nilai yang lama. Misalnya, Listing 8-25 nunjukin kode yang ngitung berapa kali tiap kata muncul di sebuah teks. Kita pake hash map dengan kata-katanya sebagai keys dan nambahin (increment) nilainya buat nyatet berapa kali kita ngeliat kata itu. Kalau ini pertama kalinya kita liat kata itu, kita bakal nge-insert nilai 0 dulu.

fn main() {
    use std::collections::HashMap;

    let text = "hello world wonderful world";

    let mut map = HashMap::new();

    for word in text.split_whitespace() {
        let count = map.entry(word).or_insert(0);
        *count += 1;
    }

    println!("{map:?}");
}
Listing 8-25: Ngitung kemunculan kata-kata pake hash map yang nyimpen kata dan jumlahnya

Kode ini bakal nyetak {"world": 2, "hello": 1, "wonderful": 1}. Kita mungkin bakal ngeliat pasangan key-value yang sama dicetak pake urutan yang beda: inget kan di “Akses Nilai di dalem Hash Map” kalau iterasi lewat hash map itu terjadi dengan urutan yang sembarangan (arbitrary).

Method split_whitespace balikin iterator yang ngelewatin subslices, dipisahin sama spasi (whitespace), dari nilai di text. Method or_insert balikin sebuah mutable reference (&mut V) ke nilai buat key yang spesifik itu. Di sini, kita nyimpen mutable reference itu di variabel count, jadi buat nge-assign (ngasih nilai) ke nilai itu, kita pertama-tama harus pake dereference pada count pake tanda bintang (*). Mutable reference-nya keluar dari scope di akhir dari for loop, jadi semua perubahan ini aman dan dibolehin sama aturan borrowing.

Fungsi Hashing

Secara default, HashMap pake fungsi hashing namanya SipHash yang bisa ngasih ketahanan dari serangan denial-of-service (DoS) yang ngelibatin hash tables1. Ini bukan algoritma hashing paling cepet yang ada, tapi trade-off buat keamanan yang lebih baik dengan sedikit penurunan performa itu sepadan sekali. Kalau kita nge-profile kode kita dan ngerasa kalau fungsi hash default-nya terlalu lambat buat tujuan kita, kita bisa ganti ke fungsi lain dengan nentuin hasher yang beda. Sebuah hasher adalah tipe yang mengimplementasikan trait BuildHasher. Kita bakal bahas traits dan cara mengimplementasikan mereka di Bab 10. Kita nggak perlu harus mengimplementasikan hasher kita sendiri dari nol kok; crates.io punya library-library yang di-share sama user Rust lainnya yang nyediain hashers yang mengimplementasikan banyak algoritma hashing umum.

Ringkasan

Vectors, strings, sama hash maps bakal nyediain banyak fungsionalitas yang dibutuhin di program kita pas kita perlu nyimpen, akses, dan ngubah data. Ini ada beberapa latihan yang sekarang harusnya udah bisa kita selesein:

  1. Dikasih sekumpulan integer, pake sebuah vector dan balikin median (pas diurutin, nilai di posisi tengah) sama modus (nilai yang paling sering muncul; hash map bakal ngebantu sekali di sini) dari sekumpulan nilai itu.
  2. Convert strings ke pig latin. Konsonan pertama dari tiap kata dipindah ke akhir kata terus ditambahin ay, jadi first jadi irst-fay. Kata-kata yang diawali sama huruf vokal ditambahin hay di akhirnya (apple jadi apple-hay). Inget detail soal encoding UTF-8 ya!
  3. Pake hash map sama vectors, bikin interface teks buat ngebolehin user nambahin nama pegawai ke sebuah departemen di sebuah perusahaan; misalnya, “Tambahin Sally ke Engineering” atau “Tambahin Amir ke Sales.” Terus bolehin user buat narik (retrieve) daftar semua orang di suatu departemen atau semua orang di perusahaan berdasarkan departemen, diurutin secara alfabet.

Dokumentasi API standard library ngejelasin method-method yang dipunyai sama vectors, strings, sama hash maps yang bakal ngebantu sekali buat latihan- latihan ini!

Kita lagi masuk ke program-program yang lebih kompleks di mana operasi bisa aja gagal, jadi ini waktu yang paling pas buat bahas penanganan error (error handling). Kita bakal ngelakuin itu berikutnya!


  1. https://en.wikipedia.org/wiki/SipHash

Error Handling (Penanganan Error)

Error itu adalah kenyataan hidup di software, jadi Rust punya sejumlah fitur buat nanganin situasi di mana ada sesuatu yang salah. Di banyak kasus, Rust mewajibkan kita buat ngakuin kemungkinan adanya error dan ngambil suatu aksi sebelum kode kita bisa di-compile. Persyaratan ini bikin program kita jadi lebih kuat (robust) dengan mastiin kalau kita bakal nemuin error dan nanganin mereka dengan bener sebelum kita nge-deploy kode kita ke production!

Rust ngelempokin error jadi dua kategori besar: error recoverable (yang bisa dipulihkan) dan unrecoverable (yang nggak bisa dipulihkan). Buat error yang recoverable, kayak error file not found (file nggak ditemuin), kemungkinan besar kita cuma mau ngelaporin masalahnya ke user terus nyoba operasinya lagi. Error yang unrecoverable selalu jadi gejala dari bugs, kayak nyoba akses lokasi yang ngelewatin akhir dari sebuah array, dan makanya kita mau langsung ngehentiin programnya.

Kebanyakan bahasa nggak ngebedain dua jenis error ini dan nanganin keduanya pake cara yang sama, pake mekanisme kayak exceptions (pengecualian). Rust nggak punya exceptions. Sebaliknya, dia punya tipe Result<T, E> buat error yang recoverable dan macro panic! yang ngehentiin eksekusi pas program nemu error yang unrecoverable. Bab ini bakal bahas soal manggil panic! dulu terus bahas soal balikin nilai Result<T, E>. Selain itu, kita bakal eksplor pertimbangan- pertimbangan pas milih buat nyoba pulih (recover) dari error atau ngehentiin eksekusi.

Error yang Tidak Bisa Dipulihkan Memakai panic!

Error Unrecoverable pake panic!

Kadang hal-hal buruk terjadi di kode kita, dan nggak ada yang bisa kita lakuin buat ngatasinnya. Di kasus kayak gini, Rust punya macro panic!. Ada dua cara buat micu sebuah panic di praktiknya: dengan ngambil aksi yang bikin kode kita panic (kayak akses array ngelewatin akhirnya) atau dengan secara eksplisit manggil macro panic!. Di dua kasus itu, kita nyebabin sebuah panic di program kita. Secara default, panics ini bakal nyetak pesan kegagalan, unwind (nggulung balik), ngebersihin stack, terus quit (keluar). Lewat environment variable (variabel lingkungan), kita juga bisa nyuruh Rust buat nampilin call stack pas sebuah panic terjadi biar lebih gampang buat ngelacak sumber dari panic itu.

Unwinding the Stack atau Aborting sebagai Respon ke Panic

Secara default, pas sebuah panic terjadi, program mulai proses unwinding, yang artinya Rust jalan mundur ke atas stack terus ngebersihin data dari tiap fungsi yang dia temuin. Tapi, jalan mundur terus ngebersihin data itu butuh banyak kerjaan. Makanya, Rust ngebolehin kita milih alternatif yaitu langsung aborting (ngebatalin), yang ngeakhirin program tanpa ngebersihin apa-apa.

Memori yang tadinya dipake program terus bakal perlu dibersihin sama sistem operasi (OS). Kalau di project kita kita butuh ngebikin file binary hasil akhirnya sekecil mungkin, kita bisa pindah dari unwinding jadi aborting pas terjadi panic dengan nambahin panic = 'abort' ke bagian [profile] yang sesuai di file Cargo.toml kita. Misalnya, kalau kita mau abort pas panic di release mode, tambahin ini:

[profile.release]
panic = 'abort'

Yuk kita coba manggil panic! di program yang simpel:

Filename: src/main.rs
fn main() {
    panic!("crash and burn");
}

Pas kita jalanin programnya, kita bakal liat yang kayak gini:

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

thread 'main' panicked at src/main.rs:2:5:
crash and burn
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Pemanggilan panic! nyebabin pesan error yang ada di dua baris terakhir. Baris pertama nunjukin pesan panic kita dan lokasi di source code kita di mana panic-nya terjadi: src/main.rs:2:5 nunjukin kalau itu ada di baris kedua, karakter kelima di file src/main.rs kita.

Di kasus ini, baris yang ditunjuk adalah bagian dari kode kita, dan kalau kita buka baris itu, kita bakal liat pemanggilan macro panic!. Di kasus lain, pemanggilan panic! mungkin ada di kode yang dipanggil sama kode kita, dan nama file serta nomor baris yang dilaporin sama pesan error-nya bakal nunjukin kode punya orang lain di mana macro panic! itu dipanggil, bukan baris kode kita yang akhirnya nyebabin pemanggilan panic! itu.

Kita bisa pake backtrace dari fungsi-fungsi tempat panic! dipanggil buat nyari tau bagian mana dari kode kita yang nyebabin masalahnya. Buat mahamin gimana cara pake backtrace panic!, yuk kita liat contoh lain dan liat gimana rasanya pas pemanggilan panic! dateng dari sebuah library gara-gara ada bug di kode kita, bukannya dari kode kita yang manggil macro-nya secara langsung. Listing 9-1 punya kode yang nyoba akses indeks di vector yang ngelewatin rentang (range) indeks yang valid.

Filename: src/main.rs
fn main() {
    let v = vec![1, 2, 3];

    v[99];
}
Listing 9-1: Nyoba akses elemen yang ngelewatin akhir sebuah vector, yang bakal nyebabin pemanggilan panic!

Di sini, kita lagi nyoba akses elemen ke-100 dari vector kita (yang ada di indeks 99 karena indexing mulai dari nol), tapi vector-nya cuma punya tiga elemen. Di situasi ini, Rust bakal panic. Pake [] seharusnya balikin sebuah elemen, tapi kalau kita ngasih indeks yang nggak valid, nggak ada elemen yang bisa dibalikin Rust di sini yang bener.

Di C, nyoba baca data ngelewatin akhir dari struktur data itu dianggap sebagai undefined behavior (perilaku yang nggak terdefinisi). Kita mungkin bakal dapet apa pun yang ada di lokasi memori yang harusnya sesuai sama elemen itu di struktur datanya, walaupun memori itu bukan milik struktur data tersebut. Ini disebut buffer overread dan bisa memicu celah keamanan (security vulnerabilities) kalau seorang attacker (penyerang) bisa memanipulasi indeks sedemikian rupa biar bisa baca data yang nggak boleh mereka baca yang disimpan setelah struktur data itu.

Buat ngelindungin program kita dari celah semacam ini, kalau kita nyoba baca elemen di indeks yang nggak ada, Rust bakal ngehentiin eksekusi dan nolak buat lanjut. Yuk kita coba dan liat hasilnya:

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

thread 'main' panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Error ini nunjuk ke baris 4 dari main.rs kita di mana kita nyoba akses indeks 99 dari vector di v.

Baris note: ngasih tau kita kalau kita bisa nge-set environment variable RUST_BACKTRACE buat dapetin backtrace dari apa persisnya yang terjadi yang nyebabin error itu. Sebuah backtrace adalah daftar dari semua fungsi yang udah dipanggil sampe bisa nyampe ke titik ini. Backtraces di Rust cara kerjanya sama kayak di bahasa lain: kunci buat baca backtrace adalah mulai dari atas terus baca sampe kita liat file yang kita tulis sendiri. Itu adalah titik di mana masalahnya bermula. Baris-baris di atas titik itu adalah kode yang dipanggil sama kode kita; baris-baris di bawahnya adalah kode yang manggil kode kita. Baris-baris sebelum dan sesudah ini mungkin nyakup kode inti Rust, kode standard library, atau crates yang lagi kita pake. Yuk kita coba dapetin backtrace dengan nge-set environment variable RUST_BACKTRACE ke nilai apa pun kecuali 0. Listing 9-2 nunjukin output yang mirip sama apa yang bakal kita liat.

$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
stack backtrace:
   0: rust_begin_unwind
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/panicking.rs:692:5
   1: core::panicking::panic_fmt
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/core/src/panicking.rs:75:14
   2: core::panicking::panic_bounds_check
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/core/src/panicking.rs:273:5
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/slice/index.rs:274:10
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/slice/index.rs:16:9
   5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/alloc/src/vec/mod.rs:3361:9
   6: panic::main
             at ./src/main.rs:4:6
   7: core::ops::function::FnOnce::call_once
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
Listing 9-2: Backtrace yang dihasilin oleh pemanggilan panic! yang ditampilin pas environment variable RUST_BACKTRACE di-set

Outputnya lumayan banyak tuh! Output pasti yang bakal kita liat mungkin beda-beda tergantung dari sistem operasi sama versi Rust yang dipake. Biar dapet backtraces dengan informasi sebanyak ini, debug symbols harus dinyalain. Debug symbols dinyalain secara default pas kita pake cargo build atau cargo run tanpa flag --release, kayak yang kita lakuin di sini.

Di output di Listing 9-2, baris 6 dari backtrace-nya nunjuk ke baris di project kita yang nyebabin masalahnya: baris 4 dari src/main.rs. Kalau kita nggak mau program kita panic, kita harus mulai nyari masalahnya di lokasi yang ditunjuk sama baris pertama yang nyebutin file yang kita tulis sendiri. Di Listing 9-1, di mana kita sengaja nulis kode yang bakal panic, cara buat benerin panic-nya adalah dengan nggak minta elemen yang ada di luar rentang indeks vector-nya. Pas kode kita panic di masa depan nanti, kita harus nyari tau aksi apa yang lagi dilakuin sama kode itu pake nilai apa sampe bisa nyebabin panic dan apa yang seharusnya dilakuin sama kode itu sebagai gantinya.

Kita bakal balik lagi bahas panic! dan kapan kita harus dan nggak harus pake panic! buat nanganin kondisi error di bagian “To panic! or Not to panic! nanti di bab ini. Selanjutnya, kita bakal liat gimana caranya buat pulih dari sebuah error pake Result.

Error yang Bisa Dipulihkan Memakai Result

Error Recoverable pake Result

Sebagian besar error nggak terlalu serius sampe harus ngehentiin program sepenuhnya. Kadang pas sebuah fungsi gagal itu karena alasan yang bisa kita interpretasi dan respon dengan gampang. Misalnya, kalau kita nyoba buka file dan operasi itu gagal gara-gara filenya nggak ada, kita mungkin mau bikin file itu bukannya malah nge-terminate (menghentikan) prosesnya.

Inget dari “Menangani Potensi Kegagalan dengan Result di Bab 2 kalau enum Result didefinisikan punya dua varian, Ok sama Err, kayak gini:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

T sama E itu generic type parameters (parameter tipe generik): kita bakal bahas generik lebih detail di Bab 10. Yang perlu kita tau sekarang adalah T merepresentasikan tipe dari nilai yang bakal dibalikin di kasus sukses di dalem varian Ok, dan E merepresentasikan tipe dari error yang bakal dibalikin di kasus gagal di dalem varian Err. Karena Result punya generic type parameters ini, kita bisa pake tipe Result dan fungsi-fungsi yang didefinisikan padanya di banyak situasi yang beda di mana nilai sukses dan nilai error yang mau kita balikin mungkin beda-beda.

Yuk kita manggil fungsi yang balikin nilai Result karena fungsinya bisa aja gagal. Di Listing 9-3 kita nyoba ngebuka sebuah file.

Filename: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");
}
Listing 9-3: Membuka sebuah file

Tipe kembalian (return type) dari File::open adalah Result<T, E>. Parameter generik T udah diisi sama implementasi dari File::open dengan tipe dari nilai suksesnya, yaitu std::fs::File, yang merupakan sebuah file handle. Tipe dari E yang dipake di nilai error adalah std::io::Error. Tipe kembalian ini artinya pemanggilan ke File::open bisa aja sukses dan balikin file handle yang bisa kita baca atau tulis. Pemanggilan fungsi ini juga bisa aja gagal: misalnya, filenya mungkin nggak ada, atau kita mungkin nggak punya izin (permission) buat akses file itu. Fungsi File::open butuh cara buat ngasih tau kita apakah dia sukses atau gagal dan di saat yang sama ngasih kita antara file handle atau informasi error. Informasi ini persis apa yang disampein sama enum Result.

Di kasus di mana File::open sukses, nilai di variabel greeting_file_result bakal jadi instance dari Ok yang nampung file handle. Di kasus di mana dia gagal, nilai di greeting_file_result bakal jadi instance dari Err yang nampung lebih banyak info soal jenis error yang terjadi.

Kita perlu nambahin kode di Listing 9-3 buat ngambil tindakan yang beda tergantung dari nilai yang dibalikin sama File::open. Listing 9-4 nunjukin salah satu cara buat nanganin Result pake tool dasar, yaitu ekspresi match yang udah kita bahas di Bab 6.

Filename: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {error:?}"),
    };
}
Listing 9-4: Pake ekspresi match buat nanganin varian Result yang mungkin dibalikin

Perhatiin ya kalau sama kayak enum Option, enum Result sama varian-variannya udah dibawa ke dalem scope lewat prelude, jadi kita nggak perlu nentuin Result:: sebelum varian Ok sama Err di arms dari match-nya.

Pas hasilnya Ok, kode ini bakal balikin nilai file di dalem varian Ok-nya, dan terus kita nge-assign nilai file handle itu ke variabel greeting_file. Setelah match, kita bisa pake file handle-nya buat baca atau nulis.

Arm lain dari match-nya nanganin kasus di mana kita dapet nilai Err dari File::open. Di contoh ini, kita milih buat manggil macro panic!. Kalau nggak ada file namanya hello.txt di direktori kita saat ini dan kita jalanin kode ini, kita bakal liat output berikut dari macro panic!:

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

thread 'main' panicked at src/main.rs:8:23:
Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Kayak biasa, output ini ngasih tau kita persis apa yang salah.

Nge-match di Error yang Beda-beda

Kode di Listing 9-4 bakal panic! nggak peduli apa alasan File::open gagal. Padahal, kita mau ngambil tindakan yang beda buat alasan kegagalan yang beda juga. Kalau File::open gagal gara-gara filenya nggak ada, kita mau bikin file itu terus balikin handle ke file baru itu. Kalau File::open gagal buat alasan apa pun lainnya—misalnya, karena kita nggak punya izin buat buka filenya—kita tetep mau kodenya buat panic! dengan cara yang sama kayak di Listing 9-4. Buat ini, kita nambahin ekspresi match bersarang (inner match), yang ditunjukin di Listing 9-5.

Filename: src/main.rs
use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {e:?}"),
            },
            _ => {
                panic!("Problem opening the file: {error:?}");
            }
        },
    };
}
Listing 9-5: Nanganin jenis error yang beda dengan cara yang beda juga

Tipe dari nilai yang dibalikin sama File::open di dalem varian Err adalah io::Error, yang merupakan struct yang disediain sama standard library. Struct ini punya method kind yang bisa kita panggil buat dapet nilai io::ErrorKind. Enum io::ErrorKind disediain sama standard library dan punya varian-varian yang merepresentasikan berbagai jenis error yang mungkin terjadi dari sebuah operasi io. Varian yang mau kita pake adalah ErrorKind::NotFound, yang ngindikasikan kalau file yang lagi kita coba buka itu belum ada. Jadi kita nge-match greeting_file_result, tapi kita juga punya inner match di error.kind().

Kondisi yang mau kita cek di inner match adalah apakah nilai yang dibalikin sama error.kind() itu adalah varian NotFound dari enum ErrorKind. Kalau iya, kita nyoba bikin filenya pake File::create. Tapi, karena File::create juga bisa aja gagal, kita butuh arm kedua di ekspresi inner match kita. Pas filenya nggak bisa dibuat, pesan error yang beda bakal dicetak. Arm kedua dari match bagian luar (outer match) tetep sama, jadi programnya bakal panic buat error apa pun selain error file nggak ditemuin.

Alternatif Buat Penggunaan match dengan Result<T, E>

Banyak sekali ya match-nya! Ekspresi match itu sangat berguna tapi dia juga lumayan primitif. Di Bab 13, kita bakal belajar soal closures, yang dipake bareng banyak method yang didefinisikan pada Result<T, E>. Method- method ini bisa lebih ringkas daripada pake match pas nanganin nilai Result<T, E> di kode kita.

Misalnya, ini cara lain buat nulis logika yang sama kayak yang ditunjukin di Listing 9-5, kali ini pake closures dan method unwrap_or_else:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Masalah pas bikin file: {error:?}");
            })
        } else {
            panic!("Masalah pas buka file: {error:?}");
        }
    });
}

Walaupun kode ini punya perilaku yang sama kayak Listing 9-5, dia nggak punya ekspresi match apa pun dan lebih bersih buat dibaca. Balik lagi ke contoh ini setelah kita kelar baca Bab 13, terus cek method unwrap_or_else di dokumentasi standard library. Masih banyak lagi method kayak gini yang bisa ngerapihin ekspresi match bersarang yang sangat besar pas kita lagi berurusan sama error.

Jalan Pintas buat Panic Kalo Error: unwrap sama expect

Pake match emang jalan dengan baik sih, tapi bisa agak kepanjangan (verbose) dan nggak selalu ngomunikasikan maksud kita dengan baik. Tipe Result<T, E> punya banyak method pembantu (helper methods) yang didefinisikan padanya buat ngelakuin berbagai tugas yang lebih spesifik. Method unwrap itu adalah method shortcut (jalan pintas) yang diimplementasikan persis kayak ekspresi match yang kita tulis di Listing 9-4. Kalau nilai Result-nya adalah varian Ok, unwrap bakal balikin nilai di dalem Ok-nya. Kalau Result-nya adalah varian Err, unwrap bakal manggil macro panic! buat kita. Ini contoh penggunaan unwrap:

Filename: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap();
}

Kalau kita jalanin kode ini tanpa file hello.txt, kita bakal liat pesan error dari pemanggilan panic! yang dilakuin sama method unwrap:

thread 'main' panicked at src/main.rs:4:49:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }

Sama halnya, method expect juga ngebolehin kita buat milih pesan error panic!-nya sendiri. Pake expect bukannya unwrap dan ngasih pesan error yang bagus bisa nyampein maksud kita dan bikin ngelacak sumber panic jadi lebih gampang. Sintaks dari expect keliatan kayak gini:

Filename: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")
        .expect("hello.txt should be included in this project");
}

Kita pake expect dengan cara yang sama kayak unwrap: buat balikin file handle-nya atau manggil macro panic!. Pesan error yang dipake sama expect di pemanggilan panic!-nya bakal jadi parameter yang kita masukin ke expect, bukannya pesan panic! default yang dipake sama unwrap. Ini contoh outputnya:

thread 'main' panicked at src/main.rs:5:10:
hello.txt harusnya udah disertain di project ini: Os { code: 2, kind: NotFound, message: "No such file or directory" }

Di production-quality code (kode level produksi), kebanyakan Rustacean milih expect daripada unwrap dan ngasih konteks lebih banyak soal kenapa operasi itu diharapkan bakal selalu berhasil. Dengan gitu, kalau asumsi kita terbukti salah, kita punya lebih banyak informasi yang bisa dipake pas debugging.

Ngelepar Balik Error (Propagating Errors)

Pas implementasi sebuah fungsi manggil sesuatu yang mungkin gagal, bukannya nanganin error-nya di dalem fungsi itu sendiri, kita bisa milih buat balikin error-nya ke kode yang manggil fungsi itu biar mereka yang mutusin mau ngapain. Ini dikenal sebagai propagating the error (ngelepar balik error) dan ngasih kontrol lebih banyak ke kode pemanggil, di mana mungkin ada lebih banyak informasi atau logika yang nentuin gimana error itu harus di-handle daripada apa yang tersedia di konteks fungsi kita.

Misalnya, Listing 9-6 nunjukin fungsi yang ngebaca username dari sebuah file. Kalau filenya nggak ada atau nggak bisa dibaca, fungsi ini bakal balikin error- error itu ke kode yang manggil fungsi ini.

Filename: src/main.rs
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}
}
Listing 9-6: Fungsi yang balikin error ke kode pemanggil pake match

Fungsi ini sebenernya bisa ditulis pake cara yang jauh lebih pendek, tapi kita bakal mulai dengan ngelakuinnya secara manual biar bisa eksplor cara nanganin error; nanti di akhir, kita bakal tunjukin cara yang lebih singkat. Yuk kita liat tipe kembalian (return type) dari fungsinya dulu: Result<String, io::Error>. Ini artinya fungsinya balikin nilai dari tipe Result<T, E>, di mana parameter generik T udah diisi pake tipe konkret String dan tipe generik E udah diisi pake tipe konkret io::Error.

Kalau fungsinya berhasil tanpa masalah apa pun, kode yang manggil fungsi ini bakal nerima nilai Ok yang nampung sebuah String—yaitu username yang dibaca sama fungsi ini dari file. Kalau fungsi ini nemu masalah apa pun, kode pemanggil bakal nerima nilai Err yang nampung instance dari io::Error yang isinya informasi lebih lanjut soal masalahnya. Kita milih io::Error sebagai tipe kembalian dari fungsi ini karena kebetulan itu adalah tipe dari nilai error yang dibalikin dari kedua operasi yang lagi kita panggil di dalem body fungsi ini yang mungkin gagal: yaitu fungsi File::open sama method read_to_string.

Body dari fungsi ini dimulai dengan manggil fungsi File::open. Terus kita nanganin nilai Result-nya pake match yang mirip kayak match di Listing 9-4. Kalau File::open berhasil, file handle di variabel pattern file bakal jadi nilai di variabel mutable username_file dan fungsinya lanjut. Di kasus Err, bukannya manggil panic!, kita pake keyword return buat balik (return) lebih awal dari fungsi sepenuhnya dan ngelepar nilai error dari File::open, yang sekarang ada di variabel pattern e, balik ke kode pemanggil sebagai nilai error dari fungsi ini.

Jadi, kalau kita punya file handle di username_file, fungsinya terus bikin String baru di variabel username terus manggil method read_to_string pada file handle di username_file buat baca isi filenya ke dalem username. Method read_to_string juga balikin sebuah Result karena dia bisa aja gagal, walaupun File::open tadi udah berhasil. Jadi kita butuh match satu lagi buat nanganin Result itu: kalau read_to_string berhasil, berarti fungsi kita udah berhasil, dan kita balikin username dari filenya yang sekarang udah ada di variabel username yang dibungkus di dalem sebuah Ok. Kalau read_to_string gagal, kita balikin nilai error-nya pake cara yang sama kayak kita balikin nilai error di dalem match yang nanganin nilai kembalian dari File::open. Tapi, kita nggak perlu secara eksplisit bilang return, karena ini adalah ekspresi terakhir di fungsinya.

Kode yang manggil fungsi ini nanti bakal dapet antara nilai Ok yang isinya sebuah username atau nilai Err yang isinya sebuah io::Error. Terserah kode pemanggilnya mau ngapain sama nilai-nilai itu. Kalau kode pemanggilnya dapet nilai Err, dia bisa aja manggil panic! terus nge-crash-in programnya, pake username default, atau nyari username dari tempat lain selain dari file, misalnya. Kita nggak punya informasi yang cukup soal apa yang sebenernya lagi dicoba lakuin sama kode pemanggil, jadi kita nge-lempar balik semua informasi sukses atau error ke atas biar di-handle sama dia dengan bener.

Pola ngelepar balik error ini saking umumnya di Rust sampe Rust nyediain operator tanda tanya ? buat bikin proses ini lebih gampang.

Jalan Pintas Buat Ngelepar Error: Operator ?

Listing 9-7 nunjukin implementasi dari read_username_from_file yang punya fungsionalitas yang sama kayak di Listing 9-6, tapi implementasi ini pake operator ?.

Filename: src/main.rs
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username_file = File::open("hello.txt")?;
    let mut username = String::new();
    username_file.read_to_string(&mut username)?;
    Ok(username)
}
}
Listing 9-7: Sebuah fungsi yang balikin error ke kode pemanggil pake operator ?

Tanda ? yang ditaruh setelah sebuah nilai Result itu didefinisikan buat kerja dengan cara yang hampir persis sama kayak ekspresi match yang kita definisikan buat nanganin nilai Result di Listing 9-6. Kalau nilai Result-nya adalah Ok, nilai di dalem Ok-nya bakal dibalikin dari ekspresi ini, dan programnya bakal lanjut. Kalau nilainya adalah Err, Err itu bakal dibalikin dari keseluruhan fungsi seolah-olah kita udah pake keyword return, jadi nilai error-nya bakal dilempar balik ke kode pemanggil.

Ada sedikit perbedaan antara apa yang dilakuin ekspresi match di Listing 9-6 sama apa yang dilakuin operator ?: nilai error yang dikenain operator ? bakal ngelewatin fungsi from, yang didefinisikan di trait From di standard library, yang dipake buat nge-convert nilai dari satu tipe ke tipe lainnya. Pas operator ? manggil fungsi from, tipe error yang diterima bakal di-convert jadi tipe error yang didefinisikan di tipe kembalian (return type) dari fungsi saat ini. Ini sangat berguna pas sebuah fungsi balikin satu tipe error kustom buat merepresentasikan semua kemungkinan cara fungsi itu bisa gagal, walaupun bagian-bagian di dalemnya mungkin gagal karena banyak alasan yang beda.

Misalnya, kita bisa ngubah fungsi read_username_from_file di Listing 9-7 buat balikin tipe error kustom namanya OurError yang kita definisikan sendiri. Kalau kita juga mendefinisikan impl From<io::Error> for OurError buat ngonstruksi sebuah instance dari OurError dari sebuah io::Error, maka pemanggilan operator ? di dalem body read_username_from_file bakal manggil from dan nge-convert tipe error-nya tanpa perlu nambahin kode lain lagi ke fungsinya.

Di konteks Listing 9-7, tanda ? di akhir pemanggilan File::open bakal balikin nilai di dalem Ok ke variabel username_file. Kalau ada error yang terjadi, operator ? bakal langsung keluar (return early) dari keseluruhan fungsi dan ngasih nilai Err apa pun ke kode pemanggil. Hal yang sama juga berlaku buat tanda ? di akhir pemanggilan read_to_string.

Operator ? ngilangin sangat banyak boilerplate code dan bikin implementasi fungsi ini jadi lebih simpel. Kita bahkan bisa nyederhanain kode ini lebih lanjut dengan nge-chaining (nyambungin) pemanggilan method langsung setelah ?, kayak yang ditunjukin di Listing 9-8.

Filename: src/main.rs
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();

    File::open("hello.txt")?.read_to_string(&mut username)?;

    Ok(username)
}
}
Listing 9-8: Nge-chaining pemanggilan method setelah operator ?

Kita udah mindahin pembuatan String baru di variabel username ke awal dari fungsinya; bagian itu nggak berubah. Bukannya bikin variabel username_file, kita nge-chaining pemanggilan read_to_string secara langsung ke hasil dari File::open("hello.txt")?. Kita tetep punya tanda ? di akhir pemanggilan read_to_string, dan kita tetep balikin nilai Ok yang nampung username pas baik File::open maupun read_to_string berhasil, bukannya balikin error-nya. Fungsionalitasnya tetep sama persis kayak di Listing 9-6 sama Listing 9-7; ini cuma cara yang beda dan lebih ergonomis buat nulis kodenya.

Listing 9-9 nunjukin cara buat ngebikin ini jadi lebih singkat lagi pake fs::read_to_string.

Filename: src/main.rs
#![allow(unused)]
fn main() {
use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}
}
Listing 9-9: Pake fs::read_to_string bukannya buka terus baca filenya secara manual

Baca sebuah file jadi string itu operasi yang lumayan umum, jadi standard library nyediain fungsi praktis fs::read_to_string yang buka filenya, bikin String baru, baca isinya dari file, masukin isinya ke dalem String itu, terus balikin nilai String-nya. Tentunya, kalau langsung pake fs::read_to_string dari awal kita jadi nggak dapet kesempatan buat ngejelasin semua penanganan error tadi, makanya kita bahas cara panjangnya dulu.

Di Mana Aja Operator ? Bisa Dipake

Operator ? cuma bisa dipake di fungsi-fungsi yang tipe kembaliannya (return type) kompatibel (cocok) sama nilai di mana tanda ? itu dipake. Ini karena operator ? didefinisikan buat ngelakuin early return (kembali lebih awal) sebuah nilai keluar dari fungsi, sama kayak ekspresi match yang kita definisikan di Listing 9-6. Di Listing 9-6, match-nya pake nilai Result, dan arm early return-nya balikin nilai Err(e). Tipe kembalian dari fungsinya juga harus berupa sebuah Result biar cocok sama return ini.

Di Listing 9-10, yuk kita liat error apa yang bakal kita dapet kalau kita nyoba pake operator ? di dalem fungsi main yang punya tipe kembalian yang nggak kompatibel sama tipe dari nilai di mana kita pake tanda ?.

Filename: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")?;
}
Listing 9-10: Nyoba pake tanda ? di fungsi main yang balikin () nggak bakal bisa di-compile.

Kode ini buka sebuah file, yang bisa aja gagal. Operator ? ngikutin nilai Result yang dibalikin sama File::open, tapi fungsi main ini punya tipe kembalian (), bukan Result. Pas kita nge-compile kode ini, kita bakal dapet pesan error berikut:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:4:48
  |
3 | fn main() {
  | --------- this function should return `Result` or `Option` to accept `?`
4 |     let greeting_file = File::open("hello.txt")?;
  |                                                ^ cannot use the `?` operator in a function that returns `()`
  |
help: consider adding return type
  |
3 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
4 |     let greeting_file = File::open("hello.txt")?;
5 +     Ok(())
  |

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

Error ini nunjukin kalau kita cuma dibolehin pake operator ? di fungsi yang balikin Result, Option, atau tipe lain yang mengimplementasikan FromResidual.

Buat benerin error-nya, kita punya dua pilihan. Pilihan pertama adalah ngubah tipe kembalian dari fungsinya biar kompatibel sama nilai yang lagi kita kenain operator ? selama kita nggak punya batasan yang ngelarang hal itu. Pilihan lainnya adalah pake sebuah match atau salah satu dari method di Result<T, E> buat nanganin nilai Result<T, E>-nya dengan cara apa pun yang paling pas.

Pesan error-nya juga nyebutin kalau ? bisa dipake bareng nilai Option<T> juga. Sama kayak pas pake ? di dalem Result, kita cuma bisa pake ? di dalem Option di sebuah fungsi yang juga balikin sebuah Option. Perilaku dari operator ? pas dipanggil di dalem sebuah Option<T> itu mirip sama perilakunya pas dipanggil di dalem sebuah Result<T, E>: kalau nilainya adalah None, nilai None bakal dibalikin lebih awal dari fungsi di titik itu. Kalau nilainya adalah Some, nilai di dalem Some-nya bakal jadi nilai hasil dari ekspresi tersebut, dan fungsinya lanjut jalan. Listing 9-11 punya contoh sebuah fungsi yang nyari karakter terakhir dari baris pertama di dalem sebuah teks.

fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}

fn main() {
    assert_eq!(
        last_char_of_first_line("Hello, world\nHow are you today?"),
        Some('d')
    );

    assert_eq!(last_char_of_first_line(""), None);
    assert_eq!(last_char_of_first_line("\nhi"), None);
}
Listing 9-11: Pake operator ? di nilai Option<T>

Fungsi ini balikin Option<char> karena mungkin aja ada karakter di sana, tapi mungkin juga nggak ada. Kode ini ngambil argumen string slice text terus manggil method lines pada string tersebut, yang bakal balikin sebuah iterator yang ngelewatin baris-baris di string-nya. Karena fungsi ini pengen nge-cek baris pertamanya, dia manggil next pada iterator-nya buat dapet nilai pertama dari iterator tersebut. Kalau text isinya string kosong, pemanggilan next ini bakal balikin None, dan di kasus itu kita pake ? buat berhenti terus balikin None dari last_char_of_first_line. Kalau text bukan string kosong, next bakal balikin nilai Some yang nampung string slice dari baris pertama di dalem text.

Tanda ? ngekstrak string slice itu, dan kita bisa manggil chars pada string slice tersebut buat dapet iterator dari karakter-karakternya. Kita tertarik sama karakter terakhir di baris pertama ini, jadi kita manggil last buat balikin item terakhir di iterator-nya. Ini adalah sebuah Option karena mungkin aja baris pertamanya itu string kosong; misalnya, kalau text dimulai pake baris kosong tapi punya karakter di baris lainnya, kayak di "\nhi". Tapi, kalau emang ada karakter terakhir di baris pertama, dia bakal dibalikin di dalem varian Some. Operator ? di tengah-tengah itu ngasih kita cara yang ringkas buat mengekspresikan logika ini, ngebolehin kita mengimplementasikan fungsi ini di dalem satu baris aja. Kalau kita nggak bisa pake operator ? di dalem Option, kita harus mengimplementasikan logika ini pake lebih banyak pemanggilan method atau pake ekspresi match.

Perhatiin ya kalau kita bisa pake operator ? di dalem Result di sebuah fungsi yang balikin Result, dan kita bisa pake operator ? di dalem Option di sebuah fungsi yang balikin Option, tapi kita nggak bisa nyampur aduk. Operator ? nggak bakal otomatis nge-convert sebuah Result jadi sebuah Option atau sebaliknya; di kasus kayak gitu, kita bisa pake method kayak ok pada Result atau method ok_or pada Option buat ngelakuin proses konversi-nya secara eksplisit.

Sejauh ini, semua fungsi main yang udah kita pake itu balikin (). Fungsi main itu spesial karena dia adalah entry point dan exit point dari program executable, dan ada batasan soal tipe kembaliannya apa aja yang dibolehin biar programnya bisa jalan sesuai ekspektasi.

Untungnya, main juga bisa balikin Result<(), E>. Listing 9-12 punya kode dari Listing 9-10, tapi kita udah ubah tipe kembalian dari main jadi Result<(), Box<dyn Error>> dan nambahin nilai kembalian Ok(()) di akhirnya. Kode ini sekarang bakal bisa di-compile.

Filename: src/main.rs
use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello.txt")?;

    Ok(())
}
Listing 9-12: Ngubah main buat balikin Result<(), E> ngebolehin penggunaan operator ? pada nilai Result.

Tipe Box<dyn Error> adalah sebuah trait object, yang bakal kita bahas di “Menggunakan Trait Objects Yang Mengizinkan Nilai Dari Tipe Yang Beda-beda” di Bab 18. Buat sekarang, kita bisa anggep Box<dyn Error> artinya “jenis error apa pun.” Pake ? di dalem nilai Result di fungsi main yang punya tipe error Box<dyn Error> itu dibolehin karena ini ngizinin nilai Err apa pun buat dibalikin lebih awal. Walaupun body dari fungsi main ini cuma bakal pernah balikin error bertipe std::io::Error, dengan nentuin Box<dyn Error>, signature ini bakal tetep bener biarpun nanti ada lebih banyak kode yang balikin error tipe lain yang ditambahin ke dalem body main.

Pas fungsi main balikin Result<(), E>, executable-nya bakal exit (keluar) dengan nilai 0 kalau main balikin Ok(()) dan bakal exit dengan nilai selain nol kalau main balikin nilai Err. Program executable yang ditulis dalam C bakal balikin integer pas mereka exit: program yang berhasil bakal balikin integer 0, dan program yang error bakal balikin integer selain 0. Rust juga balikin integer dari program executable buat tetep kompatibel sama konvensi ini.

Fungsi main bisa balikin tipe apa pun yang mengimplementasikan trait std::process::Termination, yang punya fungsi report yang balikin sebuah ExitCode. Cek dokumentasi standard library buat info lebih lanjut soal gimana cara mengimplementasikan trait Termination buat tipe kustom kita sendiri.

Sekarang setelah kita bahas detail soal manggil panic! atau balikin Result, yuk kita balik ke topik soal gimana cara mutusin mana yang pas buat dipake di kasus yang mana.

Mending Pakai panic! atau Tidak?

Kapan Harus panic! dan Kapan Nggak

Terus gimana caranya kita mutusin kapan harus manggil panic! dan kapan harus balikin Result? Pas kode panic, nggak ada cara buat pulih (recover). Kita bisa aja manggil panic! buat situasi error apa pun, mau ada cara buat pulih atau nggak, tapi kalau gitu kita jadi ngambil keputusan atas nama kode pemanggil kalau situasinya emang nggak bisa dipulihin. Pas kita milih buat balikin nilai Result, kita ngasih opsi ke kode pemanggil. Kode pemanggil bisa milih buat nyoba pulih dengan cara yang pas buat situasinya, atau dia bisa mutusin kalau nilai Err di kasus ini emang nggak bisa dipulihin, jadi dia bisa manggil panic! dan ngerubah error recoverable kita jadi error unrecoverable. Makanya, balikin Result itu pilihan default yang bagus pas kita lagi mendefinisikan fungsi yang mungkin aja gagal.

Di situasi-situasi kayak ngasih contoh, kode prototipe, sama nulis test, lebih pantes buat nulis kode yang bakal panic daripada balikin Result. Yuk kita eksplor alasannya, terus bahas situasi-situasi di mana compiler nggak bisa tau kalau kegagalan itu mustahil terjadi, tapi kita sebagai manusia tau. Bab ini bakal ditutup pake beberapa panduan umum (guidelines) soal gimana cara mutusin apakah harus panic di kode library atau nggak.

Contoh-contoh, Kode Prototipe, sama Tests

Pas kita lagi nulis contoh buat ngejelasin suatu konsep, masukin kode penanganan error yang kuat (robust) malah bisa bikin contohnya jadi kurang jelas. Di dalam contoh-contoh, udah dimaklumin kalau pemanggilan ke method kayak unwrap yang bisa panic itu dimaksudkan sebagai placeholder (tempat pengganti) buat gimana kita maunya aplikasi kita nanganin error, yang mana bisa beda-beda tergantung dari apa yang lagi dilakuin sama sisa kode kita.

Sama halnya, method unwrap sama expect itu praktis sekali pas lagi prototyping (bikin prototipe), sebelum kita siap mutusin gimana cara nanganin error. Mereka ninggalin tanda yang jelas di kode kita buat pas kita udah siap bikin program kita jadi lebih kuat (robust).

Kalau pemanggilan method gagal di dalem sebuah test, kita pasti mau seluruh test-nya ikutan gagal, biarpun method itu bukan fungsionalitas yang lagi dites. Karena panic! adalah cara sebuah test ditandain gagal, manggil unwrap atau expect adalah hal yang bener-bener seharusnya dilakuin.

Kasus di mana Kita Punya Lebih Banyak Informasi daripada Compiler

Bakal pantes juga buat manggil expect pas kita punya logika lain yang mastiin kalau Result-nya bakal punya nilai Ok, tapi logikanya bukan sesuatu yang dipahamin sama compiler. Kita bakal tetep punya nilai Result yang harus ditanganin: operasi apa pun yang lagi kita panggil secara umum tetep punya kemungkinan buat gagal, biarpun itu mustahil terjadi secara logika di situasi spesifik kita. Kalau kita bisa mastiin dengan nge-cek kodenya secara manual kalau kita nggak bakal pernah dapet varian Err, itu sah-sah aja buat manggil expect dan dokumentasiin alesan kenapa kita yakin kita nggak bakal pernah dapet varian Err di dalem teks argumennya. Ini contohnya:

fn main() {
    use std::net::IpAddr;

    let home: IpAddr = "127.0.0.1"
        .parse()
        .expect("Hardcoded IP address should be valid");
}

Kita bikin instance IpAddr dengan nge-parse (mengurai) sebuah hardcoded string. Kita bisa liat kalau 127.0.0.1 itu alamat IP yang valid, jadi sah-sah aja buat pake expect di sini. Tapi, punya hardcoded string yang valid nggak ngubah tipe kembalian dari method parse: kita tetep dapet nilai Result, dan compiler tetep bakal nyuruh kita nanganin Result-nya seolah-olah varian Err itu mungkin terjadi karena compiler nggak cukup pinter buat ngeliat kalau string ini bakal selalu jadi alamat IP yang valid. Kalau string alamat IP-nya dateng dari user bukannya di-hardcode ke program dan makanya emang punya kemungkinan gagal, kita pastinya mau nanganin Result-nya dengan cara yang lebih kuat (robust) sebagai gantinya. Nyebutin asumsi kalau alamat IP ini di- hardcode bakal ngingetin kita buat ngubah expect jadi kode penanganan error yang lebih baik kalau, di masa depan, kita harus ngedapetin alamat IP dari sumber lain.

Panduan buat Error Handling

Sangat disaranin buat bikin kode kita panic pas ada kemungkinan kode kita bisa berakhir di keadaan yang buruk (bad state). Di konteks ini, bad state adalah pas ada asumsi, jaminan, kontrak, atau invarian (aturan yang harus selalu benar) yang dilanggar, misalnya pas nilai yang nggak valid, nilai yang saling bertentangan, atau nilai yang ilang dimasukin ke kode kita—ditambah satu atau lebih dari hal- hal berikut:

  • Bad state itu adalah sesuatu yang nggak diduga-duga, beda sama sesuatu yang kemungkinan bakal sesekali kejadian, kayak user masukin data pake format yang salah.
  • Kode kita setelah titik ini harus ngandelin kalau dia nggak lagi di bad state itu, bukannya nge-cek masalah itu di tiap langkahnya.
  • Nggak ada cara yang bagus buat nge-encode (nyimpen) informasi ini ke tipe- tipe yang kita pake. Kita bakal bahas contoh dari apa yang kita maksud di “Meng-encode Keadaan dan Perilaku sebagai Tipe” di Bab 18.

Kalau seseorang manggil kode kita terus ngasih nilai-nilai yang nggak masuk akal, paling bener sih balikin sebuah error kalau bisa biar user dari library-nya bisa mutusin apa yang mau mereka lakuin di kasus itu. Tapi, di kasus di mana lanjut jalan bisa ngebahayain keamanan atau ngerusak, pilihan terbaik mungkin adalah manggil panic! terus ngingetin orang yang pake library kita soal bug di kode mereka biar mereka bisa benerin pas masa development (pengembangan). Sama juga, panic! itu sering kali pas kalau kita lagi manggil kode eksternal yang ada di luar kendali kita terus dia balikin invalid state yang nggak bisa kita benerin.

Tapi, pas kegagalan emang udah di-ekspektasi, lebih pantes buat balikin sebuah Result daripada manggil panic!. Contohnya kayak sebuah parser yang dikasih data yang cacat formatnya (malformed) atau sebuah HTTP request yang balikin status yang ngindikasikan kalau kita udah kena rate limit (batas batas frekuensi permintaan). Di kasus-kasus ini, balikin Result nunjukin kalau kegagalan adalah kemungkinan yang di-ekspektasi yang harus diputusin sama kode pemanggil gimana cara nanganinnya.

Pas kode kita ngejalanin operasi yang bisa ngebahayain user kalau dipanggil pake nilai-nilai yang nggak valid, kode kita harusnya nge-verifikasi kalau nilai- nilainya valid dulu terus panic kalau ternyata nggak valid. Ini sebagian besar karena alasan keamanan (safety): nyoba beroperasi pada data yang nggak valid bisa nge-ekspos kode kita ke celah keamanan (vulnerabilities). Ini alasan utama kenapa standard library bakal manggil panic! kalau kita nyoba akses memori di luar batas (out-of-bounds): nyoba akses memori yang bukan milik struktur data saat ini adalah masalah keamanan yang umum sekali. Fungsi-fungsi sering kali punya contracts (kontrak): perilaku mereka cuma dijamin kalau inputnya menuhin syarat tertentu. Panic pas kontrak dilanggar itu masuk akal karena pelanggaran kontrak selalu ngindikasikan ada bug di pihak pemanggil (caller-side), dan ini bukan jenis error yang kita mau kode pemanggil harus tangani secara eksplisit. Malah, nggak ada cara yang masuk akal buat kode pemanggil buat bisa pulih; si programmer yang bikin kode pemanggil harus benerin kodenya. Kontrak buat sebuah fungsi, terutama pas ada pelanggaran yang bakal nyebabin panic, harusnya dijelasin di dokumentasi API buat fungsi itu.

Tapi, punya sangat banyak pengecekan error di semua fungsi kita bakal panjang sekali (verbose) dan nyebelin. Untungnya, kita bisa pake sistem tipe (type system) Rust (dan karena itu dapet pengecekan tipe yang dilakuin sama compiler) buat ngelakuin banyak pengecekan buat kita. Kalau fungsi kita nerima tipe tertentu sebagai parameternya, kita bisa lanjut sama logika kode kita dengan tenang karena tau compiler udah mastiin kalau kita punya nilai yang valid. Misalnya, kalau kita nerima sebuah tipe bukannya sebuah Option, program kita berharap dapet sesuatu bukannya nggak ada apa-apa. Kode kita terus nggak perlu nanganin dua kasus buat varian Some sama None: dia cuma bakal punya satu kasus di mana dia pasti dapet sebuah nilai. Kode yang nyoba masukin nggak ada apa-apa ke fungsi kita bahkan nggak bakal bisa di-compile, jadi fungsi kita nggak perlu nge-cek kasus itu pas runtime. Contoh lain adalah pake tipe integer unsigned (nggak ada tanda minus) kayak u32, yang mastiin kalau parameternya nggak bakal pernah negatif.

Bikin Tipe Kustom Buat Validasi

Yuk kita bawa ide pake sistem tipe Rust buat mastiin kita punya nilai yang valid satu langkah lebih jauh terus liat cara bikin tipe kustom buat validasi. Inget game tebak angka di Bab 2 di mana kode kita minta user buat nebak angka antara 1 sampe 100. Kita nggak pernah mevalidasi kalau tebakan user bener-bener ada di antara angka-angka itu sebelum kita bandingin sama angka rahasia kita; kita cuma mevalidasi kalau tebakannya itu positif. Di kasus ini, konsekuensinya nggak terlalu parah sih: output “Ketinggian” atau “Kerendahan” kita bakal tetep bener. Tapi ini bakal jadi peningkatan yang berguna buat mandu user ke arah tebakan yang valid dan punya perilaku yang beda pas user nebak angka di luar rentang versus pas user ngetik huruf, misalnya.

Salah satu cara buat ngelakuin ini adalah dengan nge-parse (mengurai) tebakannya sebagai sebuah i32 bukannya cuma u32 buat ngebolehin angka yang potensial negatif, terus nambahin pengecekan apakah angkanya ada di dalem rentang, kayak gini:

Filename: src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        // --snip--

        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: i32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        if guess < 1 || guess > 100 {
            println!("The secret number will be between 1 and 100.");
            continue;
        }

        match guess.cmp(&secret_number) {
            // --snip--
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

Ekspresi if nge-cek apakah nilai kita ada di luar rentang, ngasih tau user soal masalahnya, terus manggil continue buat mulai iterasi loop berikutnya dan minta tebakan lain. Setelah ekspresi if, kita bisa lanjut sama perbandingan antara guess sama angka rahasianya dengan tenang karena tau guess pasti di antara 1 sampe 100.

Tapi, ini bukan solusi yang ideal: kalau bener-bener kritis sekali (absolutely critical) kalau programnya cuma beroperasi pada nilai di antara 1 sampe 100, dan program itu punya banyak fungsi dengan persyaratan ini, punya pengecekan kayak gini di tiap fungsi bakal ngebosenin dan repetitif sekali (dan mungkin ngaruh ke performa juga).

Sebagai gantinya, kita bisa bikin tipe baru di dalem modul yang didedikasikan khusus dan naruh validasinya di dalem sebuah fungsi buat bikin instance dari tipe itu bukannya ngulangin validasinya di mana-mana. Dengan gitu, bakal aman buat fungsi-fungsi buat pake tipe baru ini di signature mereka dan pake nilai yang mereka terima dengan pede. Listing 9-13 nunjukin salah satu cara buat mendefinisikan tipe Guess yang cuma bakal bikin instance dari Guess kalau fungsi new nerima nilai di antara 1 sampe 100.

Filename: src/guessing_game.rs
#![allow(unused)]
fn main() {
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}
Listing 9-13: Sebuah tipe Guess yang cuma bakal lanjut kalau nilainya di antara 1 sampe 100

Perhatiin ya kalau kode ini di src/guessing_game.rs bergantung sama penambahan deklarasi modul mod guessing_game; di src/lib.rs yang belum kita tunjukin di sini. Di dalem file modul baru ini, kita mendefinisikan sebuah struct namanya Guess yang punya field namanya value yang nampung sebuah i32. Di sinilah angkanya bakal disimpan.

Terus kita mengimplementasikan sebuah fungsi associated namanya new pada Guess yang bertugas bikin instance-instance dari nilai Guess. Fungsi new didefinisikan buat punya satu parameter namanya value dari tipe i32 dan balikin sebuah Guess. Kode di body fungsi new ngetes value buat mastiin kalau dia ada di antara 1 sampe 100. Kalau value nggak lolos tes ini, kita manggil panic!, yang bakal ngingetin programmer yang nulis kode pemanggil kalau mereka punya bug yang harus dibenerin, karena bikin sebuah Guess dengan value di luar rentang ini bakal melanggar kontrak yang diandelin sama Guess::new. Kondisi-kondisi di mana Guess::new mungkin bakal panic harusnya didiskusikan di dokumentasi API yang ngadep public (public-facing API); kita bakal ngebahas konvensi dokumentasi buat nunjukin kemungkinan panic! di dokumentasi API yang kita bikin di Bab 14. Kalau value lolos tes, kita bikin Guess baru dengan field value-nya di-set ke parameter value terus balikin Guess-nya.

Selanjutnya, kita mengimplementasikan sebuah method namanya value yang minjem (borrows) self, nggak punya parameter lain apa pun, dan balikin sebuah i32. Tipe method kayak gini kadang disebut getter karena tujuannya adalah buat dapet beberapa data dari field-nya terus balikin datanya. Method public ini dibutuhin karena field value dari struct Guess itu private. Ini penting sekali biar field value tetep private biar kode yang pake struct Guess nggak dibolehin nge-set value secara langsung: kode di luar modul guessing_game harus pake fungsi Guess::new buat bikin instance dari Guess, dan dengan gitu ngejamin nggak ada cara buat sebuah Guess buat punya value yang belum dicek sama kondisi di fungsi Guess::new.

Fungsi yang punya parameter atau balikin cuma angka di antara 1 sampe 100 kemudian bisa mendeklarasikan di signature-nya kalau dia nerima atau balikin sebuah Guess bukannya sebuah i32 dan nggak perlu ngelakuin pengecekan tambahan apa pun di body-nya.

Ringkasan

Fitur error-handling di Rust didesain buat ngebantu kita nulis kode yang lebih kuat (robust). Macro panic! nandain kalau program kita ada di keadaan yang dia nggak bisa tanganin dan ngasih kita cara buat nyuruh prosesnya buat berhenti bukannya nyoba lanjut pake nilai yang nggak valid atau salah. Enum Result pake sistem tipe Rust buat ngindikasikan kalau operasi bisa aja gagal dengan cara yang kode kita bisa pulih (recover) darinya. Kita bisa pake Result buat ngasih tau kode yang manggil kode kita kalau dia harus nanganin potensi sukses atau gagal juga. Pake panic! sama Result di situasi yang pas bakal bikin kode kita lebih bisa diandelin pas ngadepin masalah yang nggak bisa dihindarin.

Sekarang setelah kita liat cara yang kepake sekali di mana standard library pake generik bareng enum Option sama Result, kita bakal bahas gimana cara kerja generik (generics) dan gimana kita bisa pakenya di kode kita.

Tipe Generik, Traits, dan Lifetimes

Setiap bahasa pemrograman punya tools buat menangani duplikasi konsep secara efektif. Di Rust, salah satu tool itu adalah generics (generik): pengganti abstrak buat tipe konkret atau properti lainnya. Kita bisa mengekspresikan perilaku dari generik atau gimana mereka berhubungan dengan generik lainnya tanpa perlu tau apa yang bakal menempati posisi mereka saat kode di-compile dan dijalankan.

Fungsi bisa menerima parameter dari suatu tipe generik, bukannya tipe konkret seperti i32 atau String, dengan cara yang sama seperti mereka menerima parameter dengan nilai yang belum diketahui untuk menjalankan kode yang sama di beberapa nilai konkret. Sebenarnya, kita sudah memakai generik di Bab 6 dengan Option<T>, di Bab 8 dengan Vec<T> dan HashMap<K, V>, serta di Bab 9 dengan Result<T, E>. Di bab ini, kita bakal eksplor gimana cara mendefinisikan tipe, fungsi, dan method kita sendiri pakai generik!

Pertama-tama kita bakal mengulang gimana cara mengekstrak sebuah fungsi buat mengurangi duplikasi kode. Kemudian kita bakal memakai teknik yang sama buat bikin fungsi generik dari dua fungsi yang hanya berbeda di tipe parameternya. Kita juga bakal menjelaskan gimana cara memakai tipe generik di dalam definisi struct dan enum.

Setelah itu, kita bakal belajar gimana cara memakai traits untuk mendefinisikan perilaku dengan cara yang generik. Kita bisa menggabungkan traits dengan tipe generik untuk membatasi tipe generik agar cuma menerima tipe yang punya perilaku tertentu, dan tidak asal menerima tipe apa saja.

Terakhir, kita bakal membahas lifetimes: berbagai jenis generik yang memberi compiler informasi soal gimana referensi saling berhubungan. Lifetimes memungkinkan kita ngasih informasi yang cukup ke compiler soal borrowed values (nilai yang dipinjam) agar compiler bisa memastikan referensinya bakal valid di lebih banyak situasi yang tidak akan dia sanggup lakukan tanpa bantuan kita.

Menghapus Duplikasi dengan Mengekstrak Fungsi

Generik memungkinkan kita mengganti tipe spesifik pakai placeholder (tempat pengganti) yang mewakili banyak tipe buat menghilangkan duplikasi kode. Sebelum masuk ke sintaks generik, mari kita lihat dulu gimana cara menghilangkan duplikasi pakai cara yang tidak melibatkan tipe generik, yaitu dengan mengekstrak sebuah fungsi yang menggantikan nilai spesifik pakai placeholder yang mewakili banyak nilai. Habis itu, kita bakal menerapkan teknik yang sama buat mengekstrak fungsi generik! Dengan melihat gimana cara mengenali kode duplikat yang bisa diekstrak jadi sebuah fungsi, kita bakal mulai terbiasa mengenali kode duplikat yang bisa memakai generik.

Kita bakal mulai dari program pendek di Listing 10-1 yang mencari angka paling besar di dalam sebuah daftar (list).

Filename: src/main.rs
fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");
    assert_eq!(*largest, 100);
}
Listing 10-1: Mencari angka paling besar di dalam daftar angka

Kita menyimpan daftar integer di variabel number_list dan menaruh referensi ke angka pertama di daftar itu di variabel bernama largest. Kemudian kita iterasi melewati semua angka di daftar itu, dan kalau angka saat ini lebih besar dari angka yang disimpan di largest, kita mengganti referensi di variabel itu. Tapi, kalau angka saat ini lebih kecil atau sama dengan angka paling besar yang dilihat sejauh ini, variabelnya tidak berubah, dan kodenya lanjut ke angka berikutnya di daftar itu. Setelah memeriksa semua angka di daftar, largest seharusnya merujuk ke angka paling besar, yang di kasus ini adalah 100.

Sekarang kita dapat tugas buat mencari angka paling besar di dua daftar angka yang berbeda. Untuk melakukan itu, kita bisa memilih buat menduplikasi kode di Listing 10-1 lalu memakai logika yang sama di dua tempat yang berbeda di program, seperti yang ditunjukkan di Listing 10-2.

Filename: src/main.rs
fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");
}
Listing 10-2: Kode buat mencari angka paling besar di dua daftar angka

Walaupun kode ini jalan, menduplikasi kode itu merepotkan dan rawan error. Kita juga harus ingat buat meng-update kodenya di banyak tempat kalau kita mau mengubahnya.

Buat menghilangkan duplikasi ini, kita bakal bikin abstraksi dengan mendefinisikan sebuah fungsi yang beroperasi pada daftar integer apa pun yang dimasukkan sebagai parameternya. Solusi ini bikin kode kita lebih jelas dan memungkinkan kita mengekspresikan konsep pencarian angka paling besar di sebuah daftar secara abstrak.

Di Listing 10-3, kita mengekstrak kode yang mencari angka paling besar ke dalam fungsi bernama largest. Terus kita panggil fungsi itu buat mencari angka paling besar di dua daftar dari Listing 10-2. Kita juga bisa memakai fungsi itu di daftar nilai i32 lain yang mungkin kita punya di masa depan.

Filename: src/main.rs
fn largest(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 100);

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let result = largest(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 6000);
}
Listing 10-3: Kode yang diabstraksi buat mencari angka paling besar di dua daftar

Fungsi largest punya parameter bernama list, yang mewakili slice konkret apa pun dari nilai i32 yang mungkin kita masukkan ke fungsi tersebut. Hasilnya, pas kita memanggil fungsinya, kodenya jalan di nilai-nilai spesifik yang kita masukkan.

Sebagai ringkasan, ini langkah-langkah yang kita ambil buat ngubah kode dari Listing 10-2 jadi Listing 10-3:

  1. Kenali kode yang terduplikasi.
  2. Ekstrak kode yang terduplikasi ke dalam body fungsi, lalu tentukan input dan nilai kembalian dari kode itu di signature fungsinya.
  3. Update dua instance kode yang terduplikasi buat memanggil fungsinya sebagai gantinya.

Berikutnya, kita bakal pakai langkah-langkah yang sama ini dengan generik buat mengurangi duplikasi kode. Sama seperti body fungsi yang bisa beroperasi di list abstrak bukannya nilai spesifik, generik memungkinkan kode buat beroperasi di tipe abstrak.

Misalnya, katakanlah kita punya dua fungsi: satu yang mencari item paling besar di slice nilai i32 dan satu lagi yang mencari item paling besar di slice nilai char. Gimana cara kita menghilangkan duplikasi itu? Mari kita cari tahu!

Tipe Data Generik

Tipe Data Generik

Kita memakai generik buat membuat definisi untuk item seperti signature fungsi atau struct, yang nantinya bisa kita pakai dengan berbagai macam tipe data konkret. Mari kita lihat dulu gimana cara mendefinisikan fungsi, struct, enum, dan method memakai generik. Setelah itu, kita bakal membahas gimana pengaruh generik terhadap performa kode.

Di Definisi Fungsi

Saat mendefinisikan fungsi yang memakai generik, kita menaruh generik itu di signature fungsi di tempat kita biasanya menentukan tipe data untuk parameter dan nilai kembalian. Melakukan hal ini bikin kode kita jadi lebih fleksibel dan memberikan lebih banyak fungsionalitas bagi pemanggil fungsi kita sekaligus mencegah duplikasi kode.

Melanjutkan fungsi largest kita, Listing 10-4 menunjukkan dua fungsi yang keduanya mencari nilai paling besar di dalam sebuah slice. Kita bakal menggabungkan kedua fungsi ini jadi satu fungsi tunggal yang memakai generik.

Filename: src/main.rs
fn largest_i32(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> &char {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 100);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&char_list);
    println!("The largest char is {result}");
    assert_eq!(*result, 'y');
}
Listing 10-4: Dua fungsi yang cuma beda di nama dan tipe di signature-nya

Fungsi largest_i32 adalah fungsi yang kita ekstrak di Listing 10-3 untuk mencari i32 paling besar di dalam slice. Fungsi largest_char mencari char paling besar di dalam slice. Body fungsinya punya kode yang persis sama, jadi mari kita hilangkan duplikasi ini dengan memperkenalkan parameter tipe generik di satu fungsi tunggal.

Buat memparameterisasi tipe di fungsi tunggal yang baru, kita harus menamai parameter tipenya, sama seperti kita menamai parameter nilai buat sebuah fungsi. Kita bisa memakai identifier (nama) apa saja sebagai nama parameter tipe. Tapi kita bakal memakai T karena, secara konvensi, nama parameter tipe di Rust itu pendek, sering kali cuma satu huruf, dan konvensi penamaan tipe di Rust adalah CamelCase. Singkatan dari type (tipe), T adalah pilihan default buat kebanyakan programmer Rust.

Pas kita memakai sebuah parameter di dalam body fungsi, kita harus mendeklarasikan nama parameter itu di signature agar compiler tau apa makna nama tersebut. Demikian juga, pas kita memakai nama parameter tipe di signature fungsi, kita harus mendeklarasikan nama parameter tipe itu sebelum memakainya. Untuk mendefinisikan fungsi largest yang generik, kita menaruh deklarasi nama tipe di dalam kurung sudut, <>, di antara nama fungsinya dan daftar parameternya, seperti ini:

fn largest<T>(list: &[T]) -> &T {

Kita ngebaca definisi ini sebagai: fungsi largest bersifat generik terhadap suatu tipe T. Fungsi ini punya satu parameter bernama list, yang merupakan sebuah slice berisi nilai bertipe T. Fungsi largest bakal mengembalikan referensi ke nilai dengan tipe T yang sama.

Listing 10-5 menunjukkan definisi fungsi largest gabungan yang memakai tipe data generik di signature-nya. Listing ini juga menunjukkan gimana kita bisa memanggil fungsi tersebut baik dengan slice nilai i32 maupun nilai char. Perhatikan bahwa kode ini belum bisa di-compile.

Filename: src/main.rs
fn largest<T>(list: &[T]) -> &T {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {result}");

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {result}");
}
Listing 10-5: Fungsi largest yang memakai parameter tipe generik; kode ini belum bisa di-compile

Kalau kita men-compile kode ini sekarang, kita bakal dapat error ini:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T` with trait `PartialOrd`
  |
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
  |             ++++++++++++++++++++++

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

Teks bantuannya menyebutkan std::cmp::PartialOrd, yang mana itu adalah sebuah trait, dan kita bakal membahas traits di bagian selanjutnya. Buat sekarang, ketahuilah bahwa error ini menyatakan kalau body dari largest tidak bakal jalan untuk semua tipe yang mungkin bakal mengisi T. Karena kita mau membandingkan nilai-nilai bertipe T di dalam body-nya, kita cuma bisa memakai tipe-tipe yang nilainya bisa diurutkan. Buat memungkinkan perbandingan, standard library punya trait std::cmp::PartialOrd yang bisa kita implementasikan di tipe-tipe tertentu (lihat Lampiran C buat info lebih lanjut soal trait ini). Buat memperbaiki Listing 10-5, kita bisa mengikuti saran di teks bantuannya dan membatasi tipe-tipe yang valid buat T hanya pada tipe-tipe yang mengimplementasikan PartialOrd. Listing ini kemudian bakal bisa di-compile, karena standard library mengimplementasikan PartialOrd buat i32 dan char.

Di Definisi Struct

Kita juga bisa mendefinisikan struct agar memakai parameter tipe generik di satu atau lebih field-nya menggunakan sintaks <>. Listing 10-6 mendefinisikan sebuah struct Point<T> buat menampung nilai koordinat x dan y dari tipe apa pun.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}
Listing 10-6: Sebuah struct Point<T> yang menampung nilai x dan y bertipe T

Sintaks buat memakai generik di definisi struct itu mirip kayak yang dipakai di definisi fungsi. Pertama kita deklarasikan nama parameter tipenya di dalam kurung sudut persis setelah nama struct-nya. Kemudian kita pakai tipe generik itu di definisi struct-nya di tempat kita biasanya memasukkan tipe data konkret.

Perhatikan bahwa karena kita cuma pakai satu tipe generik buat mendefinisikan Point<T>, definisi ini berarti struct Point<T> bersifat generik terhadap suatu tipe T, dan field x serta y itu keduanya memiliki tipe yang sama tersebut, tidak peduli apa tipe aslinya. Kalau kita bikin instance dari Point<T> yang punya nilai dengan tipe yang berbeda, seperti di Listing 10-7, kode kita tidak bakal bisa di-compile.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}
Listing 10-7: Field x dan y harus punya tipe yang sama karena keduanya punya tipe data generik yang sama yaitu T.

Di contoh ini, saat kita nge-assign nilai integer 5 ke x, kita memberitahu compiler kalau tipe generik T bakal jadi integer untuk instance Point<T> ini. Lalu saat kita memberikan 4.0 untuk y, yang mana sebelumnya kita definisikan punya tipe yang sama dengan x, kita bakal dapat error ketidakcocokan tipe (type mismatch) seperti ini:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
 --> src/main.rs:7:38
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integer, found floating-point number

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

Buat mendefinisikan struct Point di mana x dan y keduanya adalah generik tapi bisa punya tipe yang berbeda, kita bisa memakai banyak parameter tipe generik. Misalnya, di Listing 10-8, kita mengubah definisi Point agar bersifat generik terhadap tipe T dan U, di mana x bertipe T dan y bertipe U.

Filename: src/main.rs
struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}
Listing 10-8: Sebuah Point<T, U> yang generik terhadap dua tipe sehingga x dan y bisa menampung nilai dengan tipe yang berbeda

Sekarang semua instance dari Point yang ditunjukkan itu diperbolehkan! Kita bisa memakai sebanyak apa pun parameter tipe generik di sebuah definisi, tapi memakai lebih dari beberapa bakal bikin kode kita jadi susah dibaca. Kalau kita merasa butuh banyak tipe generik di kode kita, itu bisa jadi pertanda kalau kode kita butuh direstrukturisasi jadi bagian-bagian yang lebih kecil.

Di Definisi Enum

Sama seperti struct, kita bisa mendefinisikan enum buat menampung tipe data generik di dalam variannya. Mari kita lihat lagi enum Option<T> yang disediakan oleh standard library, yang kita pakai di Bab 6:

#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

Definisi ini seharusnya sekarang jadi lebih masuk akal. Seperti yang bisa kita lihat, enum Option<T> bersifat generik terhadap tipe T dan punya dua varian: Some, yang menampung satu nilai bertipe T, dan varian None yang tidak menampung nilai apa pun. Dengan memakai enum Option<T>, kita bisa mengekspresikan konsep abstrak dari nilai yang opsional (bisa ada isinya atau tidak), dan karena Option<T> itu generik, kita bisa memakai abstraksi ini apa pun tipe dari nilai opsional tersebut.

Enum juga bisa memakai banyak tipe generik. Definisi dari enum Result yang kita pakai di Bab 9 adalah salah satu contohnya:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

Enum Result bersifat generik terhadap dua tipe, T dan E, dan punya dua varian: Ok, yang menampung nilai bertipe T, dan Err, yang menampung nilai bertipe E. Definisi ini membuatnya sangat nyaman untuk memakai enum Result di mana pun kita punya operasi yang mungkin berhasil (mengembalikan nilai bertipe T) atau gagal (mengembalikan error bertipe E). Kenyataannya, inilah yang kita pakai buat membuka file di Listing 9-3, di mana T diisi dengan tipe std::fs::File saat filenya berhasil dibuka dan E diisi dengan tipe std::io::Error pas ada masalah saat membuka file tersebut.

Kalau kita mengenali situasi di kode kita di mana banyak definisi struct atau enum yang cuma berbeda di tipe nilai yang mereka tampung, kita bisa menghindari duplikasi dengan memakai tipe generik.

Di Definisi Method

Kita bisa mengimplementasikan method di struct dan enum (seperti yang kita lakukan di Bab 5) dan memakai tipe generik di definisinya juga. Listing 10-9 menunjukkan struct Point<T> yang kita definisikan di Listing 10-6 dilengkapi sebuah method bernama x yang diimplementasikan di atasnya.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}
Listing 10-9: Mengimplementasikan sebuah method bernama x di struct Point<T> yang bakal mengembalikan referensi ke field x bertipe T

Di sini, kita sudah mendefinisikan sebuah method bernama x pada Point<T> yang mengembalikan referensi ke data di field x.

Perhatikan bahwa kita harus mendeklarasikan T persis setelah impl supaya kita bisa memakai T buat menentukan kalau kita lagi mengimplementasikan method di tipe Point<T>. Dengan mendeklarasikan T sebagai tipe generik setelah impl, Rust bisa mengidentifikasi kalau tipe di dalam kurung sudut di Point itu adalah tipe generik, bukan tipe konkret. Kita bisa saja memilih nama yang berbeda buat parameter generik ini daripada parameter generik yang dideklarasikan di definisi struct, tapi memakai nama yang sama adalah konvensi yang umum. Kalau kita menulis method di dalam impl yang mendeklarasikan tipe generik, method itu bakal didefinisikan pada instance tipe apa pun, tidak peduli tipe konkret apa yang akhirnya menggantikan tipe generiknya.

Kita juga bisa ngasih batasan pada tipe generik saat mendefinisikan method pada suatu tipe. Misalnya, kita bisa mengimplementasikan method hanya pada instance Point<f32> saja bukannya pada instance Point<T> dengan tipe generik apa pun. Di Listing 10-10 kita memakai tipe konkret f32, yang artinya kita tidak mendeklarasikan tipe apa pun setelah impl.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}
Listing 10-10: Blok impl yang hanya berlaku buat struct dengan tipe konkret tertentu buat parameter tipe generik T

Kode ini berarti tipe Point<f32> bakal punya method distance_from_origin; instance Point<T> lain di mana T bukan tipe f32 tidak bakal punya method ini. Method ini mengukur seberapa jauh titik kita dari titik origin di koordinat (0.0, 0.0) dan memakai operasi matematika yang hanya tersedia buat tipe floating-point.

Parameter tipe generik di definisi struct tidak selalu sama dengan parameter yang kita pakai di signature method pada struct yang sama. Listing 10-11 memakai tipe generik X1 dan Y1 buat struct Point serta X2 Y2 buat signature method mixup buat bikin contohnya jadi lebih jelas. Method ini bikin instance Point baru dengan nilai x dari Point yang mewakili self (bertipe X1) dan nilai y dari Point yang di-pass masuk (bertipe Y2).

Filename: src/main.rs
struct Point<X1, Y1> {
    x: X1,
    y: Y1,
}

impl<X1, Y1> Point<X1, Y1> {
    fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };

    let p3 = p1.mixup(p2);

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
Listing 10-11: Sebuah method yang memakai tipe generik yang berbeda dari definisi struct-nya

Di main, kita sudah mendefinisikan sebuah Point yang punya i32 buat x (dengan nilai 5) dan f64 buat y (dengan nilai 10.4). Variabel p2 adalah struct Point yang punya string slice buat x (dengan nilai "Hello") dan char buat y (dengan nilai c). Memanggil mixup pada p1 dengan argumen p2 bakal menghasilkan p3, yang bakal punya i32 buat x karena x-nya datang dari p1. Variabel p3 bakal punya char buat y karena y-nya datang dari p2. Pemanggilan macro println! bakal mencetak p3.x = 5, p3.y = c.

Tujuan dari contoh ini adalah buat mendemonstrasikan situasi di mana beberapa parameter generik dideklarasikan bersama impl dan beberapa lainnya dideklarasikan bersama definisi method-nya. Di sini, parameter generik X1 dan Y1 dideklarasikan setelah impl karena mereka ditujukan buat definisi struct-nya. Parameter generik X2 dan Y2 dideklarasikan setelah fn mixup karena mereka cuma relevan buat method itu aja.

Performa Kode yang Memakai Generik

Kita mungkin bertanya-tanya apakah ada biaya performa saat runtime kalau kita pakai parameter tipe generik. Kabar baiknya adalah memakai tipe generik tidak bakal bikin program kita berjalan lebih lambat dibanding kalau kita memakai tipe konkret.

Rust mencapai ini dengan melakukan monomorphization pada kode yang memakai generik di saat compile time. Monomorphization adalah proses mengubah kode generik jadi kode spesifik dengan mengisi tipe-tipe konkret yang dipakai pas di-compile. Dalam proses ini, compiler melakukan hal kebalikan dari langkah- langkah yang kita ambil buat bikin fungsi generik di Listing 10-5: compiler melihat semua tempat di mana kode generik itu dipanggil dan menghasilkan kode buat tipe-tipe konkret tempat kode generik itu dipanggil.

Mari kita lihat gimana proses ini bekerja dengan menggunakan enum generik Option<T> dari standard library:

#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}

Pas Rust men-compile kode ini, dia melakukan monomorphization. Selama proses itu, compiler membaca nilai-nilai yang udah dipakai di instance Option<T> dan mengenali dua jenis Option<T>: satu buat i32 dan satu lagi buat f64. Oleh karena itu, dia memperluas definisi generik dari Option<T> jadi dua definisi yang dikhususkan buat i32 dan f64, sehingga mengganti definisi generik dengan yang spesifik.

Versi kode hasil monomorphization terlihat mirip seperti berikut (compiler sebenarnya memakai nama yang berbeda dari apa yang kita pakai di sini buat ilustrasi):

Filename: src/main.rs
enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

Generik Option<T> digantikan dengan definisi spesifik yang dibikin sama compiler. Karena Rust men-compile kode generik jadi kode yang menentukan tipe asli di masing-masing instance, kita tidak harus membayar biaya apa pun saat runtime akibat pemakaian generik. Pas kodenya jalan, performanya sama persis seperti kalau kita menduplikasi masing-masing definisi pakai tangan sendiri. Proses monomorphization ini bikin generik di Rust jadi sangat efisien pas runtime.

Mendefinisikan Perilaku Bersama Memakai Traits

Traits: Mendefinisikan Perilaku Bersama

Sebuah trait mendefinisikan fungsionalitas yang dimiliki suatu tipe tertentu dan bisa dibagikan (di-share) dengan tipe lainnya. Kita bisa memakai traits buat mendefinisikan perilaku bersama (shared behavior) secara abstrak. Kita bisa memakai trait bounds buat menentukan kalau sebuah tipe generik bisa berupa tipe apa pun asalkan punya perilaku tertentu.

Catatan: Traits itu mirip sama fitur yang sering disebut interfaces di bahasa pemrograman lain, walaupun ada beberapa perbedaan.

Mendefinisikan sebuah Trait

Perilaku dari sebuah tipe terdiri dari methods yang bisa kita panggil pada tipe tersebut. Berbagai tipe bisa berbagi perilaku yang sama kalau kita bisa memanggil methods yang sama pada semua tipe itu. Definisi trait adalah cara buat mengelompokkan method signatures (tanda tangan metode) bersama-sama untuk mendefinisikan sekumpulan perilaku yang dibutuhkan untuk mencapai suatu tujuan.

Misalnya, katakanlah kita punya beberapa struct yang menampung berbagai macam dan jumlah teks: sebuah struct NewsArticle yang menampung berita di lokasi tertentu dan sebuah SocialPost yang maksimal isinya 280 karakter beserta metadata yang menunjukkan apakah itu postingan baru, di-repost, atau balasan buat postingan lain.

Kita mau bikin library crate agregator media bernama aggregator yang bisa nampilin ringkasan data yang mungkin disimpan di dalam instance NewsArticle atau SocialPost. Untuk melakukan ini, kita butuh ringkasan dari tiap tipe, dan kita bakal minta ringkasan itu dengan memanggil method summarize di tiap instance-nya. Listing 10-12 menunjukkan definisi trait publik Summary yang mengekspresikan perilaku ini.

Filename: src/lib.rs
pub trait Summary {
    fn summarize(&self) -> String;
}
Listing 10-12: Sebuah trait Summary yang terdiri dari perilaku yang disediakan oleh method summarize

Di sini, kita mendeklarasikan sebuah trait memakai keyword trait lalu nama trait-nya, yang mana adalah Summary di kasus ini. Kita juga mendeklarasikan trait ini sebagai pub supaya crates yang bergantung pada crate ini bisa memanfaatkan trait ini juga, seperti yang bakal kita lihat di beberapa contoh nanti. Di dalam kurung kurawal, kita mendeklarasikan method signatures yang menggambarkan perilaku tipe-tipe yang mengimplementasikan trait ini, yang di kasus ini adalah fn summarize(&self) -> String.

Setelah method signature, bukannya ngasih implementasi di dalam kurung kurawal, kita memakai titik koma. Tiap tipe yang mengimplementasikan trait ini harus menyediakan perilaku khususnya sendiri buat body (isi) dari method ini. Compiler bakal memastikan kalau tipe apa pun yang punya trait Summary bakal punya method summarize yang didefinisikan dengan signature yang persis sama kayak gini.

Sebuah trait bisa punya banyak method di dalamnya: method signatures didaftarkan satu baris satu, dan tiap baris diakhiri dengan titik koma.

Mengimplementasikan sebuah Trait pada suatu Tipe

Sekarang setelah kita mendefinisikan signatures yang diinginkan dari method trait Summary, kita bisa mengimplementasikannya pada tipe-tipe di agregator media kita. Listing 10-13 menunjukkan implementasi trait Summary pada struct NewsArticle yang memakai judul (headline), penulis, dan lokasi buat bikin nilai kembalian dari summarize. Buat struct SocialPost, kita mendefinisikan summarize sebagai username diikuti sama seluruh teks postingannya, dengan asumsi kalau konten postingan sudah dibatasi sampai 280 karakter.

Filename: src/lib.rs
pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}
Listing 10-13: Mengimplementasikan trait Summary pada tipe NewsArticle dan SocialPost

Mengimplementasikan trait pada suatu tipe itu mirip dengan mengimplementasikan method biasa. Bedanya adalah setelah impl, kita menaruh nama trait yang mau kita implementasikan, lalu memakai keyword for, dan kemudian menentukan nama tipe di mana kita mau mengimplementasikan trait tersebut. Di dalam blok impl, kita menaruh method signatures yang sudah didefinisikan sama definisi trait-nya. Alih-alih menambahkan titik koma setelah setiap signature, kita memakai kurung kurawal dan mengisi isi method dengan perilaku spesifik yang kita mau dari method trait tersebut untuk tipe khususnya.

Sekarang setelah library ini mengimplementasikan trait Summary pada NewsArticle dan SocialPost, pengguna dari crate ini bisa memanggil method dari trait tersebut pada instance NewsArticle dan SocialPost dengan cara yang sama seperti memanggil method biasa. Bedanya cuma si pengguna harus membawa trait tersebut ke dalam scope sekaligus membawa tipe-tipenya. Ini contoh gimana sebuah binary crate bisa memakai library crate aggregator kita:

use aggregator::{SocialPost, Summary};

fn main() {
    let post = SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    };

    println!("1 new post: {}", post.summarize());
}

Kode ini bakal mencetak 1 new post: horse_ebooks: of course, as you probably already know, people.

Crates lain yang bergantung pada crate aggregator juga bisa membawa trait Summary ke dalam scope untuk mengimplementasikan Summary di tipe mereka sendiri. Satu batasan yang perlu dicatat adalah kita cuma bisa mengimplementasikan sebuah trait pada suatu tipe kalau setidaknya trait-nya atau tipenya, atau keduanya, berada di crate kita sendiri (local to our crate). Misalnya, kita bisa mengimplementasikan trait dari standard library seperti Display pada tipe kustom seperti SocialPost sebagai bagian dari fungsionalitas crate aggregator kita karena tipe SocialPost itu ada di crate aggregator kita. Kita juga bisa mengimplementasikan Summary pada Vec<T> di crate aggregator kita karena trait Summary itu ada di crate aggregator kita.

Tapi kita tidak bisa mengimplementasikan traits eksternal pada tipe eksternal. Misalnya, kita tidak bisa mengimplementasikan trait Display pada Vec<T> di dalam crate aggregator kita karena Display dan Vec<T> dua-duanya didefinisikan di standard library dan bukan bagian dari crate aggregator kita. Batasan ini adalah bagian dari properti yang disebut coherence (koherensi), dan lebih spesifik lagi disebut orphan rule (aturan yatim piatu), dinamai begitu karena tipe induknya tidak ada. Aturan ini memastikan kalau kode milik orang lain tidak bisa merusak kode kita dan sebaliknya. Tanpa aturan ini, dua crates bisa saja mengimplementasikan trait yang sama untuk tipe yang sama, dan Rust tidak bakal tau implementasi mana yang harus dipakai.

Implementasi Default

Kadang-kadang akan berguna kalau kita punya perilaku default untuk beberapa atau semua method di sebuah trait daripada mewajibkan implementasi untuk semua method di setiap tipe. Dengan begitu, saat kita mengimplementasikan trait pada tipe tertentu, kita bisa tetap menyimpan atau menimpa (override) perilaku default dari tiap method.

Di Listing 10-14, kita menentukan string default buat method summarize dari trait Summary alih-alih cuma mendefinisikan method signature-nya, seperti yang kita lakukan di Listing 10-12.

Filename: src/lib.rs
pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Read more...)")
    }
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}
Listing 10-14: Mendefinisikan trait Summary dengan implementasi default buat method summarize

Untuk memakai implementasi default buat meringkas instance dari NewsArticle, kita cukup menentukan blok impl yang kosong dengan impl Summary for NewsArticle {}.

Meskipun kita tidak lagi mendefinisikan method summarize di NewsArticle secara langsung, kita sudah menyediakan implementasi default dan menentukan kalau NewsArticle mengimplementasikan trait Summary. Hasilnya, kita tetap bisa memanggil method summarize pada instance NewsArticle, seperti ini:

use aggregator::{self, NewsArticle, Summary};

fn main() {
    let article = NewsArticle {
        headline: String::from("Penguins win the Stanley Cup Championship!"),
        location: String::from("Pittsburgh, PA, USA"),
        author: String::from("Iceburgh"),
        content: String::from(
            "The Pittsburgh Penguins once again are the best \
             hockey team in the NHL.",
        ),
    };

    println!("New article available! {}", article.summarize());
}

Kode ini mencetak New article available! (Read more...).

Membuat implementasi default tidak mengharuskan kita untuk mengubah apa pun dari implementasi Summary pada SocialPost di Listing 10-13. Alasannya adalah sintaks buat menimpa implementasi default itu persis sama kayak sintaks buat mengimplementasikan method trait yang tidak punya implementasi default.

Implementasi default bisa memanggil method lain di trait yang sama, bahkan kalau method lain itu tidak punya implementasi default. Dengan cara ini, sebuah trait bisa menyediakan banyak fungsionalitas berguna dan cuma mewajibkan si peng-implementasi buat menentukan sebagian kecil saja. Misalnya, kita bisa mendefinisikan trait Summary agar punya method summarize_author yang implementasinya wajib, lalu mendefinisikan method summarize yang punya implementasi default yang memanggil method summarize_author:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

Untuk memakai versi Summary ini, kita cuma perlu mendefinisikan summarize_author pas kita mengimplementasikan trait-nya pada sebuah tipe:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

Setelah kita mendefinisikan summarize_author, kita bisa memanggil summarize pada instance dari struct SocialPost, dan implementasi default dari summarize bakal memanggil definisi summarize_author yang sudah kita sediakan. Karena kita sudah mengimplementasikan summarize_author, trait Summary sudah ngasih kita perilaku dari method summarize tanpa mengharuskan kita menulis kode tambahan lagi. Berikut contoh pemakaiannya:

use aggregator::{self, SocialPost, Summary};

fn main() {
    let post = SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    };

    println!("1 new post: {}", post.summarize());
}

Kode ini mencetak 1 new post: (Read more from @horse_ebooks...).

Perhatikan kalau tidak mungkin untuk memanggil implementasi default dari dalam implementasi yang lagi menimpa (overriding) method yang sama.

Traits sebagai Parameter

Sekarang setelah kita tahu cara mendefinisikan dan mengimplementasikan traits, kita bisa eksplor gimana cara memakai traits buat mendefinisikan fungsi yang bisa menerima berbagai macam tipe. Kita bakal memakai trait Summary yang sudah kita implementasikan di tipe NewsArticle dan SocialPost di Listing 10-13 untuk mendefinisikan fungsi notify yang memanggil method summarize pada parameter item-nya, yang bertipe apa pun selama tipe itu mengimplementasikan trait Summary. Buat melakukannya, kita memakai sintaks impl Trait, seperti ini:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

Alih-alih tipe konkret buat parameter item, kita memakai keyword impl bersama dengan nama trait-nya. Parameter ini bakal menerima tipe apa pun yang mengimplementasikan trait yang ditentukan. Di dalam body dari notify, kita bisa memanggil method apa pun pada item yang asalnya dari trait Summary, contohnya summarize. Kita bisa memanggil notify dan memberikan instance apa pun dari NewsArticle atau SocialPost. Kode yang memanggil fungsi tersebut dengan tipe lain, misalnya String atau i32, tidak bakal bisa di-compile karena tipe-tipe tersebut tidak mengimplementasikan Summary.

Sintaks Trait Bound

Sintaks impl Trait memang praktis buat kasus-kasus sederhana tapi sebenarnya itu cuma syntax sugar (sintaks pemanis) dari bentuk yang lebih panjang yang dikenal sebagai trait bound; bentuknya kayak gini:

pub fn notify<T: Summary>(item: &T) {
    println!("Breaking news! {}", item.summarize());
}

Bentuk yang lebih panjang ini ekuivalen (sama) dengan contoh di bagian sebelumnya, tapi lebih panjang (verbose). Kita menaruh trait bounds bersamaan dengan deklarasi parameter tipe generik setelah tanda titik dua (:) dan di dalam kurung sudut.

Sintaks impl Trait itu nyaman dan bikin kode lebih ringkas buat kasus-kasus sederhana, sementara sintaks trait bound yang lebih lengkap bisa mengekspresikan lebih banyak kerumitan buat kasus lain. Misalnya, kita bisa punya dua parameter yang dua-duanya mengimplementasikan Summary. Kalau pakai sintaks impl Trait, bentuknya bakal seperti ini:

pub fn notify(item1: &impl Summary, item2: &impl Summary) {

Memakai impl Trait cocok kalau kita mau fungsi ini mengizinkan item1 dan item2 untuk punya tipe yang berbeda (asalkan dua-duanya mengimplementasikan Summary). Tapi, kalau kita mau memaksa kedua parameter tersebut buat punya tipe yang sama persis, kita harus memakai trait bound, seperti ini:

pub fn notify<T: Summary>(item1: &T, item2: &T) {

Tipe generik T yang ditentukan sebagai tipe dari parameter item1 dan item2 membatasi fungsi ini sehingga tipe konkret dari nilai yang diberikan buat argumen item1 dan item2 itu harus sama.

Menentukan Beberapa Trait Bounds dengan Sintaks +

Kita juga bisa menentukan lebih dari satu trait bound. Katakanlah kita mau notify bisa memakai display formatting di samping memanggil summarize pada item: kita tentukan di definisi notify kalau item harus mengimplementasikan Display sekaligus Summary. Kita bisa melakukannya menggunakan sintaks +:

pub fn notify(item: &(impl Summary + Display)) {

Sintaks + ini juga valid buat dipakai sama trait bounds pada tipe generik:

pub fn notify<T: Summary + Display>(item: &T) {

Dengan dua trait bounds yang ditentukan, body dari notify bisa memanggil summarize dan juga memakai {} buat memformat item.

Trait Bounds yang Lebih Rapi pake Klausa where

Memakai terlalu banyak trait bounds ada sisi negatifnya. Masing-masing generik punya trait bounds-nya sendiri, jadi fungsi dengan banyak parameter tipe generik bisa mengandung sangat banyak informasi trait bound di antara nama fungsi dan daftar parameternya, yang mana bisa bikin signature fungsinya jadi susah dibaca. Karena alasan ini, Rust punya sintaks alternatif buat menentukan trait bounds di dalam sebuah klausa where setelah signature fungsinya. Jadi, alih-alih nulis begini:

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {

kita bisa pakai klausa where, kayak gini:

fn some_function<T, U>(t: &T, u: &U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{
    unimplemented!()
}

Signature fungsinya jadi tidak terlalu penuh: nama fungsi, daftar parameter, dan tipe kembalian semuanya berdekatan, mirip seperti fungsi yang tidak punya banyak trait bounds.

Mengembalikan Tipe yang Mengimplementasikan Traits

Kita juga bisa memakai sintaks impl Trait di posisi kembalian (return position) buat mengembalikan nilai dari suatu tipe yang mengimplementasikan sebuah trait, kayak gini:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable() -> impl Summary {
    SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    }
}

Dengan memakai impl Summary buat tipe kembaliannya, kita menentukan kalau fungsi returns_summarizable bakal mengembalikan suatu tipe yang mengimplementasikan trait Summary tanpa harus menyebut nama tipe konkretnya. Di kasus ini, returns_summarizable mengembalikan sebuah SocialPost, tapi kode yang memanggil fungsi ini tidak perlu tau soal itu.

Kemampuan buat menentukan tipe kembalian hanya berdasarkan trait yang diimplementasikannya itu sangat berguna, apalagi di konteks closures dan iterators, yang bakal kita bahas di Bab 13. Closures dan iterators bikin tipe-tipe yang cuma compiler doang yang tau, atau tipe-tipe yang namanya kepanjangan buat ditulis. Sintaks impl Trait memudahkan kita menentukan secara ringkas kalau sebuah fungsi mengembalikan tipe tertentu yang mengimplementasikan trait Iterator tanpa perlu nulis tipe yang kepanjangan.

Tapi, kita cuma bisa memakai impl Trait kalau kita mengembalikan satu tipe tunggal. Misalnya, kode ini, yang mengembalikan entah NewsArticle atau SocialPost dengan tipe kembalian impl Summary, tidak bakal bisa jalan:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable(switch: bool) -> impl Summary {
    if switch {
        NewsArticle {
            headline: String::from(
                "Penguins win the Stanley Cup Championship!",
            ),
            location: String::from("Pittsburgh, PA, USA"),
            author: String::from("Iceburgh"),
            content: String::from(
                "The Pittsburgh Penguins once again are the best \
                 hockey team in the NHL.",
            ),
        }
    } else {
        SocialPost {
            username: String::from("horse_ebooks"),
            content: String::from(
                "of course, as you probably already know, people",
            ),
            reply: false,
            repost: false,
        }
    }
}

Mengembalikan entah NewsArticle atau SocialPost itu tidak diperbolehkan karena adanya batasan dari gimana sintaks impl Trait diimplementasikan di dalam compiler. Kita bakal bahas gimana cara nulis fungsi dengan perilaku kayak gini di bagian “Memakai Trait Objects yang Mengizinkan Nilai Dari Tipe yang Berbeda-beda” di Bab 18.

Memakai Trait Bounds Buat Mengimplementasikan Method secara Bersyarat

Dengan memakai trait bound bareng sebuah blok impl yang memakai parameter tipe generik, kita bisa mengimplementasikan methods secara bersyarat (conditionally) buat tipe-tipe yang mengimplementasikan traits yang ditentukan. Misalnya, tipe Pair<T> di Listing 10-15 selalu mengimplementasikan fungsi new buat mengembalikan instance baru dari Pair<T> (ingat dari bagian “Mendefinisikan Methods” di Bab 5 bahwa Self adalah alias tipe buat tipe dari blok impl-nya, yang mana di kasus ini adalah Pair<T>). Tapi di blok impl berikutnya, Pair<T> cuma mengimplementasikan method cmp_display kalau tipe di dalamnya T mengimplementasikan trait PartialOrd (yang memungkinkan perbandingan) dan trait Display (yang memungkinkan untuk dicetak).

Filename: src/lib.rs
use std::fmt::Display;

struct Pair<T> {
    x: T,
    y: T,
}

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}
Listing 10-15: Mengimplementasikan method pada tipe generik secara bersyarat bergantung pada trait bounds

Kita juga bisa mengimplementasikan secara bersyarat sebuah trait buat tipe apa pun yang mengimplementasikan trait lain. Implementasi sebuah trait pada tipe apa pun yang memenuhi trait bounds-nya disebut sebagai blanket implementations (implementasi selimut) dan ini sangat banyak dipakai di standard library Rust. Misalnya, standard library mengimplementasikan trait ToString pada tipe apa pun yang mengimplementasikan trait Display. Blok impl di standard library keliatan mirip kayak kode ini:

impl<T: Display> ToString for T {
    // --snip--
}

Karena standard library punya blanket implementation ini, kita bisa memanggil method to_string yang didefinisikan sama trait ToString pada tipe apa pun yang mengimplementasikan trait Display. Misalnya, kita bisa mengubah integer jadi nilai String miliknya seperti ini karena integer mengimplementasikan Display:

#![allow(unused)]
fn main() {
let s = 3.to_string();
}

Blanket implementations ini biasanya muncul di dokumentasi buat suatu trait di bagian “Implementors”.

Traits dan trait bounds memungkinkan kita nulis kode yang memakai parameter tipe generik untuk mengurangi duplikasi, sekaligus ngasih tau compiler kalau kita maunya tipe generik itu punya perilaku tertentu. Compiler kemudian bakal memakai informasi trait bound tersebut buat mengecek apakah semua tipe konkret yang dipakai di kode kita sudah menyediakan perilaku yang benar. Di bahasa pemrograman yang dynamically typed (tipe dinamis), kita bakal dapat error pas runtime kalau kita memanggil method di suatu tipe yang sebenarnya tidak punya definisi method tersebut. Tapi Rust mindahin error-error ini ke fase compile time jadi kita dipaksa buat membenarkan masalah ini sebelum kode kita bahkan bisa dijalankan. Sebagai bonus, kita tidak perlu nulis kode buat ngecek perilaku saat runtime karena kita sudah mengeceknya pas compile time. Melakukan ini bakal meningkatkan performa tanpa harus mengorbankan fleksibilitas dari generik.

Validasi Referensi Memakai Lifetimes

Memvalidasi Referensi dengan Lifetimes

Lifetimes (waktu hidup) adalah jenis generik lain yang sebenarnya sudah kita pakai. Bukannya memastikan kalau sebuah tipe punya perilaku yang kita mau, lifetimes memastikan kalau referensi bakal tetap valid selama kita masih butuh.

Satu detail yang tidak kita bahas di bagian “Referensi dan Borrowing” di Bab 4 adalah setiap referensi di Rust punya sebuah lifetime, yaitu scope di mana referensi itu valid. Sebagian besar waktu, lifetimes itu bersifat implisit dan ditebak (inferred), sama halnya kayak sebagian besar waktu tipe juga ditebak. Kita baru diwajibkan buat menganotasi tipe kalau ada beberapa kemungkinan tipe yang bisa dipakai. Mirip dengan itu, kita harus menganotasi lifetimes kalau lifetimes dari referensi-referensi yang ada bisa berhubungan dengan beberapa cara yang berbeda. Rust mewajibkan kita menganotasi hubungan ini memakai parameter lifetime generik untuk memastikan referensi sebenarnya yang dipakai pas runtime bakal pasti valid.

Menganotasi lifetimes bahkan bukan konsep yang dimiliki kebanyakan bahasa pemrograman lain, jadi ini mungkin bakal terasa asing. Meskipun kita tidak bakal membahas lifetimes secara menyeluruh di bab ini, kita bakal membahas cara-cara umum yang mungkin bakal kita temui terkait sintaks lifetime supaya kita bisa nyaman dengan konsepnya.

Mencegah Dangling References dengan Lifetimes

Tujuan utama dari lifetimes adalah buat mencegah dangling references (referensi menggantung), yang bikin program merujuk ke data yang salah alih- alih data yang sebenarnya dituju. Coba perhatikan program di Listing 10-16, yang punya scope luar dan scope dalam.

fn main() {
    let r;

    {
        let x = 5;
        r = &x;
    }

    println!("r: {r}");
}
Listing 10-16: Usaha untuk memakai referensi yang nilainya sudah keluar dari scope

Catatan: Contoh di Listing 10-16, 10-17, dan 10-23 mendeklarasikan variabel tanpa memberi mereka nilai awal, jadi nama variabelnya eksis di scope luar. Sekilas, ini mungkin kelihatannya bertentangan sama aturan Rust yang tidak mengizinkan nilai null. Tapi, kalau kita mencoba memakai sebuah variabel sebelum memberinya nilai, kita bakal dapat error compile-time, yang menunjukkan kalau Rust memang tidak mengizinkan nilai null.

Scope luar mendeklarasikan variabel bernama r tanpa nilai awal, dan scope dalam mendeklarasikan variabel bernama x dengan nilai awal 5. Di dalam scope dalam, kita mencoba nge-set nilai r jadi referensi ke x. Kemudian scope dalamnya berakhir, dan kita mencoba mencetak nilai di r. Kode ini tidak bakal bisa di-compile karena nilai yang dirujuk sama r sudah keluar dari scope sebelum kita mencoba memakainya. Ini pesan error-nya:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `x` does not live long enough
 --> src/main.rs:6:13
  |
5 |         let x = 5;
  |             - binding `x` declared here
6 |         r = &x;
  |             ^^ borrowed value does not live long enough
7 |     }
  |     - `x` dropped here while still borrowed
8 |
9 |     println!("r: {r}");
  |                   - borrow later used here

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

Pesan error-nya bilang kalau variabel x “tidak hidup cukup lama” (does not live long enough). Alasannya adalah x bakal keluar dari scope saat scope dalam berakhir di baris 7. Tapi r masih valid untuk scope luar; karena scope-nya lebih besar, kita bilang kalau dia “hidup lebih lama.” Kalau Rust membiarkan kode ini jalan, r bakal merujuk ke memori yang sudah di-dealokasi saat x keluar dari scope, dan apa pun yang kita coba lakukan dengan r tidak bakal jalan dengan benar. Terus gimana caranya Rust bisa nentuin kalau kode ini tidak valid? Rust memakai sebuah borrow checker.

Borrow Checker

Compiler Rust punya sebuah borrow checker yang membandingkan scopes buat menentukan apakah semua referensi yang dipinjam (borrows) itu valid. Listing 10-17 menunjukkan kode yang sama seperti Listing 10-16 tapi dengan anotasi yang menunjukkan lifetimes dari variabel-variabelnya.

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {r}");   //          |
}                         // ---------+
Listing 10-17: Anotasi lifetimes dari r dan x, dinamakan masing-masing 'a dan 'b

Di sini, kita sudah menganotasi lifetime dari r dengan 'a dan lifetime dari x dengan 'b. Seperti yang bisa dilihat, blok 'b di dalam itu jauh lebih kecil daripada blok lifetime 'a di luar. Pada saat compile time, Rust membandingkan ukuran dari dua lifetimes ini dan melihat kalau r punya lifetime 'a tapi dia merujuk ke memori dengan lifetime 'b. Programnya ditolak karena 'b lebih pendek dari 'a: subjek yang dirujuk tidak hidup selama referensinya.

Listing 10-18 memperbaiki kodenya biar dia tidak punya dangling reference dan bisa di-compile tanpa error sama sekali.

fn main() {
    let x = 5;            // ----------+-- 'b
                          //           |
    let r = &x;           // --+-- 'a  |
                          //   |       |
    println!("r: {r}");   //   |       |
                          // --+       |
}                         // ----------+
Listing 10-18: Referensi yang valid karena datanya punya lifetime yang lebih panjang dari referensinya

Di sini, x punya lifetime 'b, yang mana di kasus ini lebih besar dari 'a. Ini berarti r bisa merujuk ke x karena Rust tahu kalau referensi di r bakal selalu valid selama x masih valid.

Sekarang setelah kita tahu di mana lifetimes dari referensi berada dan gimana Rust menganalisis lifetimes buat memastikan referensi bakal selalu valid, mari kita eksplor lifetimes generik buat parameter dan nilai kembalian di dalam konteks fungsi.

Generic Lifetimes di Fungsi

Kita bakal nulis fungsi yang mengembalikan string slice yang lebih panjang di antara dua string slice. Fungsi ini bakal menerima dua string slice dan mengembalikan satu string slice. Setelah kita mengimplementasikan fungsi longest, kode di Listing 10-19 seharusnya mencetak The longest string is abcd.

Filename: src/main.rs
fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}
Listing 10-19: Fungsi main yang memanggil fungsi longest buat mencari mana yang lebih panjang dari dua string slices

Perhatikan bahwa kita mau fungsi ini menerima string slices, yang merupakan referensi, bukannya strings, karena kita tidak mau fungsi longest mengambil ownership dari parameter-parameternya. Coba cek lagi bagian “String Slices sebagai Parameter” di Bab 4 buat pembahasan lebih lanjut soal kenapa parameter yang kita pakai di Listing 10-19 adalah yang memang kita perlukan.

Kalau kita mencoba mengimplementasikan fungsi longest seperti yang ditunjukkan di Listing 10-20, kode ini tidak bakal bisa di-compile.

Filename: src/main.rs
fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}
Listing 10-20: Implementasi fungsi longest yang mengembalikan yang lebih panjang dari dua string slices tapi belum bisa di-compile

Alih-alih jalan, kita dapat error berikut yang membahas soal lifetimes:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0106]: missing lifetime specifier
 --> src/main.rs:9:33
  |
9 | fn longest(x: &str, y: &str) -> &str {
  |               ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
  |
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  |           ++++     ++          ++          ++

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

Teks bantuannya ngasih tau kalau tipe kembaliannya butuh parameter lifetime generik di situ karena Rust tidak bisa menebak apakah referensi yang bakal dikembalikan itu merujuk ke x atau ke y. Nyatanya, kita juga tidak tahu, karena blok if di dalam body fungsi ini mengembalikan referensi ke x dan blok else mengembalikan referensi ke y!

Saat mendefinisikan fungsi ini, kita tidak tahu nilai konkret apa yang bakal dimasukkan ke fungsi ini, jadi kita tidak tahu apakah blok if atau blok else yang bakal dijalankan. Kita juga tidak tahu lifetimes konkret dari referensi yang bakal dimasukkan, jadi kita tidak bisa melihat scopes seperti yang kita lakukan di Listing 10-17 dan 10-18 untuk menentukan apakah referensi yang kita kembalikan itu bakal selalu valid. Borrow checker juga tidak bisa menentukannya, karena dia tidak tahu gimana lifetimes dari x dan y berhubungan sama lifetime dari nilai kembaliannya. Buat membenarkan error ini, kita bakal menambahkan parameter lifetime generik yang mendefinisikan hubungan antara referensi-referensi tersebut agar borrow checker bisa melakukan analisisnya.

Sintaks Anotasi Lifetime

Anotasi lifetime tidak mengubah seberapa lama suatu referensi hidup. Mereka justru menggambarkan hubungan dari lifetimes antara banyak referensi satu sama lain tanpa memengaruhi lifetimes itu sendiri. Sama seperti fungsi yang bisa menerima tipe apa pun saat signature-nya menentukan parameter tipe generik, fungsi juga bisa menerima referensi dengan lifetime apa pun dengan menentukan parameter lifetime generik.

Anotasi lifetime punya sintaks yang agak tidak biasa: nama parameter lifetime harus dimulai dengan apostrof (tanda kutip tunggal, ') dan biasanya semuanya huruf kecil dan sangat pendek, sama seperti tipe generik. Kebanyakan orang memakai nama 'a untuk anotasi lifetime yang pertama. Kita menaruh anotasi parameter lifetime setelah tanda & dari sebuah referensi, dengan memakai spasi untuk memisahkan anotasinya dari tipe referensinya.

Ini beberapa contohnya: sebuah referensi ke i32 tanpa parameter lifetime, sebuah referensi ke i32 yang punya parameter lifetime bernama 'a, dan sebuah referensi mutable ke i32 yang juga punya lifetime 'a.

&i32        // sebuah referensi
&'a i32     // sebuah referensi dengan _lifetime_ eksplisit
&'a mut i32 // sebuah referensi _mutable_ dengan _lifetime_ eksplisit

Satu anotasi lifetime yang berdiri sendiri tidak punya banyak arti karena anotasi itu ditujukan untuk memberi tahu Rust gimana parameter lifetime generik dari berbagai referensi saling berhubungan. Mari kita teliti gimana anotasi lifetime berhubungan satu sama lain di dalam konteks fungsi longest.

Anotasi Lifetime di Signature Fungsi

Buat memakai anotasi lifetime di signature fungsi, kita harus mendeklarasikan parameter lifetime generik di dalam kurung sudut di antara nama fungsi dan daftar parameter, sama persis seperti yang kita lakukan sama parameter tipe generik.

Kita mau signature ini mengekspresikan batasan ini: referensi yang dikembalikan bakal valid setidaknya selama kedua parameter itu juga valid. Ini adalah hubungan antara lifetimes dari parameter dan nilai kembaliannya. Kita bakal menamakan lifetime itu 'a lalu menambahkannya ke setiap referensi, seperti yang ditunjukkan di Listing 10-21.

Filename: src/main.rs
fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
Listing 10-21: Definisi fungsi longest yang menentukan kalau semua referensi di signature tersebut harus punya lifetime 'a yang sama

Kode ini seharusnya bisa di-compile dan menghasilkan hasil yang kita inginkan saat kita memakainya bersama fungsi main di Listing 10-19.

Signature fungsinya sekarang memberi tahu Rust bahwa untuk suatu lifetime 'a, fungsi ini menerima dua parameter, di mana keduanya adalah string slices yang hidup setidaknya sepanjang lifetime 'a. Signature fungsinya juga memberi tahu Rust bahwa string slice yang dikembalikan dari fungsi itu bakal hidup setidaknya sepanjang lifetime 'a. Di praktiknya, ini berarti lifetime dari referensi yang dikembalikan oleh fungsi longest itu sama dengan lifetime yang paling kecil dari antara nilai-nilai yang dirujuk oleh argumen-argumen fungsi tersebut. Hubungan-hubungan inilah yang kita mau Rust pakai saat menganalisis kode ini.

Ingat, saat kita menentukan parameter lifetime di signature fungsi ini, kita tidak sedang mengubah lifetimes dari nilai apa pun yang masuk atau keluar. Tapi, kita sedang menentukan kalau borrow checker harus menolak nilai apa pun yang tidak mematuhi batasan-batasan ini. Perhatikan bahwa fungsi longest tidak perlu tahu persis berapa lama x dan y bakal hidup, dia cuma perlu tahu kalau ada suatu scope yang bisa disubstitusi untuk 'a yang bakal memenuhi signature ini.

Saat menganotasi lifetimes di fungsi, anotasinya ditaruh di signature fungsi, bukan di body fungsi. Anotasi lifetime menjadi bagian dari kontrak fungsi itu, mirip dengan tipe-tipe di signature-nya. Memiliki signature fungsi yang mengandung kontrak lifetime berarti analisis yang dilakukan compiler Rust bisa jadi lebih sederhana. Kalau ada masalah dengan cara sebuah fungsi dianotasi atau cara dia dipanggil, pesan error compiler bisa menunjuk ke bagian kode kita serta batasannya dengan lebih tepat. Kalau sebaliknya compiler Rust menebak-nebak lebih banyak soal apa yang kita maksud terkait hubungan antar lifetimes, compiler mungkin cuma bakal bisa nunjukin pemakaian kode kita yang berjarak beberapa langkah dari sumber masalah aslinya.

Saat kita memasukkan referensi konkret ke longest, lifetime konkret yang disubstitusikan untuk 'a adalah bagian dari scope x yang tumpang tindih (overlap) dengan scope y. Dengan kata lain, lifetime generik 'a bakal mendapatkan lifetime konkret yang setara dengan yang lebih kecil di antara lifetimes x dan y. Karena kita sudah menganotasi referensi yang dikembalikan dengan parameter lifetime 'a yang sama, referensi kembalian itu juga bakal valid sepanjang yang lebih kecil di antara lifetimes x dan y.

Mari kita lihat gimana anotasi lifetime membatasi fungsi longest dengan memasukkan referensi yang punya lifetimes konkret yang berbeda. Listing 10-22 adalah contoh yang simpel.

Filename: src/main.rs
fn main() {
    let string1 = String::from("long string is long");

    {
        let string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {result}");
    }
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
Listing 10-22: Memakai fungsi longest dengan referensi ke nilai String yang punya lifetimes konkret yang berbeda

Di contoh ini, string1 itu valid sampai akhir dari scope luar, string2 itu valid sampai akhir scope dalam, dan result merujuk ke sesuatu yang valid sampai akhir scope dalam. Jalankan kode ini dan kita bakal lihat kalau borrow checker menyetujuinya; kodenya bakal di-compile dan mencetak The longest string is long string is long.

Berikutnya, mari kita coba contoh yang menunjukkan kalau lifetime dari referensi di result harus merupakan lifetime yang lebih kecil dari dua argumennya. Kita bakal memindahkan deklarasi variabel result ke luar scope dalam tapi membiarkan proses assignment nilai ke variabel result di dalam scope bareng string2. Lalu kita bakal pindahin println! yang memakai result ke luar scope dalam, setelah scope dalam tersebut berakhir. Kode di Listing 10-23 tidak bakal bisa di-compile.

Filename: src/main.rs
fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
Listing 10-23: Mencoba memakai result setelah string2 keluar dari scope

Pas kita nyoba compile kode ini, kita bakal dapet error ini:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `string2` does not live long enough
 --> src/main.rs:6:44
  |
5 |         let string2 = String::from("xyz");
  |             ------- binding `string2` declared here
6 |         result = longest(string1.as_str(), string2.as_str());
  |                                            ^^^^^^^ borrowed value does not live long enough
7 |     }
  |     - `string2` dropped here while still borrowed
8 |     println!("The longest string is {result}");
  |                                      ------ borrow later used here

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

Error ini menunjukkan kalau biar result valid buat statement println!, string2 harusnya valid sampai akhir dari scope luar. Rust tahu ini karena kita sudah menganotasi lifetimes dari parameter fungsi dan nilai kembalian dengan memakai parameter lifetime 'a yang sama.

Sebagai manusia, kita bisa melihat kode ini dan langsung tahu kalau string1 lebih panjang dari string2, dan karenanya, result bakal berisi referensi ke string1. Karena string1 belum keluar dari scope, referensi ke string1 seharusnya masih valid buat statement println!. Tapi, compiler tidak bisa melihat kalau referensinya valid di kasus ini. Kita sudah memberi tahu Rust kalau lifetime referensi yang dikembalikan oleh fungsi longest itu sama dengan yang lebih kecil di antara lifetimes dari referensi-referensi yang dimasukkan. Maka dari itu, borrow checker menolak kode di Listing 10-23 karena kemungkinannya punya referensi yang tidak valid.

Coba desain eksperimen lain yang memvariasikan nilai dan lifetimes dari referensi yang di-pass ke fungsi longest serta gimana referensi kembaliannya dipakai. Bikin hipotesis tentang apakah eksperimen kita bakal lolos borrow checker sebelum men-compile; lalu cek apakah kita benar!

Berpikir dalam Konteks Lifetimes

Gimana cara kita menentukan parameter lifetime itu bergantung pada apa yang sedang dilakukan sama fungsi kita. Misalnya, kalau kita mengubah implementasi fungsi longest biar selalu mengembalikan parameter pertama bukannya string slice yang paling panjang, kita tidak perlu menentukan lifetime pada parameter y. Kode berikut ini bakal bisa di-compile:

Filename: src/main.rs
fn main() {
    let string1 = String::from("abcd");
    let string2 = "efghijklmnopqrstuvwxyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &str) -> &'a str {
    x
}

Kita sudah menentukan parameter lifetime 'a buat parameter x dan tipe kembaliannya, tapi tidak buat parameter y, karena lifetime y tidak punya hubungan apa pun dengan lifetime x atau nilai kembaliannya.

Saat mengembalikan sebuah referensi dari sebuah fungsi, parameter lifetime buat tipe kembaliannya harus cocok dengan parameter lifetime buat salah satu dari parameternya. Kalau referensi yang dikembalikan tidak merujuk ke salah satu parameter, maka referensi itu pasti merujuk ke suatu nilai yang dibuat di dalam fungsi itu sendiri. Namun, ini bakal jadi dangling reference karena nilai itu bakal keluar dari scope di akhir dari fungsinya. Coba perhatikan usaha implementasi fungsi longest yang tidak bakal bisa di-compile ini:

Filename: src/main.rs
fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &str, y: &str) -> &'a str {
    let result = String::from("really long string");
    result.as_str()
}

Di sini, meskipun kita sudah menentukan parameter lifetime 'a buat tipe kembaliannya, implementasi ini bakal gagal di-compile karena lifetime nilai kembaliannya sama sekali tidak berhubungan dengan lifetime parameter-parameternya. Ini pesan error yang bakal kita dapat:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0515]: cannot return value referencing local variable `result`
  --> src/main.rs:11:5
   |
11 |     result.as_str()
   |     ------^^^^^^^^^
   |     |
   |     returns a value referencing data owned by the current function
   |     `result` is borrowed here

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

Masalahnya adalah result keluar dari scope dan dibersihkan di akhir dari fungsi longest. Kita juga mencoba mengembalikan referensi ke result dari fungsinya. Tidak ada cara buat kita menentukan parameter lifetime yang bisa mengubah dangling reference tersebut, dan Rust tidak bakal ngebiarin kita bikin dangling reference. Di kasus ini, perbaikan terbaiknya adalah dengan mengembalikan tipe data yang owned (dimiliki) bukannya sebuah referensi sehingga fungsi yang memanggilnya nanti bertanggung jawab buat membersihkan nilai tersebut.

Pada akhirnya, sintaks lifetime adalah soal menghubungkan lifetimes dari berbagai parameter dan nilai kembalian dari suatu fungsi. Begitu mereka terhubung, Rust punya informasi yang cukup buat mengizinkan operasi yang aman buat memori (memory-safe operations) dan menolak operasi yang bakal membuat dangling pointers atau melanggar keamanan memori.

Anotasi Lifetime di Definisi Struct

Sejauh ini, struct yang kita definisikan semuanya menampung tipe-tipe yang owned. Kita bisa mendefinisikan struct buat menampung referensi, tapi di kasus itu kita perlu menambahkan anotasi lifetime pada setiap referensi di dalam definisi struct tersebut. Listing 10-24 punya struct bernama ImportantExcerpt yang menampung sebuah string slice.

Filename: src/main.rs
struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}
Listing 10-24: Sebuah struct yang menampung referensi, yang mana butuh anotasi lifetime

Struct ini punya field tunggal part yang menampung string slice, yang merupakan sebuah referensi. Sama kayak tipe data generik, kita mendeklarasikan nama parameter lifetime generik di dalam kurung sudut setelah nama struct sehingga kita bisa memakai parameter lifetime itu di dalam definisi struct-nya. Anotasi ini berarti sebuah instance dari ImportantExcerpt tidak bisa hidup lebih lama dari referensi yang ditampungnya di field part.

Fungsi main di sini bikin instance dari struct ImportantExcerpt yang menampung referensi ke kalimat pertama dari String yang dimiliki sama variabel novel. Data di novel sudah ada sebelum instance ImportantExcerpt itu dibikin. Selain itu, novel belum keluar dari scope sampai setelah ImportantExcerpt keluar dari scope, jadi referensi di dalam instance ImportantExcerpt itu dipastikan valid.

Lifetime Elision (Penghilangan Lifetime)

Kita udah belajar kalau setiap referensi punya lifetime dan kita harus menentukan parameter lifetime untuk fungsi atau struct yang memakai referensi. Namun, kita tadi punya fungsi di Listing 4-9, yang ditampilkan lagi di Listing 10-25, yang berhasil di-compile tanpa anotasi lifetime.

Filename: src/lib.rs
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // first_word works on slices of `String`s
    let word = first_word(&my_string[..]);

    let my_string_literal = "hello world";

    // first_word works on slices of string literals
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}
Listing 10-25: Sebuah fungsi yang kita definisikan di Listing 4-9 yang berhasil di-compile tanpa anotasi lifetime, biarpun parameter dan tipe kembaliannya berupa referensi

Alasan kenapa fungsi ini bisa di-compile tanpa anotasi lifetime murni karena sejarah: di versi awal (sebelum 1.0) dari Rust, kode ini tidak bakal bisa di-compile karena setiap referensi butuh lifetime yang eksplisit. Waktu itu, signature fungsi ini bakal ditulis kayak gini:

fn first_word<'a>(s: &'a str) -> &'a str {

Setelah menulis banyak kode Rust, tim Rust menemukan kalau programmer Rust memasukkan anotasi lifetime yang sama berulang kali di situasi-situasi tertentu. Situasi-situasi ini bisa diprediksi dan mengikuti beberapa pola yang deterministik. Para pengembang memprogram pola-pola ini ke dalam kode compiler supaya borrow checker bisa menebak (infer) lifetimes di situasi-situasi ini dan tidak membutuhkan anotasi yang eksplisit lagi.

Sejarah Rust ini cukup relevan karena mungkin saja ke depannya bakal ada pola deterministik lain yang muncul dan ditambahkan ke dalam compiler. Di masa depan, mungkin bakal lebih sedikit lagi anotasi lifetime yang diwajibkan.

Pola-pola yang diprogram ke dalam analisis referensi Rust disebut lifetime elision rules (aturan penghilangan lifetime). Ini bukan aturan buat dipatuhi sama programmer; mereka ini adalah serangkaian kasus tertentu yang bakal dipertimbangkan oleh compiler, dan kalau kode kita masuk ke kasus-kasus ini, kita tidak perlu nulis lifetimes-nya secara eksplisit.

Aturan elision ini tidak memberikan tebakan (inference) yang komplit. Kalau masih ada kebingungan atau ketidakpastian (ambiguity) soal apa lifetimes dari referensi tersebut setelah Rust menerapkan aturan-aturannya, compiler tidak bakal menebak-nebak apa seharusnya lifetime untuk referensi yang tersisa. Alih-alih menebak, compiler bakal ngasih kita error yang bisa diselesaikan dengan menambahkan anotasi lifetime secara manual.

Lifetimes pada parameter fungsi atau method disebut input lifetimes, dan lifetimes pada nilai kembalian disebut output lifetimes.

Compiler memakai tiga aturan buat mencari tahu lifetimes dari referensi saat tidak ada anotasi yang eksplisit. Aturan pertama berlaku buat input lifetimes, sedangkan aturan kedua dan ketiga berlaku buat output lifetimes. Kalau compiler sudah sampai ke akhir dari tiga aturan ini dan masih ada referensi yang tidak diketahui lifetimes-nya, compiler bakal berhenti dengan sebuah error. Aturan-aturan ini berlaku buat definisi fn maupun blok impl.

Aturan pertama adalah compiler meng-assign parameter lifetime ke setiap parameter yang berupa referensi. Dengan kata lain, fungsi dengan satu parameter dapat satu parameter lifetime: fn foo<'a>(x: &'a i32); fungsi dengan dua parameter dapat dua parameter lifetime terpisah: fn foo<'a, 'b>(x: &'a i32, y: &'b i32); dan seterusnya.

Aturan kedua adalah, kalau ada tepat satu parameter input lifetime, lifetime itu di-assign ke semua parameter output lifetime: fn foo<'a>(x: &'a i32) -> &'a i32.

Aturan ketiga adalah, kalau ada beberapa parameter input lifetime, tapi salah satunya adalah &self atau &mut self karena ini adalah sebuah method, maka lifetime dari self itu bakal di-assign ke semua parameter output lifetime. Aturan ketiga ini bikin methods jauh lebih enak buat dibaca dan ditulis karena kita butuh lebih sedikit simbol.

Mari pura-pura kita adalah compiler. Kita bakal menerapkan aturan-aturan ini buat mencari tahu lifetimes dari referensi di dalam signature fungsi first_word di Listing 10-25. Signature-nya mulai tanpa ada lifetimes apa pun yang berkaitan dengan referensi-referensinya:

fn first_word(s: &str) -> &str {

Kemudian compiler menerapkan aturan pertama, yang menentukan bahwa tiap parameter dapat lifetime-nya masing-masing. Kita bakal menyebutnya 'a seperti biasa, jadi sekarang signature-nya seperti ini:

fn first_word<'a>(s: &'a str) -> &str {

Aturan kedua bisa diterapkan karena ada tepat satu input lifetime. Aturan kedua menentukan bahwa lifetime dari satu parameter input itu di-assign ke output lifetime, jadi signature-nya sekarang jadi kayak gini:

fn first_word<'a>(s: &'a str) -> &'a str {

Sekarang semua referensi di signature fungsi ini sudah punya lifetimes, dan compiler bisa melanjutkan analisisnya tanpa perlu programmer buat menganotasi lifetimes di signature fungsi ini.

Mari kita lihat contoh lain, kali ini memakai fungsi longest yang tidak punya parameter lifetime pas kita mulai ngerjain di Listing 10-20:

fn longest(x: &str, y: &str) -> &str {

Mari terapkan aturan pertama: tiap parameter dapat lifetime-nya sendiri. Kali ini kita punya dua parameter bukannya satu, jadi kita punya dua lifetimes:

fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {

Kita bisa lihat kalau aturan kedua tidak bisa diterapkan karena ada lebih dari satu input lifetime. Aturan ketiga juga tidak bisa diterapkan, karena longest adalah sebuah fungsi bukannya method, jadi tidak ada parameter yang berupa self. Setelah melewati ketiga aturan ini, kita masih belum tahu apa lifetime dari tipe kembaliannya. Inilah alasan kenapa kita dapat error pas nyoba men-compile kode di Listing 10-20: compiler sudah melewati aturan-aturan lifetime elision tapi masih belum bisa mencari tahu semua lifetimes dari referensi yang ada di signature tersebut.

Karena aturan ketiga sebenarnya cuma berlaku buat method signatures, kita bakal membahas lifetimes di konteks tersebut selanjutnya buat melihat kenapa aturan ketiga ini bikin kita tidak perlu menganotasi lifetimes di method signatures terlalu sering.

Anotasi Lifetime di Definisi Method

Saat kita mengimplementasikan methods pada struct yang punya lifetimes, kita memakai sintaks yang sama persis seperti parameter tipe generik, yang ditunjukkan di Listing 10-11. Di mana kita mendeklarasikan dan memakai parameter lifetimes itu bergantung pada apakah mereka berhubungan dengan field struct-nya atau parameter dan nilai kembalian method-nya.

Nama lifetime buat field struct selalu harus dideklarasikan setelah keyword impl dan kemudian dipakai setelah nama struct-nya karena lifetimes itu adalah bagian dari tipe struct-nya.

Di dalam method signatures di dalam blok impl, referensi mungkin terikat ke lifetime dari referensi di dalam field struct, atau mungkin mereka independen. Selain itu, aturan lifetime elision sering kali bikin anotasi lifetime tidak diperlukan lagi di method signatures. Mari kita lihat beberapa contoh yang memakai struct bernama ImportantExcerpt yang kita definisikan di Listing 10-24.

Pertama kita bakal memakai sebuah method bernama level yang satu-satunya parameternya adalah referensi ke self dan nilai kembaliannya adalah sebuah i32, yang mana bukan referensi ke apa pun:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {announcement}");
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

Deklarasi parameter lifetime setelah impl dan pemakaiannya setelah nama tipe itu wajib, tapi kita tidak diwajibkan buat menganotasi lifetime dari referensi ke self berkat aturan elision yang pertama.

Ini adalah contoh di mana aturan lifetime elision yang ketiga bisa diterapkan:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {announcement}");
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

Ada dua input lifetimes, jadi Rust menerapkan aturan lifetime elision pertama dan memberi baik &self maupun announcement lifetimes mereka masing-masing. Lalu, karena salah satu parameternya adalah &self, tipe kembaliannya bakal dapat lifetime dari &self, dan semua lifetimes sudah lengkap terjelaskan.

Lifetime Static

Ada satu lifetime spesial yang perlu kita bahas yaitu 'static, yang menandakan kalau referensi yang bersangkutan bisa hidup selama keseluruhan durasi dari program. Semua string literals punya lifetime 'static, yang bisa kita anotasi seperti berikut:

#![allow(unused)]
fn main() {
let s: &'static str = "Saya punya lifetime static.";
}

Teks dari string ini disimpan langsung di dalam binary program kita, yang mana bakal selalu tersedia. Maka dari itu, lifetime dari semua string literals adalah 'static.

Kita mungkin bakal melihat saran di pesan error untuk memakai lifetime 'static. Tapi sebelum menentukan 'static sebagai lifetime buat sebuah referensi, pikirkan dulu apakah referensi yang kita punya itu sebenarnya hidup selama keseluruhan lifetime program kita atau tidak, dan apakah kita emang maunya begitu. Sebagian besar waktu, pesan error yang menyarankan lifetime 'static itu terjadi gara-gara kita mencoba membuat dangling reference atau ada ketidakcocokan (mismatch) antara lifetimes yang tersedia. Di kasus seperti itu, solusinya adalah dengan memperbaiki masalah utamanya, bukannya asal menentukan lifetime 'static.

Parameter Tipe Generik, Trait Bounds, dan Lifetimes Secara Bersamaan

Mari kita lihat secara singkat sintaks buat menentukan parameter tipe generik, trait bounds, dan lifetimes semuanya di satu fungsi!

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest_with_an_announcement(
        string1.as_str(),
        string2,
        "Today is someone's birthday!",
    );
    println!("The longest string is {result}");
}

use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,
{
    println!("Announcement! {ann}");
    if x.len() > y.len() { x } else { y }
}

Ini adalah fungsi longest dari Listing 10-21 yang mengembalikan string slice yang lebih panjang dari antara dua string slices. Tapi sekarang fungsi ini punya parameter ekstra bernama ann dari tipe generik T, yang mana bisa diisi oleh tipe apa pun yang mengimplementasikan trait Display seperti yang ditentukan sama klausa where. Parameter ekstra ini bakal dicetak memakai {}, itulah kenapa kita butuh trait bound Display. Karena lifetimes itu adalah salah satu tipe dari generik, deklarasi parameter lifetime 'a dan parameter tipe generik T berada di dalam satu daftar yang sama di dalam kurung sudut setelah nama fungsinya.

Ringkasan

Kita sudah ngebahas banyak hal di bab ini! Sekarang setelah kita paham tentang parameter tipe generik, traits dan trait bounds, serta parameter lifetime generik, kita udah siap buat nulis kode tanpa pengulangan yang bisa jalan di berbagai situasi yang beda-beda. Parameter tipe generik ngasih kita kemampuan buat menerapkan kode ke tipe yang berbeda. Traits dan trait bounds memastikan kalau walaupun tipe-tipenya generik, mereka tetap bakal punya perilaku yang dibutuhin sama kode kita. Kita udah belajar gimana cara memakai anotasi lifetime buat memastikan kalau kode fleksibel ini tidak bakal punya dangling references (referensi yang menggantung). Dan semua analisis ini terjadi saat compile time, yang sama sekali tidak memengaruhi performa saat runtime!

Percaya atau tidak, masih banyak lagi yang bisa dipelajari soal topik-topik yang kita bahas di bab ini: Bab 18 bakal ngebahas trait objects, yang merupakan cara lain buat memakai traits. Ada juga skenario-skenario yang lebih rumit yang melibatkan anotasi lifetime yang cuma bakal kita perlukan di situasi yang sangat tingkat lanjut (advanced); buat itu, kita bisa membaca Rust Reference. Tapi buat langkah selanjutnya, kita bakal belajar gimana cara menulis tests di Rust supaya kita bisa memastikan kalau kode kita berjalan persis seperti yang seharusnya.

Menulis Pengujian Otomatis (Automated Tests)

Di esainya pada tahun 1972 yang berjudul “The Humble Programmer,” Edsger W. Dijkstra bilang kalau “pengujian program (program testing) bisa jadi cara yang sangat efektif buat menunjukkan adanya bugs, tapi sangat tidak memadai buat menunjukkan kalau bugs itu tidak ada.” Itu bukan berarti kita tidak boleh mencoba melakukan pengujian sebanyak mungkin!

Kebenaran (correctness) dalam program kita adalah sejauh mana kode kita melakukan apa yang kita inginkan. Rust didesain dengan tingkat kepedulian yang tinggi soal kebenaran dari program, tapi kebenaran itu kompleks dan tidak mudah dibuktikan. Sistem tipe (type system) Rust menanggung sebagian besar beban ini, tapi sistem tipe tidak bisa menangkap semuanya. Oleh karena itu, Rust menyertakan dukungan buat menulis pengujian perangkat lunak otomatis (automated software tests).

Katakanlah kita menulis sebuah fungsi add_two yang menambahkan 2 ke angka apa pun yang dimasukkan ke dalamnya. Signature dari fungsi ini menerima sebuah integer sebagai parameter dan mengembalikan sebuah integer sebagai hasil. Saat kita mengimplementasikan dan men-compile fungsi tersebut, Rust melakukan semua pengecekan tipe dan borrow checking yang sudah kita pelajari sejauh ini untuk memastikan bahwa, misalnya, kita tidak memberikan nilai String atau referensi yang tidak valid ke fungsi ini. Tapi Rust tidak bisa mengecek apakah fungsi ini bakal melakukan persis apa yang kita inginkan, yaitu mengembalikan parameternya ditambah 2, bukannya malah parameternya ditambah 10 atau dikurang 50! Di sinilah pengujian (tests) berperan.

Kita bisa menulis pengujian yang menegaskan (assert), misalnya, bahwa saat kita memasukkan angka 3 ke fungsi add_two, nilai yang dikembalikan adalah 5. Kita bisa menjalankan pengujian-pengujian ini kapan pun kita membuat perubahan pada kode kita buat memastikan setiap perilaku benar yang sudah ada itu tidak berubah.

Pengujian adalah keterampilan yang kompleks: walaupun kita tidak bisa membahas setiap detail soal gimana cara nulis pengujian yang bagus di dalam satu bab, di bab ini kita bakal membahas mekanisme dari fasilitas pengujian Rust. Kita bakal membahas anotasi dan macros yang tersedia pas kita menulis pengujian kita, perilaku default dan opsi-opsi yang disediakan buat menjalankan pengujian kita, dan gimana cara mengatur pengujian jadi unit tests (pengujian unit) dan integration tests (pengujian integrasi).

Gimana Cara Menulis Tests

Cara Menulis Pengujian (Tests)

Pengujian (tests) adalah fungsi Rust yang memverifikasi kalau kode selain kode pengujian berjalan sesuai yang diharapkan. Body dari fungsi pengujian biasanya melakukan tiga aksi ini:

  • Menyiapkan data atau state (keadaan) yang dibutuhkan.
  • Menjalankan kode yang mau diuji.
  • Menegaskan (assert) kalau hasilnya sesuai dengan yang diharapkan.

Mari kita lihat fitur-fitur yang disediakan Rust khusus buat menulis pengujian yang mengambil aksi-aksi ini, termasuk atribut test, beberapa macro, dan atribut should_panic.

Anatomi Fungsi Pengujian

Paling sederhananya, pengujian di Rust adalah sebuah fungsi yang dianotasi dengan atribut test. Atribut adalah metadata tentang kode Rust; salah satu contohnya adalah atribut derive yang kita pakai buat structs di Bab 5. Untuk mengubah sebuah fungsi menjadi fungsi pengujian, tambahkan #[test] di baris sebelum fn. Saat kita menjalankan pengujian menggunakan perintah cargo test, Rust bakal mem-build sebuah test runner binary yang menjalankan fungsi-fungsi yang dianotasi ini dan melaporkan apakah setiap fungsi pengujian tersebut sukses atau gagal.

Kapan pun kita membuat project library baru dengan Cargo, sebuah modul pengujian (test module) dengan satu fungsi pengujian di dalamnya akan otomatis dibuatkan buat kita. Modul ini ngasih kita template buat nulis pengujian jadi kita tidak perlu repot-repot nyari struktur dan sintaks persisnya setiap kali mulai project baru. Kita bisa menambahkan sebanyak apa pun fungsi pengujian tambahan dan modul pengujian yang kita mau!

Kita bakal eksplorasi beberapa aspek soal gimana pengujian itu bekerja dengan ber-eksperimen menggunakan pengujian template ini sebelum kita benar-benar menguji kode apa pun. Lalu kita bakal nulis beberapa pengujian dunia nyata yang memanggil kode yang sudah kita tulis dan menegaskan kalau perilakunya sudah benar.

Mari kita buat project library baru bernama adder yang bakal menjumlahkan dua angka:

$ cargo new adder --lib
     Created library `adder` project
$ cd adder

Isi dari file src/lib.rs di library adder kita seharusnya kelihatan seperti Listing 11-1.

Filename: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}
Listing 11-1: Kode yang di-generate secara otomatis oleh cargo new

File ini dimulai dengan sebuah contoh fungsi add, supaya kita punya sesuatu buat diuji.

Buat sekarang, mari fokus pada fungsi it_works aja. Perhatikan anotasi #[test]: atribut ini menunjukkan kalau ini adalah fungsi pengujian, jadi test runner tahu buat memperlakukan fungsi ini sebagai pengujian. Kita juga bisa punya fungsi biasa di dalam modul tests untuk membantu menyiapkan skenario umum atau menjalankan operasi umum, jadi kita harus selalu menunjukkan fungsi mana yang merupakan fungsi pengujian.

Contoh body fungsi ini memakai macro assert_eq! untuk menegaskan kalau result, yang menampung hasil pemanggilan add dengan angka 2 dan 2, itu sama dengan 4. Penegasan ini berfungsi sebagai contoh format buat pengujian yang umum. Mari kita jalankan untuk melihat kalau pengujian ini sukses (passes).

Perintah cargo test menjalankan semua pengujian di dalam project kita, seperti yang ditunjukkan di Listing 11-2.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
     Running unittests src/lib.rs (target/debug/deps/adder-01ad14159ff659ab)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Listing 11-2: Output dari menjalankan pengujian yang di-generate otomatis

Cargo men-compile dan menjalankan pengujiannya. Kita melihat baris running 1 test. Baris berikutnya menunjukkan nama fungsi pengujian yang di-generate, bernama tests::it_works, dan hasil dari pengujian itu adalah ok. Ringkasan keseluruhan test result: ok. berarti semua pengujian sukses, dan bagian yang tertulis 1 passed; 0 failed menjumlahkan banyaknya pengujian yang sukses atau gagal.

Kita bisa menandai sebuah pengujian untuk diabaikan (ignored) supaya tidak dijalankan pada waktu tertentu; kita bakal membahas ini di bagian “Mengabaikan Beberapa Pengujian Kecuali Diminta Secara Spesifik” nanti di bab ini. Karena kita belum melakukannya di sini, ringkasannya menunjukkan 0 ignored. Kita juga bisa mengirimkan argumen ke perintah cargo test untuk menjalankan hanya pengujian yang namanya cocok dengan string tertentu; ini disebut filtering (penyaringan) dan kita bakal membahasnya di bagian “Menjalankan Sebagian Pengujian Berdasarkan Nama”. Di sini kita tidak menyaring pengujiannya, jadi akhir dari ringkasan menunjukkan 0 filtered out.

Statistik 0 measured adalah untuk pengujian benchmark yang mengukur performa. Pengujian benchmark, saat tulisan ini dibuat, baru tersedia di versi Rust nightly. Cek dokumentasi soal pengujian benchmark buat info lebih lanjut.

Bagian selanjutnya dari output pengujian yang dimulai dengan Doc-tests adder adalah hasil dari documentation tests (pengujian dokumentasi). Kita belum punya documentation tests apa pun, tapi Rust bisa men-compile contoh kode apa pun yang ada di dokumentasi API kita. Fitur ini membantu menjaga supaya dokumentasi dan kode kita tetap sinkron! Kita bakal membahas cara menulis documentation tests di bagian “Komentar Dokumentasi Sebagai Pengujian” di Bab 14. Buat sekarang, kita abaikan saja output Doc-tests ini.

Mari kita mulai mengubah pengujian ini sesuai kebutuhan kita sendiri. Pertama, ubah nama fungsi it_works jadi nama yang beda, misalnya exploration, kayak gini:

Nama file: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

Kemudian jalankan cargo test lagi. Outputnya sekarang bakal menunjukkan exploration bukannya it_works:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.59s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::exploration ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Sekarang kita bakal nambahin pengujian satu lagi, tapi kali ini kita bakal bikin pengujian yang gagal! Pengujian bakal gagal kalau ada sesuatu di dalam fungsi pengujian tersebut yang menyebabkan panic. Tiap pengujian dijalankan di dalam thread baru, dan saat main thread melihat ada test thread yang mati, pengujian itu bakal ditandai sebagai gagal. Di Bab 9, kita membahas gimana cara paling sederhana untuk membuat panic adalah dengan memanggil macro panic!. Masukkan pengujian baru ini sebagai fungsi bernama another, sehingga file src/lib.rs kita kelihatan seperti Listing 11-3.

Filename: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}
Listing 11-3: Menambahkan pengujian kedua yang bakal gagal karena kita memanggil macro panic!

Jalankan pengujiannya lagi memakai cargo test. Output-nya seharusnya kelihatan kayak Listing 11-4, yang menunjukkan kalau pengujian exploration kita sukses tapi pengujian another kita gagal.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.72s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok

failures:

---- tests::another stdout ----

thread 'tests::another' panicked at src/lib.rs:17:9:
Make this test fail
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::another

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`
Listing 11-4: Hasil pengujian saat satu pengujian sukses dan yang lainnya gagal

Bukannya ok, baris test tests::another menunjukkan FAILED. Ada dua bagian baru yang muncul di antara hasil masing-masing pengujian dan ringkasan akhir: bagian pertama menampilkan detail alasan setiap pengujian yang gagal. Di kasus ini, kita mendapat detail bahwa tests::another gagal karena terjadi panic dengan pesan Make this test fail di baris 17 pada file src/lib.rs. Bagian berikutnya mendaftarkan cuma nama-nama dari semua pengujian yang gagal, yang mana sangat berguna saat ada banyak pengujian dan sangat banyak output dari pengujian yang gagal. Kita bisa memakai nama pengujian yang gagal itu untuk menjalankan hanya pengujian tersebut agar lebih gampang men-debug-nya; kita bakal membahas lebih lanjut soal cara-cara menjalankan pengujian di bagian “Mengontrol Bagaimana Pengujian Dijalankan”.

Baris ringkasan ditampilkan di bagian paling akhir: secara keseluruhan, hasil pengujian kita adalah FAILED. Kita punya satu pengujian yang sukses dan satu yang gagal.

Sekarang karena kita sudah melihat seperti apa hasil pengujian di berbagai skenario, mari kita lihat beberapa macro selain panic! yang berguna buat pengujian.

Memeriksa Hasil dengan Macro assert!

Macro assert!, yang disediakan oleh standard library, berguna saat kita mau memastikan bahwa suatu kondisi di pengujian dievaluasi menjadi true. Kita memberikan sebuah argumen ke macro assert! yang bakal dievaluasi jadi sebuah Boolean. Kalau nilainya true, tidak ada yang terjadi dan pengujiannya bakal sukses. Kalau nilainya false, macro assert! memanggil panic! sehingga pengujiannya gagal. Memakai macro assert! sangat membantu buat mengecek apakah kode kita berfungsi seperti yang kita mau.

Di Bab 5, Listing 5-15, kita memakai struct Rectangle dan method can_hold, yang mana diulangi lagi di sini di Listing 11-5. Mari kita masukkan kode ini ke dalam file src/lib.rs, lalu kita tulis beberapa pengujian buat kode tersebut menggunakan macro assert!.

Filename: src/lib.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}
Listing 11-5: Struct Rectangle dan method can_hold-nya dari Bab 5

Method can_hold mengembalikan Boolean, yang artinya ini adalah use case yang sangat pas buat macro assert!. Di Listing 11-6, kita nulis pengujian yang mencoba method can_hold dengan bikin instance Rectangle yang punya lebar 8 dan tinggi 7, lalu menegaskan kalau ia bisa menampung instance Rectangle lain yang punya lebar 5 dan tinggi 1.

Filename: src/lib.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }
}
Listing 11-6: Pengujian buat can_hold yang mengecek apakah persegi panjang yang lebih besar benar-benar bisa menampung persegi panjang yang lebih kecil

Perhatikan baris use super::*; di dalam modul tests. Modul tests adalah modul biasa yang mengikuti aturan visibilitas standar yang sudah kita bahas di Bab 7 di bagian “Paths untuk Merujuk ke sebuah Item di Pohon Modul”. Karena modul tests adalah inner module (modul di dalam modul lain), kita perlu membawa kode yang mau diuji di modul luar ke dalam scope modul tests ini. Kita memakai glob (*) di sini, jadi semua hal yang kita definisikan di modul luar bakal tersedia di modul tests ini.

Kita menamakan pengujian kita larger_can_hold_smaller, dan kita membuat dua instance Rectangle yang kita butuhkan. Kemudian kita memanggil macro assert! dan memasukkan hasil pemanggilan larger.can_hold(&smaller) kepadanya. Ekspresi ini seharusnya mengembalikan true, jadi pengujian kita seharusnya sukses. Mari kita buktikan!

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 1 test
test tests::larger_can_hold_smaller ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Sukses kan! Mari tambahkan pengujian satu lagi, kali ini menegaskan bahwa persegi panjang yang lebih kecil tidak bisa menampung persegi panjang yang lebih besar:

Nama file: src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        // --snip--
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

Karena hasil yang benar dari fungsi can_hold di kasus ini adalah false, kita perlu men-negasikan hasil tersebut sebelum memasukkannya ke macro assert!. Sebagai hasilnya, pengujian kita bakal sukses kalau can_hold mengembalikan false:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Dua pengujian yang sukses! Sekarang mari kita lihat apa yang terjadi pada hasil pengujian kalau kita memunculkan bug di kode kita. Kita bakal mengubah implementasi dari method can_hold dengan mengganti tanda lebih-dari menjadi tanda kurang-dari saat dia membandingkan lebar (widths):

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

// --snip--
impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width < other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

Menjalankan pengujiannya sekarang bakal menghasilkan output ini:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok

failures:

---- tests::larger_can_hold_smaller stdout ----

thread 'tests::larger_can_hold_smaller' panicked at src/lib.rs:28:9:
assertion failed: larger.can_hold(&smaller)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::larger_can_hold_smaller

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Pengujian kita berhasil menangkap bug tersebut! Karena larger.width adalah 8 dan smaller.width adalah 5, perbandingan lebar-lebar ini di can_hold sekarang mengembalikan false: 8 itu tidak kurang dari 5.

Menguji Kesamaan dengan Macro assert_eq! dan assert_ne!

Cara yang sangat umum buat memverifikasi fungsionalitas adalah dengan menguji kesamaan antara hasil dari kode yang diuji dengan nilai yang kita harapkan bakal dikembalikan oleh kode tersebut. Kita bisa saja melakukannya memakai macro assert! dengan memasukkan ekspresi yang memakai operator ==. Tapi, karena ini adalah bentuk pengujian yang sering sekali dipakai, standard library menyediakan dua macro—assert_eq! dan assert_ne!—buat melakukan pengujian ini dengan lebih praktis. Kedua macro ini membandingkan dua argumen buat melihat apakah mereka sama (equality) atau tidak sama (inequality). Mereka juga bakal mencetak kedua nilai tersebut kalau penegasannya gagal, yang mana bikin kita lebih gampang melihat kenapa pengujian itu gagal; sebaliknya, macro assert! cuma menunjukkan kalau dia dapat nilai false dari ekspresi ==, tanpa mencetak nilai-nilai apa saja yang membuat hasil tersebut jadi false.

Di Listing 11-7, kita menulis sebuah fungsi bernama add_two yang menambahkan 2 ke parameternya, lalu kita menguji fungsi ini menggunakan macro assert_eq!.

Filename: src/lib.rs
pub fn add_two(a: u64) -> u64 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}
Listing 11-7: Menguji fungsi add_two memakai macro assert_eq!

Mari cek apakah pengujiannya sukses!

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Kita membuat sebuah variabel bernama result yang menampung hasil pemanggilan add_two(2). Kemudian kita memasukkan result dan 4 sebagai argumen buat macro assert_eq!. Baris output buat pengujian ini adalah test tests::it_adds_two ... ok, dan teks ok menunjukkan bahwa pengujian kita sukses!

Mari kita masukkan sebuah bug ke dalam kode kita buat melihat seperti apa assert_eq! kalau dia gagal. Ubah implementasi fungsi add_two biar dia malah menambahkan 3:

pub fn add_two(a: u64) -> u64 {
    a + 3
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}

Jalankan pengujiannya lagi:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... FAILED

failures:

---- tests::it_adds_two stdout ----

thread 'tests::it_adds_two' panicked at src/lib.rs:12:9:
assertion `left == right` failed
  left: 5
 right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_adds_two

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Pengujian kita berhasil menangkap bug tersebut! Pengujian tests::it_adds_two gagal, dan pesannya memberi tahu kita kalau penegasan yang gagal adalah left == right beserta apa nilai left dan right yang didapatkan. Pesan ini ngebantu kita buat mulai melakukan debugging: argumen left, di mana kita menaruh hasil pemanggilan add_two(2), itu ternyata 5, padahal argumen right adalah 4. Kita bisa bayangin kalau informasi ini bakal kepake sekali terutama kalau ada banyak pengujian yang lagi jalan.

Perhatikan bahwa di beberapa bahasa dan test frameworks (kerangka pengujian), parameter untuk fungsi penegasan kesamaan biasanya dinamakan expected (harapan) dan actual (asli), dan urutan kita memasukkan argumen-argumennya itu penting. Tapi di Rust, mereka disebut left (kiri) dan right (kanan), dan urutan kita meletakkan nilai ekspektasi kita sama nilai hasil dari kodenya itu sama sekali tidak masalah. Kita bisa saja menulis penegasan di pengujian ini sebagai assert_eq!(4, result), dan ini bakal menghasilkan pesan gagal yang sama yang menampilkan assertion `left == right` failed.

Macro assert_ne! bakal sukses (pass) kalau dua nilai yang kita masukkan tidak sama, dan bakal gagal kalau mereka sama. Macro ini paling berguna buat kasus di mana kita tidak yakin nilai tersebut bakal jadi apa, tapi kita tahu pasti nilai tersebut tidak boleh jadi apa. Misalnya, kalau kita menguji fungsi yang dijamin mengubah inputnya dengan cara tertentu, tapi perubahan itu tergantung sama hari dalam seminggu saat kita menjalankan pengujiannya, maka hal terbaik yang bisa ditegaskan (assert) mungkin adalah bahwa output fungsinya tidak sama dengan inputnya.

Di balik layar, macro assert_eq! dan assert_ne! masing-masing memakai operator == dan !=. Saat penegasan gagal, macro ini mencetak argumen mereka memakai debug formatting, yang berarti nilai-nilai yang dibandingkan harus mengimplementasikan trait PartialEq dan Debug. Semua tipe primitif dan sebagian besar tipe di standard library sudah mengimplementasikan traits ini. Buat structs dan enums yang kita definisikan sendiri, kita harus mengimplementasikan PartialEq buat menegaskan kesamaan tipe tersebut. Kita juga harus mengimplementasikan Debug buat mencetak nilainya saat penegasan gagal. Karena kedua traits ini adalah derivable traits, seperti yang disebutkan di Listing 5-12 di Bab 5, hal ini biasanya semudah menambahkan anotasi #[derive(PartialEq, Debug)] ke dalam definisi struct atau enum kita. Lihat Lampiran C, “Derivable Traits,” buat detail lebih lanjut soal ini dan derivable traits lainnya.

Menambahkan Pesan Kegagalan Kustom

Kita juga bisa menambahkan pesan kustom buat dicetak bersama pesan kegagalan sebagai argumen opsional di macro assert!, assert_eq!, dan assert_ne!. Argumen apa pun yang ditaruh setelah argumen wajib bakal diteruskan langsung ke macro format! (dibahas di “Penggabungan dengan Operator + atau Macro format! di Bab 8), jadi kita bisa memasukkan format string yang mengandung placeholder {} serta nilai-nilai buat mengisi placeholder tersebut. Pesan kustom ini berguna buat mendokumentasikan apa maksud dari sebuah penegasan; saat sebuah pengujian gagal, kita jadi punya gambaran yang lebih baik soal masalah apa yang sedang terjadi dengan kodenya.

Misalnya, katakanlah kita punya fungsi yang menyapa orang dengan namanya dan kita mau menguji apakah nama yang kita berikan ke fungsi tersebut muncul di dalam outputnya:

Nama file: src/lib.rs

pub fn greeting(name: &str) -> String {
    format!("Hello {name}!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

Persyaratan buat program ini belum sepenuhnya disepakati, dan kita cukup yakin kalau teks Hello di awal sapaan bakal berubah. Kita udah mutusin kalau kita tidak mau terus-terusan meng-update pengujiannya tiap kali persyaratan ini berubah, jadi alih-alih mengecek kesamaan yang sama persis dengan nilai kembalian dari fungsi greeting, kita hanya menegaskan kalau outputnya mengandung teks dari parameter inputnya.

Sekarang mari kita masukkan sebuah bug ke dalam kode ini dengan mengubah greeting sehingga dia tidak menyertakan name dan kita bisa lihat seperti apa kegagalan pengujian standarnya:

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

Menjalankan pengujian ini bakal menghasilkan output berikut:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----

thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
assertion failed: result.contains("Carol")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Hasil ini cuma ngasih tahu kalau penegasannya gagal dan di baris mana penegasan itu berada. Pesan kegagalan yang lebih membantu seharusnya mencetak nilai dari fungsi greeting. Mari tambahkan pesan kegagalan kustom yang disusun dari format string dan placeholder yang diisi dengan nilai yang kita dapat dari fungsi greeting:

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(
            result.contains("Carol"),
            "Greeting did not contain name, value was `{result}`"
        );
    }
}

Sekarang ketika kita menjalankan pengujiannya, kita bakal dapat pesan error yang lebih informatif:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.93s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----

thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
Greeting did not contain name, value was `Hello!`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Kita bisa melihat nilai asli yang kita dapat di dalam output pengujiannya, yang mana sangat membantu kita buat debug apa yang sedang terjadi bukannya cuma apa yang kita harapkan terjadi.

Mengecek Panics dengan should_panic

Selain mengecek nilai kembalian, penting juga buat memastikan kalau kode kita menangani kondisi error sesuai yang diharapkan. Contohnya, mari perhatikan tipe Guess yang kita bikin di Bab 9, Listing 9-13. Kode lain yang memakai Guess bergantung pada jaminan bahwa instance Guess hanya bakal berisi angka antara 1 dan 100. Kita bisa nulis pengujian buat memastikan kalau mencoba membuat instance Guess dengan nilai di luar batas itu bakal mengakibatkan panic.

Kita melakukan ini dengan menambahkan atribut should_panic ke dalam fungsi pengujian kita. Pengujian ini bakal sukses jika kode di dalam fungsi tersebut mengalami panic; dan pengujian ini gagal jika kode di dalam fungsinya tidak panic.

Listing 11-8 menunjukkan pengujian yang mengecek bahwa kondisi error dari Guess::new memang terjadi pada waktu yang diharapkan.

Filename: src/lib.rs
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}
Listing 11-8: Menguji apakah sebuah kondisi bakal mengakibatkan panic!

Kita menaruh atribut #[should_panic] setelah atribut #[test] dan sebelum fungsi pengujian yang ia kenai. Mari lihat hasilnya ketika pengujian ini sukses:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests guessing_game

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Terlihat bagus! Sekarang mari masukkan sebuah bug di kode kita dengan menghapus kondisi yang membuat fungsi new panic kalau nilainya lebih besar dari 100:

pub struct Guess {
    value: i32,
}

// --snip--
impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

Saat kita menjalankan pengujian di Listing 11-8, dia bakal gagal:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----
note: test did not panic as expected at src/lib.rs:21:8

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Kita tidak dapat pesan yang cukup membantu di kasus ini, tapi pas kita melihat fungsi pengujiannya, kita ingat kalau ia dianotasi dengan #[should_panic]. Kegagalan yang kita dapat berarti kode di dalam fungsi pengujian ini tidak menyebabkan panic.

Pengujian yang memakai should_panic bisa kurang presisi. Sebuah pengujian should_panic bakal tetap sukses meskipun terjadi panic dengan alasan yang berbeda dari apa yang kita harapkan. Biar pengujian should_panic jadi lebih presisi, kita bisa menambahkan parameter opsional expected ke dalam atribut should_panic. Test harness bakal memastikan kalau pesan kegagalan tersebut mengandung teks yang diberikan. Sebagai contoh, pertimbangkan kode yang dimodifikasi untuk Guess di Listing 11-9 di mana fungsi new bakal panic dengan pesan yang berbeda-beda tergantung apakah nilainya terlalu kecil atau terlalu besar.

Filename: src/lib.rs
pub struct Guess {
    value: i32,
}

// --snip--

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be greater than or equal to 1, got {value}."
            );
        } else if value > 100 {
            panic!(
                "Guess value must be less than or equal to 100, got {value}."
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}
Listing 11-9: Menguji sebuah panic! di mana pesan panic-nya harus mengandung potongan teks tertentu (substring)

Pengujian ini bakal sukses karena teks yang kita tulis di parameter expected dari atribut should_panic adalah substring (bagian string) dari pesan panic yang muncul di fungsi Guess::new. Kita bisa juga memberikan pesan panic utuh yang kita harapkan, yang mana di kasus ini adalah Guess value must be less than or equal to 100, got 200. Apa yang kita pilih buat ditulis tergantung dari seberapa unik atau seberapa dinamis pesan panic-nya dan seberapa presisi kita mau pengujian ini. Di kasus ini, satu potongan teks saja dari pesan panic-nya sudah cukup buat memastikan kalau fungsi pengujian ini menjalankan kasus blok else if value > 100.

Untuk melihat apa yang terjadi saat sebuah pengujian should_panic dengan pesan expected itu gagal, mari kembali masukkan bug ke dalam kode kita dengan menukar body dari blok if value < 1 dan blok else if value > 100:

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be less than or equal to 100, got {value}."
            );
        } else if value > 100 {
            panic!(
                "Guess value must be greater than or equal to 1, got {value}."
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

Kali ini pas kita jalanin pengujian should_panic ini, dia bakal gagal:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----

thread 'tests::greater_than_100' panicked at src/lib.rs:12:13:
Guess value must be greater than or equal to 1, got 200.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
      panic message: "Guess value must be greater than or equal to 1, got 200."
 expected substring: "less than or equal to 100"

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Pesan kegagalannya menunjukkan kalau pengujian ini memang panic seperti yang kita harapkan, tapi pesan panic-nya tidak mengandung string yang diharapkan yaitu less than or equal to 100. Pesan panic yang justru kita dapatkan di kasus ini adalah Guess value must be greater than or equal to 1, got 200. Nah, dengan info ini, kita bisa mulai menelusuri di mana letak bug kita!

Menggunakan Result<T, E> di Pengujian

Semua pengujian yang kita bikin sejauh ini bakal panic saat gagal. Kita juga bisa menulis pengujian yang menggunakan Result<T, E>! Berikut adalah pengujian dari Listing 11-1, tapi ditulis ulang untuk memakai Result<T, E> dan mengembalikan Err daripada panic:

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() -> Result<(), String> {
        let result = add(2, 2);

        if result == 4 {
            Ok(())
        } else {
            Err(String::from("two plus two does not equal four"))
        }
    }
}

Fungsi it_works sekarang punya tipe kembalian Result<(), String>. Di dalam body fungsi, alih-alih memanggil macro assert_eq!, kita mengembalikan Ok(()) ketika pengujiannya sukses dan mengembalikan Err yang menampung String ketika pengujiannya gagal.

Menulis pengujian biar mereka mengembalikan Result<T, E> memungkinkan kita menggunakan question mark operator (?) di dalam body pengujiannya, yang bisa jadi cara sangat nyaman untuk menulis pengujian yang seharusnya gagal jika ada operasi di dalamnya yang mengembalikan varian Err.

Kita tidak bisa memakai anotasi #[should_panic] di pengujian yang memakai Result<T, E>. Buat menegaskan kalau suatu operasi mengembalikan varian Err, jangan pakai question mark operator pada nilai Result<T, E> itu. Sebaliknya, pakai assert!(value.is_err()).

Sekarang setelah kita paham beberapa cara untuk menulis pengujian, mari kita lihat apa yang terjadi di balik layar saat kita menjalankan pengujian dan mulai mengeksplorasi berbagai opsi yang bisa kita pakai dengan cargo test.

Mengontrol Gimana Tests Dijalankan

Mengontrol Bagaimana Pengujian Dijalankan

Sama halnya dengan cargo run yang men-compile kode kita lalu menjalankan binary hasilnya, cargo test men-compile kode kita dalam mode pengujian (test mode) lalu menjalankan test binary hasilnya. Perilaku default dari binary yang dihasilkan oleh cargo test adalah menjalankan semua pengujian secara paralel dan menangkap (capture) output yang dihasilkan selama pengujian berjalan, yang mana mencegah output tersebut ditampilkan sehingga kita lebih gampang membaca output yang berkaitan dengan hasil pengujian. Akan tetapi, kita bisa menentukan opsi baris perintah (command line options) untuk mengubah perilaku default ini.

Beberapa opsi baris perintah diteruskan ke cargo test, dan beberapa diteruskan ke test binary yang dihasilkan. Untuk memisahkan dua jenis argumen ini, kita mendaftarkan argumen yang diteruskan ke cargo test, diikuti dengan pemisah -- (dua tanda hubung), lalu argumen yang diteruskan ke test binary. Menjalankan cargo test --help menampilkan opsi-opsi yang bisa kita pakai bareng cargo test, dan menjalankan cargo test -- --help menampilkan opsi-opsi yang bisa kita pakai setelah pemisah. Opsi-opsi tersebut juga didokumentasikan di bagian “Tests” di buku rustc.

Menjalankan Pengujian secara Paralel atau Berurutan

Saat kita menjalankan banyak pengujian, secara default mereka berjalan secara paralel menggunakan threads (utas), yang berarti mereka selesai dijalankan lebih cepat dan kita bisa dapat feedback lebih cepat. Karena pengujian-pengujian ini berjalan di saat yang bersamaan, kita harus memastikan kalau pengujian kita tidak saling bergantung satu sama lain atau bergantung pada shared state (state/keadaan yang dibagikan) apa pun, termasuk lingkungan (environment) yang dibagikan, seperti direktori kerja saat ini (current working directory) atau variabel lingkungan (environment variables).

Misalnya, katakanlah setiap pengujian kita menjalankan beberapa kode yang membuat file di diska bernama test-output.txt lalu menulis beberapa data ke file tersebut. Kemudian tiap pengujian membaca data di file itu dan menegaskan (asserts) kalau file tersebut mengandung nilai tertentu, yang mana nilainya beda-beda di tiap pengujian. Karena pengujian berjalan di saat yang bersamaan, satu pengujian mungkin bakal menimpa (overwrite) file itu di antara waktu pengujian lain sedang menulis dan membaca file tersebut. Pengujian kedua bakal gagal, bukan karena kodenya salah tapi karena pengujian-pengujian itu saling mengintervensi satu sama lain saat dijalankan secara paralel. Salah satu solusinya adalah memastikan tiap pengujian menulis ke file yang berbeda-beda; solusi lainnya adalah menjalankan pengujian satu per satu.

Kalau kita tidak mau menjalankan pengujian secara paralel atau kalau kita mau kontrol yang lebih mendetail terhadap jumlah threads yang dipakai, kita bisa mengirim flag --test-threads beserta jumlah threads yang mau kita pakai ke test binary. Coba lihat contoh berikut:

$ cargo test -- --test-threads=1

Kita nge-set jumlah test threads jadi 1, memberi tahu program untuk tidak menggunakan paralelisme apa pun. Menjalankan pengujian pakai satu thread bakal makan waktu lebih lama ketimbang menjalankannya secara paralel, tapi pengujiannya tidak bakal saling mengintervensi kalau mereka membagikan state.

Menampilkan Output Fungsi

Secara default, kalau sebuah pengujian sukses, library pengujian Rust menangkap (captures) apa pun yang dicetak ke standard output. Misalnya, kalau kita memanggil println! di sebuah pengujian dan pengujian itu sukses, kita tidak bakal melihat output println! di terminal; kita cuma bakal melihat baris yang menandakan kalau pengujiannya sukses. Kalau pengujiannya gagal, barulah kita bakal melihat apa pun yang tadi dicetak ke standard output beserta sisa pesan kegagalannya.

Sebagai contoh, Listing 11-10 punya fungsi konyol yang mencetak nilai dari parameternya lalu mengembalikan nilai 10, serta punya sebuah pengujian yang sukses dan satu lagi pengujian yang gagal.

Filename: src/lib.rs
fn prints_and_returns_10(a: i32) -> i32 {
    println!("I got the value {a}");
    10
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn this_test_will_pass() {
        let value = prints_and_returns_10(4);
        assert_eq!(value, 10);
    }

    #[test]
    fn this_test_will_fail() {
        let value = prints_and_returns_10(8);
        assert_eq!(value, 5);
    }
}
Listing 11-10: Pengujian untuk fungsi yang memanggil println!

Saat kita menjalankan pengujian ini pakai cargo test, kita bakal melihat output berikut:

$ cargo test
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)

running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8

thread 'tests::this_test_will_fail' panicked at src/lib.rs:19:9:
assertion `left == right` failed
  left: 10
 right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Perhatikan bahwa di dalam output ini kita tidak melihat I got the value 4, yang dicetak saat pengujian yang sukses itu berjalan. Output tersebut sudah ditangkap (captured). Output dari pengujian yang gagal, I got the value 8, muncul di bagian ringkasan output pengujian, yang juga menunjukkan penyebab dari kegagalan pengujiannya.

Kalau kita mau melihat nilai yang dicetak dari pengujian yang sukses juga, kita bisa memberi tahu Rust buat juga menampilkan output dari pengujian yang sukses dengan --show-output:

$ cargo test -- --show-output

Saat kita menjalankan pengujian di Listing 11-10 lagi pakai flag --show-output, kita melihat output berikut:

$ cargo test -- --show-output
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)

running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok

successes:

---- tests::this_test_will_pass stdout ----
I got the value 4


successes:
    tests::this_test_will_pass

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8

thread 'tests::this_test_will_fail' panicked at src/lib.rs:19:9:
assertion `left == right` failed
  left: 10
 right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Menjalankan Sebagian Pengujian Berdasarkan Nama

Kadang-kadang, menjalankan test suite yang lengkap itu bisa makan waktu lama. Kalau kita lagi mengerjakan kode di area tertentu, kita mungkin mau menjalankan hanya pengujian yang berkaitan dengan kode tersebut aja. Kita bisa memilih pengujian mana yang mau dijalankan dengan mengirimkan nama atau nama-nama dari pengujian yang mau dijalankan sebagai argumen ke cargo test.

Buat mendemonstrasikan gimana cara menjalankan sebagian pengujian, pertama-tama kita bakal bikin tiga pengujian buat fungsi add_two kita, seperti yang ditunjukkan di Listing 11-11, lalu kita pilih mana yang mau dijalankan.

Filename: src/lib.rs
pub fn add_two(a: u64) -> u64 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn add_two_and_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }

    #[test]
    fn add_three_and_two() {
        let result = add_two(3);
        assert_eq!(result, 5);
    }

    #[test]
    fn one_hundred() {
        let result = add_two(100);
        assert_eq!(result, 102);
    }
}
Listing 11-11: Tiga pengujian dengan tiga nama yang berbeda

Kalau kita menjalankan pengujian tanpa mengirim argumen apa pun, seperti yang kita lihat sebelumnya, semua pengujian bakal berjalan secara paralel:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 3 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok
test tests::one_hundred ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Menjalankan Pengujian Tunggal

Kita bisa mengirimkan nama dari fungsi pengujian mana pun ke cargo test untuk menjalankan cuma pengujian itu saja:

$ cargo test one_hundred
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.69s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::one_hundred ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s

Hanya pengujian dengan nama one_hundred yang jalan; dua pengujian lainnya tidak cocok sama nama itu. Output pengujian memberi tahu kita kalau kita punya lebih banyak pengujian yang tidak dijalankan dengan menampilkan 2 filtered out di akhir.

Kita tidak bisa menentukan nama dari beberapa pengujian pakai cara ini; cuma nilai pertama yang diberikan ke cargo test aja yang bakal dipakai. Tapi ada cara buat menjalankan beberapa pengujian.

Menyaring untuk Menjalankan Beberapa Pengujian

Kita bisa menentukan sebagian dari nama pengujian, dan pengujian apa pun yang namanya cocok sama nilai tersebut bakal dijalankan. Misalnya, karena dua dari nama pengujian kita mengandung kata add, kita bisa menjalankan kedua pengujian itu dengan menjalankan cargo test add:

$ cargo test add
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

Perintah ini menjalankan semua pengujian yang punya add di namanya dan menyaring (filtered out) pengujian bernama one_hundred. Perhatikan juga bahwa modul tempat pengujian itu berada menjadi bagian dari nama pengujian tersebut, jadi kita bisa menjalankan semua pengujian di dalam sebuah modul dengan menyaringnya berdasarkan nama modul.

Mengabaikan Beberapa Pengujian Kecuali Diminta Secara Spesifik

Kadang-kadang ada beberapa pengujian spesifik yang sangat memakan waktu untuk dieksekusi, jadi kita mungkin mau mengecualikan pengujian-pengujian itu selama sebagian besar sesi cargo test. Ketimbang mendaftarkan semua pengujian yang ingin kita jalankan sebagai argumen, kita bisa menganotasi pengujian yang makan waktu tersebut dengan atribut ignore buat mengecualikannya, seperti yang ditunjukkan di sini:

Nama file: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    #[ignore]
    fn expensive_test() {
        // code that takes an hour to run
    }
}

Setelah #[test], kita menambahkan baris #[ignore] ke pengujian yang mau kita kecualikan. Sekarang pas kita menjalankan pengujian kita, it_works bakal jalan, tapi expensive_test tidak:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::expensive_test ... ignored
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Fungsi expensive_test didaftarkan sebagai ignored (diabaikan). Kalau kita mau menjalankan hanya pengujian yang diabaikan, kita bisa memakai cargo test -- --ignored:

$ cargo test -- --ignored
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::expensive_test ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Dengan mengontrol pengujian mana aja yang dijalankan, kita bisa memastikan hasil cargo test kita bakal keluar dengan cepat. Saat kita berada di titik di mana rasanya masuk akal untuk mengecek hasil dari pengujian yang ignored dan kita punya waktu buat menunggu hasilnya, kita bisa menjalankan cargo test -- --ignored alih-alih perintah biasa. Kalau kita mau menjalankan semua pengujian baik itu diabaikan atau tidak, kita bisa menjalankan cargo test -- --include-ignored.

Organisasi Test

Organisasi Pengujian

Seperti yang disebutkan di awal bab, pengujian adalah disiplin yang kompleks, dan orang-orang yang berbeda memakai istilah dan organisasi yang beda-beda juga. Komunitas Rust memikirkan pengujian dalam dua kategori utama: unit tests (pengujian unit) dan integration tests (pengujian integrasi). Unit tests itu kecil dan lebih fokus, menguji satu modul secara terisolasi pada satu waktu, dan bisa menguji antarmuka (interface) private. Integration tests itu sepenuhnya eksternal terhadap library kita dan memakai kode kita dengan cara yang sama persis seperti kode eksternal lainnya, hanya memakai antarmuka public dan berpotensi melibatkan beberapa modul per pengujian.

Menulis kedua jenis pengujian ini penting buat memastikan kalau bagian-bagian dari library kita melakukan apa yang kita harapkan, baik secara terpisah maupun bersama-sama.

Unit Tests

Tujuan dari unit tests adalah buat menguji setiap unit kode secara terisolasi dari sisa kode lainnya buat dengan cepat mencari tahu di mana kode kita berjalan sesuai harapan atau tidak. Kita bakal menaruh unit tests di dalam direktori src di setiap file bersama dengan kode yang lagi mereka uji. Konvensinya adalah membuat modul bernama tests di setiap file buat menampung fungsi-fungsi pengujian dan menganotasi modul itu dengan cfg(test).

Modul Tests dan #[cfg(test)]

Anotasi #[cfg(test)] pada modul tests memberi tahu Rust buat men-compile dan menjalankan kode pengujian hanya ketika kita menjalankan cargo test, dan tidak saat kita menjalankan cargo build. Ini menghemat waktu kompilasi (compile time) saat kita cuma mau mem-build library-nya dan menghemat ruang di dalam artefak hasil kompilasi (compiled artifact) karena pengujian-pengujiannya tidak dimasukkan. Nanti kita bakal lihat kalau integration tests itu berada di direktori yang berbeda, jadi mereka tidak butuh anotasi #[cfg(test)]. Tapi, karena unit tests berada di file yang sama dengan kodenya, kita bakal pakai #[cfg(test)] buat menentukan kalau mereka seharusnya tidak dimasukkan di hasil kompilasinya.

Ingat kembali waktu kita men-generate project adder baru di bagian pertama bab ini, Cargo men-generate kode ini buat kita:

Nama file: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

Pada modul tests yang di-generate otomatis, atribut cfg singkatan dari configuration (konfigurasi) dan ngasih tahu Rust kalau item berikutnya hanya boleh dimasukkan jika ada opsi konfigurasi tertentu. Di kasus ini, opsi konfigurasinya adalah test, yang disediakan oleh Rust buat men-compile dan menjalankan pengujian. Dengan memakai atribut cfg, Cargo men-compile kode pengujian kita cuma kalau kita secara aktif menjalankan pengujiannya dengan cargo test. Ini termasuk fungsi bantuan (helper functions) apa pun yang mungkin ada di dalam modul ini, selain fungsi-fungsi yang dianotasi dengan #[test].

Menguji Fungsi Private

Ada perdebatan di komunitas pengujian tentang apakah fungsi private (privat) sebaiknya diuji secara langsung atau tidak, dan bahasa pemrograman lain membuat pengujian fungsi private jadi hal yang susah atau mustahil. Terlepas dari ideologi pengujian mana yang kita anut, aturan privasi Rust memang memperbolehkan kita untuk menguji fungsi private. Coba perhatikan kode di Listing 11-12 yang punya fungsi private internal_adder.

Filename: src/lib.rs
pub fn add_two(a: u64) -> u64 {
    internal_adder(a, 2)
}

fn internal_adder(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn internal() {
        let result = internal_adder(2, 2);
        assert_eq!(result, 4);
    }
}
Listing 11-12: Menguji fungsi private

Perhatikan bahwa fungsi internal_adder tidak ditandai sebagai pub. Pengujian itu pada dasarnya cuma kode Rust biasa, dan modul tests itu cuma modul biasa juga. Seperti yang kita bahas di “Paths untuk Merujuk ke sebuah Item di Pohon Modul”, items di dalam modul anak (child modules) bisa memakai items di dalam modul leluhurnya (ancestor modules). Di pengujian ini, kita membawa semua items dari induk modul tests ke dalam scope dengan use super::*, lalu pengujian ini bisa memanggil internal_adder. Kalau kita ngerasa fungsi private itu tidak perlu diuji, tidak ada hal di Rust yang bakal memaksa kita buat ngelakuin itu.

Integration Tests

Di Rust, integration tests (pengujian integrasi) itu sepenuhnya eksternal terhadap library kita. Mereka memakai library kita dengan cara yang persis sama seperti kode eksternal lainnya, yang artinya mereka cuma bisa memanggil fungsi-fungsi yang merupakan bagian dari API public library kita. Tujuannya adalah buat menguji apakah banyak bagian dari library kita bekerja sama dengan benar. Unit-unit kode yang berjalan dengan benar saat sendirian bisa saja punya masalah pas digabungkan (integrated), jadi cakupan pengujian (test coverage) pada kode yang tergabung itu juga penting. Buat bikin integration tests, kita pertama-tama harus punya direktori tests.

Direktori tests

Kita membuat direktori tests di tingkat teratas (top level) direktori project kita, bersebelahan dengan src. Cargo tahu dia harus mencari file-file integration test di direktori ini. Kita kemudian bisa membuat sebanyak apa pun file pengujian yang kita mau, dan Cargo bakal men-compile tiap file tersebut sebagai sebuah crate individu.

Mari kita bikin sebuah integration test. Dengan kode di Listing 11-12 yang masih ada di file src/lib.rs, buat sebuah direktori tests, dan bikin file baru bernama tests/integration_test.rs. Struktur direktori kita seharusnya jadi seperti ini:

adder
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    └── integration_test.rs

Masukkan kode di Listing 11-13 ke dalam file tests/integration_test.rs.

Filename: tests/integration_test.rs
use adder::add_two;

#[test]
fn it_adds_two() {
    let result = add_two(2);
    assert_eq!(result, 4);
}
Listing 11-13: Sebuah integration test dari sebuah fungsi di dalam crate adder

Tiap file di dalam direktori tests itu adalah sebuah crate terpisah, jadi kita perlu membawa library kita ke dalam scope masing-masing test crate. Oleh karena itu kita menambahkan use adder::add_two; di paling atas kode kita, yang mana tidak kita perlukan di unit tests.

Kita tidak perlu menganotasi kode apa pun di tests/integration_test.rs dengan #[cfg(test)]. Cargo memperlakukan direktori tests secara spesial dan men-compile file-file di direktori ini cuma kalau kita menjalankan cargo test. Jalankan cargo test sekarang:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.31s
     Running unittests src/lib.rs (target/debug/deps/adder-1082c4b063a8fbe6)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-1082c4b063a8fbe6)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Tiga bagian dari output ini mencakup unit tests, integration test, dan doc tests. Perhatikan bahwa kalau ada pengujian di sebuah bagian yang gagal, bagian-bagian selanjutnya tidak bakal dijalankan. Misalnya, kalau ada unit test yang gagal, tidak bakal ada output buat integration tests atau doc tests karena pengujian-pengujian itu baru bakal jalan kalau semua unit tests sukses.

Bagian pertama untuk unit tests itu sama dengan yang selama ini kita lihat: satu baris buat setiap unit test (satu bernama internal yang kita tambahkan di Listing 11-12) dan kemudian satu baris ringkasan buat unit tests tersebut.

Bagian integration tests dimulai dengan baris Running tests/integration_test.rs. Setelah itu, ada satu baris buat setiap fungsi pengujian di dalam integration test itu dan satu baris ringkasan buat hasil dari integration test tepat sebelum bagian Doc-tests adder dimulai.

Tiap file integration test punya bagiannya masing-masing, jadi kalau kita nambahin lebih banyak file di direktori tests, bakal ada lebih banyak bagian integration test.

Kita masih bisa menjalankan fungsi integration test tertentu dengan menentukan nama fungsi pengujian itu sebagai argumen di cargo test. Buat menjalankan semua pengujian di dalam file integration test tertentu, kita bisa memakai argumen --test di cargo test diikuti dengan nama filenya:

$ cargo test --test integration_test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.64s
     Running tests/integration_test.rs (target/debug/deps/integration_test-82e7799c1bc62298)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Perintah ini hanya menjalankan pengujian-pengujian yang ada di file tests/integration_test.rs.

Submodul dalam Integration Tests

Saat kita menambahkan lebih banyak integration tests, kita mungkin mau membuat lebih banyak file di dalam direktori tests untuk membantu mengaturnya; misalnya, kita bisa mengelompokkan fungsi-fungsi pengujian berdasarkan fungsionalitas yang lagi mereka uji. Seperti yang disebutkan sebelumnya, setiap file di direktori tests di-compile sebagai crate-nya sendiri-sendiri secara terpisah, yang mana sangat berguna buat membuat scopes yang terpisah demi meniru dengan lebih dekat gimana para end users bakal memakai crate kita. Namun, ini artinya file-file di direktori tests tidak berbagi perilaku yang sama dengan file-file di direktori src, seperti yang kita pelajari di Bab 7 soal gimana memisahkan kode jadi berbagai modul dan file.

Perbedaan perilaku dari file-file di direktori tests ini paling terlihat saat kita punya sekumpulan fungsi bantuan (helper functions) buat dipakai di berbagai file integration test lalu kita mencoba mengikuti langkah-langkah di bagian “Memisahkan Modul ke dalam Berbagai File” di Bab 7 buat mengekstrak fungsi-fungsi itu ke modul bersama (common module). Misalnya, kalau kita membuat tests/common.rs dan menaruh fungsi bernama setup di dalamnya, kita bisa menambahkan beberapa kode ke setup yang mau kita panggil dari beberapa fungsi pengujian yang ada di file-file pengujian yang berbeda:

Nama file: tests/common.rs

pub fn setup() {
    // setup code specific to your library's tests would go here
}

Saat kita menjalankan pengujiannya lagi, kita bakal melihat bagian baru di output pengujiannya buat file common.rs, meskipun file ini sama sekali tidak mengandung fungsi pengujian maupun kita memanggil fungsi setup dari mana pun:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.89s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/common.rs (target/debug/deps/common-92948b65e88960b4)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-92948b65e88960b4)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Munculnya common di hasil pengujian dengan pesan running 0 tests yang ditampilkan buat modul itu bukanlah apa yang kita mau. Kita cuma mau membagikan sedikit kode dengan file-file integration test yang lain. Buat mencegah common muncul di output pengujian, alih-alih membuat tests/common.rs, kita bakal membuat tests/common/mod.rs. Direktori project-nya sekarang bakal kelihatan seperti ini:

├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── common
    │   └── mod.rs
    └── integration_test.rs

Ini adalah konvensi penamaan versi lama yang juga dipahami oleh Rust yang sudah kita sebutkan di bagian “Alternate File Paths” di Bab 7. Menamai file dengan cara ini memberi tahu Rust untuk tidak memperlakukan modul common sebagai file integration test. Saat kita memindahkan kode fungsi setup ke dalam tests/common/mod.rs dan menghapus file tests/common.rs, bagian khusus buat modul ini di output pengujian tidak akan muncul lagi. File-file yang ada di dalam subdirektori dari direktori tests tidak akan di-compile sebagai crate yang terpisah maupun mendapatkan bagian khususnya sendiri di output pengujian.

Setelah kita membuat tests/common/mod.rs, kita bisa memakainya dari file integration test mana pun layaknya sebuah modul. Berikut ini contoh memanggil fungsi setup dari pengujian it_adds_two yang ada di tests/integration_test.rs:

Nama file: tests/integration_test.rs

use adder::add_two;

mod common;

#[test]
fn it_adds_two() {
    common::setup();

    let result = add_two(2);
    assert_eq!(result, 4);
}

Perhatikan bahwa deklarasi mod common; itu sama dengan deklarasi modul yang sudah kita demonstrasikan di Listing 7-21. Kemudian, di dalam fungsi pengujiannya, kita bisa memanggil fungsi common::setup().

Integration Tests buat Binary Crates

Kalau project kita adalah sebuah binary crate yang hanya mengandung satu file src/main.rs dan tidak punya file src/lib.rs, kita tidak bisa bikin integration tests di direktori tests yang mencoba membawa fungsi-fungsi yang didefinisikan di src/main.rs ke dalam scope dengan memakai statement use. Cuma library crates yang mengekspos fungsi-fungsi buat bisa dipakai oleh crates lain; binary crates itu dimaksudkan untuk berjalan sendiri.

Inilah salah satu alasan kenapa project-project Rust yang menyediakan sebuah binary biasanya punya file src/main.rs yang lumayan simpel dan langsung memanggil logika yang berada di dalam file src/lib.rs. Dengan struktur itu, integration tests bisa menguji library crate dengan perintah use buat membuat fungsionalitas utamanya jadi tersedia buat dites. Kalau fungsionalitas utamanya bekerja dengan baik, sebagian kecil kode yang ada di file src/main.rs itu bakal ikut bekerja dengan baik juga, dan bagian kecil kode itu tidak perlu diuji lagi secara terpisah.

Ringkasan

Fitur pengujian Rust ngasih kita cara buat menentukan dengan spesifik gimana kode kita seharusnya berfungsi untuk memastikan kode itu terus berjalan sesuai yang kita harapkan, bahkan ketika kita membuat berbagai perubahan nanti. Unit tests mencoba bagian-bagian yang berbeda dari sebuah library secara terpisah dan bisa menguji detail implementasi private. Integration tests mengecek apakah banyak bagian dari library itu bekerja sama dengan benar, dan mereka memakai API public dari library itu buat menguji kodenya dengan cara yang persis sama dengan bagaimana kode eksternal bakal memakainya. Walaupun sistem tipe (type system) Rust dan aturan ownership ngebantu mencegah beberapa jenis bugs, pengujian tetaplah penting buat ngurangin logic bugs (kutu logika) yang berkaitan dengan bagaimana kode kita seharusnya berperilaku.

Mari kita gabungin ilmu yang udah kita pelajarin di bab ini dan di bab-bab sebelumnya buat ngerjain sebuah project bareng!

Project I/O: Bikin Program Command Line

Bab ini adalah rangkuman dari banyak skill yang udah kita pelajari sejauh ini dan sebuah eksplorasi ke beberapa fitur standard library lainnya. Kita bakal bikin alat (tool) command line yang berinteraksi sama file dan input/output dari command line buat melatih beberapa konsep Rust yang sekarang udah kita kuasai.

Kecepatan, keamanan, output binary tunggal, dan dukungan lintas platform bikin Rust jadi bahasa yang ideal buat bikin alat command line, jadi buat project kita ini, kita bakal bikin versi kita sendiri dari alat pencarian command line klasik grep (globally search a regular expression and print). Di skenario penggunaan paling sederhana, grep mencari string tertentu di dalam file yang ditentukan. Buat ngelakuin itu, grep menerima path (jalur) file dan sebuah string sebagai argumennya. Lalu dia ngebaca file tersebut, nyari baris-baris di file itu yang mengandung argumen string tadi, terus mencetak baris-baris itu.

Di sepanjang jalan, kita bakal nunjukin gimana cara bikin alat command line kita memakai fitur terminal yang dipakai sama banyak alat command line lainnya. Kita bakal ngebaca nilai dari environment variable buat ngebolehin user mengonfigurasi perilaku alat kita. Kita juga bakal mencetak pesan error ke stream konsol standard error (stderr) bukannya standard output (stdout) sehingga, misalnya, user bisa me-redirect (mengalihkan) output yang sukses ke sebuah file tapi tetap bisa melihat pesan error di layar.

Salah satu anggota komunitas Rust, Andrew Gallant, udah bikin versi grep yang berfitur lengkap dan kenceng sekali, namanya ripgrep. Sebagai perbandingan, versi kita bakal lumayan sederhana, tapi bab ini bakal ngasih kita beberapa pengetahuan dasar yang kita butuhin buat paham project dunia nyata kayak ripgrep.

Project grep kita bakal nggabungin sejumlah konsep yang udah kita pelajari sejauh ini:

  • Mengorganisasi kode (Bab 7)
  • Memakai vectors dan strings (Bab 8)
  • Menangani errors (Bab 9)
  • Memakai traits dan lifetimes di tempat yang tepat (Bab 10)
  • Nulis pengujian (tests) (Bab 11)

Kita juga bakal ngenalin secara singkat closures, iterators, dan trait objects, yang bakal dibahas lebih detail di Bab 13 dan Bab 18.

Menerima Argumen Command Line

Menerima Argumen Command Line

Mari kita bikin project baru dengan cargo new, seperti biasa. Kita bakal menamakan project kita minigrep buat ngebedain dari alat grep yang mungkin udah ada di sistem kita.

$ cargo new minigrep
     Created binary (application) `minigrep` project
$ cd minigrep

Tugas pertama adalah bikin minigrep menerima dua argumen command line-nya: path file dan sebuah string yang mau dicari. Yakni, kita mau bisa menjalankan program kita pakai cargo run, dengan dua tanda hubung (hyphens) buat menandakan argumen berikutnya itu buat program kita bukannya buat cargo, sebuah string buat dicari, dan path ke file tempat nyarinya, kayak gini:

$ cargo run -- searchstring example-filename.txt

Saat ini, program yang di-generate sama cargo new belum bisa memproses argumen yang kita berikan. Beberapa libraries yang udah ada di crates.io bisa bantu nulis program yang menerima argumen command line, tapi karena kita baru aja belajar konsep ini, mari kita mengimplementasikan kemampuan ini sendiri.

Membaca Nilai Argumen

Biar minigrep bisa ngebaca nilai argumen command line yang kita masukkan ke dalamnya, kita butuh fungsi std::env::args yang disediakan di standard library Rust. Fungsi ini mengembalikan sebuah iterator dari argumen-argumen command line yang diberikan ke minigrep. Kita bakal membahas iterators secara lengkap di Bab 13. Buat sekarang, kita cuma perlu tahu dua detail soal iterators: iterators menghasilkan serangkaian nilai, dan kita bisa memanggil method collect pada sebuah iterator buat mengubahnya jadi sebuah collection (koleksi), kayak vector misalnya, yang berisi semua elemen yang dihasilkan sama iterator tersebut.

Kode di Listing 12-1 memungkinkan program minigrep kita ngebaca argumen command line apa pun yang diberikan ke dia, terus mengumpulkan nilai-nilainya ke dalam sebuah vector.

Filename: src/main.rs
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    dbg!(args);
}
Listing 12-1: Mengumpulkan argumen command line ke dalam sebuah vector dan mencetaknya

Pertama kita bawa modul std::env ke dalam scope pakai statement use biar kita bisa memakai fungsi args-nya. Perhatikan kalau fungsi std::env::args ini bersarang (nested) di dua level modul. Kayak yang udah kita bahas di Bab 7, di kasus di mana fungsi yang mau kita pakai bersarang di lebih dari satu modul, kita memilih buat membawa induk modulnya ke dalam scope ketimbang fungsinya langsung. Dengan melakukan hal itu, kita bisa dengan gampang memakai fungsi lain dari std::env. Hal ini juga mengurangi kebingungan (ambiguity) dibandingkan menambahkan use std::env::args lalu memanggil fungsinya dengan args doang, karena args gampang sekali disangka fungsi yang didefinisikan di modul saat ini.

Fungsi args dan Unicode Tidak Valid

Perhatikan bahwa std::env::args bakal panic kalau ada argumen yang mengandung karakter Unicode yang tidak valid. Kalau program kita perlu menerima argumen yang mengandung Unicode tidak valid, pakai std::env::args_os sebagai gantinya. Fungsi tersebut mengembalikan iterator yang menghasilkan nilai OsString bukannya nilai String. Kita memilih memakai std::env::args di sini biar simpel karena nilai OsString itu beda-beda di setiap platform dan lebih rumit buat dikerjain dibanding nilai String.

Di baris pertama dari main, kita memanggil env::args, lalu kita langsung memakai collect buat mengubah iterator itu jadi vector yang berisi semua nilai yang dihasilkan sama iterator-nya. Kita bisa memakai fungsi collect buat bikin berbagai jenis koleksi, jadi kita harus secara eksplisit menganotasi tipe dari args buat menentukan kalau kita mau sebuah vector berisi strings. Walaupun kita jarang sekali perlu menganotasi tipe di Rust, collect adalah salah satu fungsi yang sering sekali butuh anotasi karena Rust tidak bisa menebak jenis koleksi apa yang kita mau.

Terakhir, kita mencetak vector-nya pakai debug macro. Mari kita coba jalankan kodenya dulu tanpa argumen lalu dengan dua argumen:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/minigrep`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
]
$ cargo run -- needle haystack
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.57s
     Running `target/debug/minigrep needle haystack`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
    "needle",
    "haystack",
]

Perhatikan kalau nilai pertama di vector itu adalah "target/debug/minigrep", yaitu nama dari binary kita. Ini sesuai sama perilaku daftar argumen di bahasa C, membiarkan program memakai nama yang digunakan saat mereka dipanggil dalam eksekusinya. Sering kali praktis punya akses ke nama program kalau kita mau mencetaknya di dalam pesan atau ngubah perilaku program berdasarkan alias command line apa yang dipakai buat memanggil programnya. Tapi buat tujuan bab ini, kita abaikan saja itu dan cuma nyimpen dua argumen yang kita butuhin.

Menyimpan Nilai Argumen di dalam Variabel

Program kita saat ini sudah bisa mengakses nilai yang ditentukan sebagai argumen command line. Sekarang kita perlu menyimpan nilai dari kedua argumen itu di dalam variabel supaya kita bisa memakai nilainya di seluruh bagian program. Kita lakuin itu di Listing 12-2.

Filename: src/main.rs
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let file_path = &args[2];

    println!("Searching for {query}");
    println!("In file {file_path}");
}
Listing 12-2: Membuat variabel buat menampung argumen query (pencarian) dan argumen path (jalur) file

Seperti yang kita lihat saat kita mencetak vector tadi, nama program menempati nilai pertama di vector pada args[0], jadi kita mulai ngambil argumennya di indeks 1. Argumen pertama yang diterima minigrep adalah string yang lagi kita cari, jadi kita menaruh referensi ke argumen pertama tersebut di variabel query. Argumen kedua bakal jadi path file, jadi kita menaruh referensi ke argumen kedua tersebut di variabel file_path.

Kita sementara mencetak nilai dari variabel-variabel ini buat membuktikan kalau kodenya berjalan sesuai keinginan kita. Mari kita jalankan program ini lagi dengan argumen test dan sample.txt:

$ cargo run -- test sample.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep test sample.txt`
Searching for test
In file sample.txt

Mantap, programnya jalan! Nilai-nilai argumen yang kita butuhkan sudah disimpan ke dalam variabel yang tepat. Nanti kita bakal menambahkan sedikit error handling (penanganan error) buat menangani beberapa potensi situasi yang keliru, misalnya saat user tidak memberikan argumen apa pun; buat sekarang, kita abaikan dulu situasi itu dan lanjut bekerja menambahkan kemampuan membaca file.

Membaca sebuah File

Membaca File

Sekarang kita bakal menambahkan fungsionalitas untuk membaca file yang ditentukan di argumen file_path. Pertama kita butuh sebuah file contoh buat mengujinya: kita bakal memakai file dengan sedikit teks yang membentang di beberapa baris dan punya beberapa kata yang diulang. Listing 12-3 punya puisi Emily Dickinson yang bakal pas sekali! Buat sebuah file bernama poem.txt di tingkat root project kita, lalu masukkan puisi “I’m Nobody! Who are you?”

Filename: poem.txt
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!
Listing 12-3: Sebuah puisi dari Emily Dickinson jadi test case yang bagus.

Dengan teks yang sudah siap, edit src/main.rs dan tambahkan kode buat membaca file tersebut, seperti yang ditunjukkan di Listing 12-4.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    // --snip--
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let file_path = &args[2];

    println!("Searching for {query}");
    println!("In file {file_path}");

    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}
Listing 12-4: Membaca isi dari file yang ditentukan oleh argumen kedua

Pertama kita membawa bagian yang relevan dari standard library dengan statement use: kita butuh std::fs buat menangani file.

Di main, statement baru fs::read_to_string menerima file_path, membuka file tersebut, dan mengembalikan nilai bertipe std::io::Result<String> yang berisi konten filenya.

Setelah itu, kita kembali menambahkan statement println! sementara yang mencetak nilai dari contents setelah file dibaca, jadi kita bisa mengecek kalau programnya berfungsi dengan baik sejauh ini.

Mari kita jalankan kode ini dengan sembarang string sebagai argumen command line pertama (karena kita belum mengimplementasikan bagian pencariannya) dan file poem.txt sebagai argumen kedua:

$ cargo run -- the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

Mantap! Kodenya berhasil membaca dan kemudian mencetak isi dari filenya. Tapi kodenya punya beberapa kelemahan. Saat ini, fungsi main punya banyak tanggung jawab: umumnya, fungsi bakal lebih jelas dan gampang dipelihara (maintain) kalau tiap fungsi bertanggung jawab untuk satu ide saja. Masalah lainnya adalah kita belum menangani error sebaik yang kita bisa. Programnya masih kecil, jadi kelemahan-kelemahan ini belum jadi masalah besar, tapi seiring programnya makin besar, bakal lebih susah untuk memperbaikinya dengan rapi. Praktik yang bagus adalah mulai me-refactor sejak awal saat mengembangkan program karena bakal jauh lebih mudah untuk me-refactor jumlah kode yang lebih sedikit. Kita bakal melakukan itu selanjutnya.

Refactoring buat Meningkatkan Modularitas dan Error Handling

Refactoring untuk Meningkatkan Modularitas dan Penanganan Error

Untuk meningkatkan program kita, kita bakal memperbaiki empat masalah yang berkaitan dengan struktur program dan gimana program menangani potensi error. Pertama, fungsi main kita sekarang melakukan dua tugas: mengurai (parsing) argumen dan membaca file. Seiring berkembangnya program kita, jumlah tugas terpisah yang ditangani oleh fungsi main juga bakal meningkat. Saat sebuah fungsi mendapat lebih banyak tanggung jawab, fungsi itu jadi lebih sulit buat dipahami, lebih susah buat diuji, dan lebih susah buat diubah tanpa merusak salah satu bagiannya. Hal terbaik adalah memisahkan fungsionalitas sehingga tiap fungsi bertanggung jawab atas satu tugas saja.

Isu ini juga terikat ke masalah kedua: walaupun query dan file_path adalah variabel konfigurasi untuk program kita, variabel seperti contents dipakai buat menjalankan logika programnya. Semakin panjang main, semakin banyak variabel yang harus kita bawa ke dalam scope; semakin banyak variabel yang ada di scope, semakin susah untuk mengingat tujuan dari masing-masing variabel tersebut. Praktik terbaiknya adalah mengelompokkan variabel-variabel konfigurasi ke dalam satu struktur buat memperjelas tujuan mereka.

Masalah ketiga adalah kita memakai expect buat mencetak pesan error saat membaca file gagal, tapi pesan error-nya cuma mencetak Should have been able to read the file. Membaca file bisa gagal karena berbagai alasan: contohnya, file tersebut bisa saja tidak ada, atau kita mungkin tidak punya izin buat membukanya. Saat ini, apa pun situasinya, kita bakal mencetak pesan error yang sama persis buat semuanya, yang mana tidak ngasih informasi apa pun ke user!

Keempat, kita memakai expect buat menangani error secara berulang kali, dan kalau user menjalankan program kita tanpa memberikan argumen yang cukup, mereka bakal dapat error index out of bounds dari Rust yang tidak menjelaskan masalahnya dengan jelas. Bakal lebih baik kalau semua kode penanganan error (error-handling code) ada di satu tempat sehingga para maintainer di masa depan cuma punya satu tempat buat dicek kalau logika penanganan error-nya perlu diubah. Mengumpulkan semua kode penanganan error di satu tempat juga bakal memastikan kalau kita mencetak pesan yang bermakna bagi end users (pengguna akhir) kita.

Mari kita atasi empat masalah ini dengan me-refactor project kita.

Separation of Concerns (Pemisahan Kepentingan) untuk Binary Projects

Masalah organisasi dari mengalokasikan tanggung jawab untuk beberapa tugas ke dalam fungsi main itu umum terjadi di banyak binary projects. Sebagai hasilnya, banyak programmer Rust merasa berguna untuk memisahkan berbagai kepentingan dari sebuah program binary saat fungsi main mulai menjadi besar. Proses ini punya langkah-langkah berikut:

  • Pisahkan program kita jadi file main.rs dan file lib.rs, lalu pindahkan logika program kita ke lib.rs.
  • Selama logika penguraian (parsing) command line masih kecil, ia bisa tetap berada di dalam fungsi main.
  • Ketika logika penguraian command line mulai menjadi rumit, ekstrak logika itu dari fungsi main ke dalam fungsi atau tipe lain.

Tanggung jawab yang tersisa di fungsi main setelah proses ini seharusnya dibatasi hanya pada hal-hal berikut:

  • Memanggil logika penguraian command line beserta nilai-nilai argumennya
  • Menyiapkan konfigurasi apa pun lainnya
  • Memanggil fungsi run yang ada di lib.rs
  • Menangani error kalau fungsi run mengembalikan error

Pola ini adalah soal pemisahan kepentingan (separating concerns): main.rs menangani jalannya program dan lib.rs menangani semua logika dari tugas yang ada. Karena kita tidak bisa menguji fungsi main secara langsung, struktur ini memungkinkan kita untuk menguji semua logika program dengan memindahkannya ke luar dari fungsi main. Kode yang tersisa di fungsi main bakal cukup kecil untuk bisa diverifikasi kebenarannya hanya dengan membacanya. Mari kita rombak program kita dengan mengikuti proses ini.

Mengekstrak Parser Argumen

Kita bakal mengekstrak fungsionalitas untuk mengurai argumen ke dalam fungsi yang bakal dipanggil oleh main. Listing 12-5 menunjukkan permulaan baru dari fungsi main yang memanggil fungsi baru parse_config, yang bakal kita definisikan di src/main.rs.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let (query, file_path) = parse_config(&args);

    // --snip--

    println!("Searching for {query}");
    println!("In file {file_path}");

    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let file_path = &args[2];

    (query, file_path)
}
Listing 12-5: Mengekstrak fungsi parse_config dari main

Kita masih mengumpulkan argumen command line ke dalam sebuah vector, tapi alih-alih me-assign nilai argumen di indeks 1 ke variabel query dan nilai argumen di indeks 2 ke variabel file_path di dalam fungsi main, kita memberikan keseluruhan vector-nya ke fungsi parse_config. Fungsi parse_config ini kemudian menampung logika yang menentukan argumen mana yang masuk ke variabel mana dan mengembalikan nilai-nilainya kembali ke main. Kita tetap membuat variabel query dan file_path di main, tapi main tidak lagi punya tanggung jawab buat menentukan gimana korelasi antara argumen command line dan variabel-variabel tersebut.

Perombakan ini mungkin kelihatan agak berlebihan buat program kita yang masih kecil, tapi kita melakukan refactoring dalam langkah-langkah kecil yang bertahap. Setelah membuat perubahan ini, jalankan programnya lagi buat memverifikasi kalau penguraian argumennya masih berfungsi. Mengecek progres kita secara rutin itu hal yang baik, untuk membantu mengidentifikasi penyebab masalah ketika masalah itu muncul.

Mengelompokkan Nilai-nilai Konfigurasi

Kita bisa mengambil langkah kecil lainnya buat meningkatkan fungsi parse_config lebih jauh lagi. Saat ini, kita mengembalikan sebuah tuple, tapi kemudian kita langsung memecah tuple itu menjadi bagian-bagian individual lagi. Ini adalah tanda kalau kita mungkin belum punya abstraksi yang tepat.

Indikator lain yang menunjukkan ada ruang buat peningkatan adalah bagian config dari nama parse_config, yang menyiratkan kalau dua nilai yang kita kembalikan itu saling berhubungan dan keduanya adalah bagian dari satu nilai konfigurasi. Saat ini kita belum menyampaikan makna tersebut di dalam struktur datanya selain dengan mengelompokkan kedua nilai itu ke dalam tuple; kita bakal lebih baik menaruh kedua nilai itu ke dalam satu struct dan memberikan nama yang bermakna buat tiap field dari struct tersebut. Melakukan hal ini bakal mempermudah para maintainer kode ini di masa depan buat paham gimana berbagai nilai tersebut saling berhubungan dan apa tujuannya.

Listing 12-6 menunjukkan peningkatan buat fungsi parse_config.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = parse_config(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    // --snip--

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let file_path = args[2].clone();

    Config { query, file_path }
}
Listing 12-6: Me-refactor parse_config agar mengembalikan sebuah instance dari struct Config

Kita sudah menambahkan sebuah struct bernama Config yang didefinisikan punya field bernama query dan file_path. Signature dari parse_config sekarang menunjukkan kalau dia mengembalikan nilai Config. Di dalam body parse_config, di mana kita tadinya mengembalikan string slices yang merujuk pada nilai String di args, kita sekarang mendefinisikan Config agar menampung nilai String yang owned (dimiliki). Variabel args di main adalah pemilik dari nilai-nilai argumen tersebut dan dia hanya membiarkan fungsi parse_config meminjamnya (borrow), yang berarti kita bakal melanggar aturan borrowing Rust kalau Config mencoba mengambil ownership (kepemilikan) dari nilai-nilai yang ada di args.

Ada banyak cara yang bisa kita pakai buat mengelola data String ini; yang paling gampang, meskipun agak kurang efisien, adalah dengan memanggil method clone pada nilai-nilai tersebut. Ini bakal membuat salinan penuh dari datanya supaya instance Config tersebut bisa memilikinya, yang mana makan lebih banyak waktu dan memori dibandingkan cuma menyimpan referensi ke data string-nya. Tapi, meng-clone datanya juga bikin kode kita jadi sangat simpel karena kita tidak perlu pusing mengelola lifetimes dari referensi-referensinya; di situasi ini, mengorbankan sedikit performa buat mendapatkan kesederhanaan adalah sebuah trade-off (pertukaran) yang sepadan.

Pertukaran (Trade-Offs) dari Pemakaian clone

Ada kecenderungan di antara banyak programmer Rust buat menghindari pemakaian clone demi memperbaiki masalah ownership karena biaya runtime-nya. Di Bab 13, kita bakal belajar gimana cara memakai methods yang lebih efisien di tipe situasi seperti ini. Tapi buat sekarang, tidak masalah menyalin beberapa string demi bisa terus maju karena kita cuma bakal membuat salinan ini sekali saja dan path file serta string pencarian kita itu sangat kecil. Jauh lebih baik punya program yang bekerja walau sedikit tidak efisien daripada mencoba melakukan hyperoptimize (optimasi berlebihan) di kode pada percobaan pertama kita. Seiring kita jadi makin berpengalaman dengan Rust, bakal lebih gampang buat mulai dari solusi yang paling efisien, tapi buat sekarang, memanggil clone itu masih sangat bisa diterima.

Kita sudah meng-update main agar dia menaruh instance dari Config yang dikembalikan oleh parse_config ke dalam variabel bernama config, dan kita juga meng-update kode yang sebelumnya memakai variabel query dan file_path secara terpisah sehingga kini ia memakai field yang ada di struct Config.

Sekarang kode kita dengan lebih jelas menyampaikan kalau query dan file_path itu berhubungan dan tujuannya adalah buat mengkonfigurasi gimana program kita bakal berjalan. Kode apa pun yang memakai nilai-nilai ini tahu untuk mencari mereka di instance config di dalam field yang dinamai sesuai tujuannya.

Membuat Constructor buat Config

Sejauh ini, kita sudah mengekstrak logika yang bertanggung jawab buat mengurai argumen command line dari main dan menaruhnya di fungsi parse_config. Melakukan hal ini membantu kita melihat kalau nilai query dan file_path itu berhubungan, dan hubungan itu harus disampaikan di kode kita. Kita kemudian menambahkan struct Config untuk menamai tujuan terkait dari query dan file_path serta untuk bisa mengembalikan nama-nama dari nilai tersebut sebagai nama field struct dari fungsi parse_config.

Jadi sekarang karena tujuan dari fungsi parse_config adalah untuk membuat sebuah instance Config, kita bisa mengubah parse_config dari fungsi biasa menjadi sebuah fungsi bernama new yang dikaitkan (associated) dengan struct Config. Membuat perubahan ini bakal bikin kodenya jadi lebih idiomatik. Kita bisa membuat instance dari berbagai tipe di standard library, seperti String, dengan memanggil String::new. Mirip dengan itu, dengan mengubah parse_config menjadi sebuah fungsi new yang dikaitkan dengan Config, kita bakal bisa membuat instance dari Config dengan memanggil Config::new. Listing 12-7 menunjukkan perubahan yang perlu kita buat.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");

    // --snip--
}

// --snip--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}
Listing 12-7: Mengubah parse_config menjadi Config::new

Kita sudah meng-update main di tempat kita memanggil parse_config untuk memanggil Config::new sebagai gantinya. Kita sudah mengubah nama parse_config jadi new dan memindahkannya ke dalam blok impl, yang mengaitkan fungsi new ini dengan Config. Coba compile kode ini lagi buat memastikan kalau ini berfungsi.

Memperbaiki Penanganan Error

Sekarang kita bakal bekerja buat memperbaiki penanganan error (error handling) kita. Ingat kembali kalau mencoba mengakses nilai di dalam vector args di indeks 1 atau indeks 2 bakal bikin program mengalami panic kalau vector-nya berisi kurang dari tiga item. Coba jalankan programnya tanpa argumen apa pun; outputnya bakal kayak gini:

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

thread 'main' panicked at src/main.rs:27:21:
index out of bounds: the len is 1 but the index is 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Baris index out of bounds: the len is 1 but the index is 1 adalah pesan error yang ditujukan buat para programmer. Ini tidak bakal ngebantu end users (pengguna akhir) kita buat paham apa yang seharusnya mereka lakuin. Mari kita perbaiki itu sekarang.

Memperbaiki Pesan Error

Di Listing 12-8, kita menambahkan pengecekan di fungsi new yang bakal memverifikasi apakah panjang slice-nya cukup sebelum mengakses indeks 1 dan indeks 2. Kalau slice-nya tidak cukup panjang, programnya bakal mengalami panic dan menampilkan pesan error yang lebih bagus.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    // --snip--
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("not enough arguments");
        }
        // --snip--

        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}
Listing 12-8: Menambahkan pengecekan jumlah argumen

Kode ini mirip sama fungsi Guess::new yang kita tulis di Listing 9-13, di mana kita memanggil panic! saat argumen value berada di luar rentang nilai yang valid. Alih-alih mengecek sebuah rentang nilai di sini, kita mengecek apakah panjang dari args minimal adalah 3 dan sisa dari fungsi ini bisa beroperasi di bawah asumsi kalau kondisi ini sudah terpenuhi. Kalau args punya kurang dari tiga item, kondisi ini bakal bernilai true, dan kita memanggil macro panic! buat mengakhiri program seketika.

Dengan sedikit baris tambahan ini di new, mari kita jalankan programnya tanpa argumen lagi buat melihat seperti apa pesan error-nya sekarang:

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

thread 'main' panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Output ini lebih mendingan: kita sekarang punya pesan error yang masuk akal. Namun, kita juga punya informasi ekstra yang tidak mau kita kasih ke user kita. Mungkin teknik yang kita pakai di Listing 9-13 bukanlah teknik yang terbaik buat dipakai di sini: sebuah pemanggilan panic! lebih cocok buat mengatasi masalah pemrograman dibanding masalah pemakaian, seperti yang dibahas di Bab 9. Sebagai gantinya, kita bakal memakai teknik lain yang sudah kita pelajari di Bab 9—mengembalikan sebuah Result yang menandakan sukses atau error.

Mengembalikan Result Alih-Alih Memanggil panic!

Kita bisa mengembalikan sebuah nilai Result yang bakal mengandung sebuah instance Config kalau sukses dan bakal mendeskripsikan masalahnya kalau terjadi error. Kita juga bakal mengubah nama fungsinya dari new jadi build karena banyak programmer berharap kalau fungsi bernama new itu tidak bakal pernah gagal. Saat Config::build sedang berkomunikasi dengan main, kita bisa memakai tipe Result buat ngasih tahu kalau ada masalah. Kemudian kita bisa mengubah main agar dia mengubah varian Err jadi error yang lebih praktis buat user kita, tanpa teks-teks tambahan soal thread 'main' dan RUST_BACKTRACE yang disebabkan oleh pemanggilan panic!.

Listing 12-9 menunjukkan perubahan yang perlu kita buat pada nilai kembalian dari fungsi yang sekarang kita namakan Config::build ini beserta isi (body) fungsinya yang dibutuhkan buat mengembalikan Result. Perhatikan bahwa kode ini tidak bakal bisa di-compile sampai kita meng-update main juga, yang mana bakal kita lakukan di listing berikutnya.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}
Listing 12-9: Mengembalikan sebuah Result dari Config::build

Fungsi build kita mengembalikan sebuah Result dengan instance Config di kasus yang sukses dan string literal di kasus yang gagal. Nilai error kita bakal selalu berupa string literal yang punya lifetime 'static.

Kita sudah membuat dua perubahan di body fungsi tersebut: alih-alih memanggil panic! saat user tidak memberikan argumen yang cukup, kita sekarang mengembalikan sebuah nilai Err, dan kita sudah membungkus nilai kembalian Config di dalam sebuah Ok. Perubahan ini membuat fungsinya sesuai dengan signature tipe barunya.

Mengembalikan nilai Err dari Config::build memungkinkan fungsi main untuk menangani nilai Result yang dikembalikan dari fungsi build tersebut dan keluar dari proses dengan lebih rapi saat terjadi error.

Memanggil Config::build dan Menangani Error

Buat menangani kasus yang gagal (error) dan mencetak pesan yang user-friendly, kita perlu meng-update main untuk menangani Result yang dikembalikan oleh Config::build, seperti yang ditunjukkan di Listing 12-10. Kita juga bakal mengambil tanggung jawab untuk keluar dari alat command line dengan kode error bukan nol (nonzero error code) menjauh dari panic! dan malah mengimplementasikannya secara manual. Status keluar (exit status) bukan nol adalah sebuah konvensi untuk memberi tanda kepada proses yang memanggil program kita bahwa program kita telah keluar dengan state error.

Filename: src/main.rs
use std::env;
use std::fs;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}
Listing 12-10: Keluar dengan sebuah kode error kalau proses build Config gagal

Di listing ini, kita sudah memakai method yang belum pernah kita bahas secara mendetail: unwrap_or_else, yang didefinisikan pada Result<T, E> oleh standard library. Pemakaian unwrap_or_else memungkinkan kita buat mendefinisikan penanganan error kustom yang tidak menggunakan panic!. Kalau Result-nya adalah nilai Ok, method ini bakal berperilaku mirip seperti unwrap: ia mengembalikan nilai di dalam yang sedang dibungkus oleh Ok. Namun, kalau nilainya adalah sebuah nilai Err, method ini memanggil kode yang ada di dalam sebuah closure, yakni sebuah fungsi anonim yang kita definisikan dan kita kirimkan sebagai argumen ke unwrap_or_else. Kita bakal membahas closures lebih dalam di Bab 13. Buat sekarang, kita cuma perlu tahu kalau unwrap_or_else bakal meneruskan nilai yang ada di dalam Err, yang mana di kasus ini adalah string statis "not enough arguments" yang kita tambahkan di Listing 12-9, ke closure kita di dalam argumen err yang muncul di antara tanda garis vertikal (|). Kode di dalam closure tersebut kemudian bisa memakai nilai err itu saat dia berjalan.

Kita sudah menambahkan satu baris use baru buat membawa process dari standard library ke dalam scope. Kode di dalam closure yang bakal berjalan di kasus error ini cuma terdiri dari dua baris: kita mencetak nilai err lalu memanggil process::exit. Fungsi process::exit bakal menghentikan programnya secara instan dan mengembalikan angka yang diteruskan sebagai kode exit status. Hal ini mirip seperti penanganan berbasis panic! yang kita pakai di Listing 12-8, tapi kita tidak lagi dapat semua output ekstra. Mari kita coba:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments

Bagus! Output ini jauh lebih friendly (ramah) buat para user kita.

Mengekstrak Logika dari Fungsi main

Sekarang setelah kita selesai me-refactor penguraian (parsing) konfigurasi, mari kita beralih ke logika programnya. Seperti yang kita nyatakan di “Separation of Concerns (Pemisahan Kepentingan) untuk Binary Projects”, kita bakal mengekstrak sebuah fungsi bernama run yang bakal menampung semua logika yang saat ini ada di fungsi main yang tidak berkaitan dengan menyiapkan konfigurasi atau menangani error. Setelah kita selesai, fungsi main bakal ringkas dan gampang diverifikasi hanya dengan melihatnya, dan kita bakal bisa menulis pengujian untuk semua logika lainnya.

Listing 12-11 menunjukkan peningkatan kecil dan bertahap berupa mengekstrak sebuah fungsi run.

Filename: src/main.rs
use std::env;
use std::fs;
use std::process;

fn main() {
    // --snip--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) {
    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

// --snip--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}
Listing 12-11: Mengekstrak fungsi run yang mengandung sisa dari logika program

Fungsi run sekarang mengandung semua sisa logika dari main, mulai dari bagian membaca file. Fungsi run menerima instance Config sebagai argumennya.

Mengembalikan Error dari Fungsi run

Dengan sisa logika program yang sudah dipisahkan ke fungsi run, kita bisa meningkatkan penanganan error-nya, sama seperti yang kita lakukan dengan Config::build di Listing 12-9. Bukannya ngebiarin program kita mengalami panic dengan memanggil expect, fungsi run bakal mengembalikan Result<T, E> pas terjadi sesuatu yang salah. Hal ini bakal membiarkan kita buat mengonsolidasi (menyatukan) logika seputar penanganan error ke dalam main lebih jauh lagi dengan cara yang ramah buat user. Listing 12-12 menunjukkan perubahan yang perlu kita buat pada signature dan body dari run.

Filename: src/main.rs
use std::env;
use std::fs;
use std::process;
use std::error::Error;

// --snip--


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}
Listing 12-12: Mengubah fungsi run agar mengembalikan Result

Kita sudah membuat tiga perubahan yang signifikan di sini. Pertama, kita mengubah tipe kembalian (return type) dari fungsi run jadi Result<(), Box<dyn Error>>. Fungsi ini sebelumnya mengembalikan tipe unit, (), dan kita mempertahankan itu sebagai nilai yang dikembalikan pada kasus yang sukses (Ok).

Buat tipe error-nya, kita memakai trait object Box<dyn Error> (dan kita juga sudah membawa std::error::Error ke dalam scope dengan statement use di paling atas). Kita bakal membahas trait objects di Bab 18. Buat sekarang, cukup ketahui kalau Box<dyn Error> berarti fungsinya bakal mengembalikan sebuah tipe yang mengimplementasikan trait Error, tapi kita tidak usah menentukan dengan spesifik tipe apa nilai kembaliannya. Ini ngasih kita fleksibilitas buat mengembalikan nilai-nilai error yang mungkin bertipe beda-beda di kasus error yang berbeda-beda. Keyword dyn itu singkatan dari dynamic (dinamis).

Kedua, kita telah menghapus pemanggilan expect dan menggantinya dengan operator ?, seperti yang kita bicarakan di Bab 9. Bukannya melakukan panic! pas ada error, ? bakal mengembalikan nilai error dari fungsi saat ini agar si pemanggil fungsi yang menangani error-nya.

Ketiga, fungsi run kini mengembalikan nilai Ok pada kasus yang sukses. Kita sudah mendeklarasikan kalau tipe sukses dari fungsi run adalah () pada signature-nya, yang artinya kita perlu membungkus nilai tipe unit tersebut di dalam sebuah nilai Ok. Sintaks Ok(()) ini mungkin kelihatannya agak aneh pas awal-awal, tapi memakai () kayak gini adalah cara yang idiomatik buat menunjukkan kalau kita memanggil run murni hanya karena side effects (efek samping)-nya aja; ia tidak mengembalikan nilai yang kita perlukan.

Saat kita menjalankan kode ini, kode ini bakal berhasil di-compile tapi bakal menampilkan sebuah warning (peringatan):

$ cargo run -- the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
  --> src/main.rs:19:5
   |
19 |     run(config);
   |     ^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
   |
19 |     let _ = run(config);
   |     +++++++

warning: `minigrep` (bin "minigrep") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.71s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

Rust ngasih tahu kita kalau kode kita mengabaikan nilai Result, padahal nilai Result itu mungkin menunjukkan kalau terjadi sebuah error. Tapi kita tidak mengecek apakah memang terjadi error atau tidak, dan compiler mengingatkan kita kalau kita mungkin lupa menaruh kode penanganan error di sini! Mari kita bereskan masalah itu sekarang.

Menangani Error yang Dikembalikan oleh run di main

Kita bakal mengecek error dan menangani mereka memakai teknik yang mirip dengan yang kita pakai bersama Config::build di Listing 12-10, tapi dengan sedikit perbedaan:

Nama file: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

fn main() {
    // --snip--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

Kita memakai if let bukannya unwrap_or_else buat mengecek apakah run mengembalikan nilai Err dan memanggil process::exit(1) jika iya. Fungsi run tidak mengembalikan sebuah nilai yang mau kita unwrap seperti Config::build yang mengembalikan instance Config. Karena run mengembalikan () pada kasus yang sukses, kita cuma peduli pada mendeteksi terjadinya error, jadi kita tidak perlu unwrap_or_else untuk mengembalikan nilai yang tidak terbungkus, yang mana hasilnya cuma bakal ().

Body dari if let dan fungsi di unwrap_or_else itu sama persis di kedua kasus: kita mencetak error-nya lalu kita keluar dari program.

Memisahkan Kode ke dalam sebuah Library Crate

Project minigrep kita sudah kelihatan bagus sejauh ini! Sekarang kita bakal memisahkan file src/main.rs dan menaruh sebagian kode ke dalam file src/lib.rs. Dengan begitu, kita bisa menguji kodenya dan bisa punya file src/main.rs dengan lebih sedikit tanggung jawab.

Mari kita definisikan kode yang bertanggung jawab untuk pencarian teks di dalam src/lib.rs ketimbang di src/main.rs, yang mana hal ini bakal membiarkan kita (atau siapa pun yang memakai library minigrep kita) untuk memanggil fungsi pencarian dari lebih banyak konteks dibanding hanya lewat binary minigrep kita.

Pertama, mari definisikan signature fungsi search di src/lib.rs seperti yang ditunjukkan di Listing 12-13, dengan isi (body) fungsi yang memanggil macro unimplemented!. Kita bakal menjelaskan signature-nya lebih detail pas kita mengisi implementasinya.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}
Listing 12-13: Mendefinisikan fungsi search di src/lib.rs

Kita sudah memakai keyword pub di definisi fungsinya untuk menunjuk search sebagai bagian dari API public dari library crate kita. Sekarang kita sudah punya sebuah library crate yang bisa kita pakai dari binary crate kita dan yang bisa kita tes!

Sekarang kita perlu membawa kode yang didefinisikan di src/lib.rs ke dalam scope dari binary crate di src/main.rs lalu memanggilnya, seperti yang ditunjukkan di Listing 12-14.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

// --snip--
use minigrep::search;

fn main() {
    // --snip--
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

// --snip--


struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}
Listing 12-14: Memakai fungsi search dari library crate minigrep di dalam src/main.rs

Kita menambahkan baris use minigrep::search untuk membawa fungsi search dari library crate ke dalam scope dari binary crate. Lalu, di dalam fungsi run, alih-alih mencetak konten dari file tersebut, kita memanggil fungsi search dan meneruskan nilai config.query dan contents sebagai argumennya. Setelah itu, run bakal memakai for loop buat mencetak setiap baris yang dikembalikan oleh search yang cocok dengan kueri (query) pencariannya. Ini juga waktu yang tepat buat menghapus pemanggilan println! di dalam fungsi main yang tadi menampilkan kueri dan path file sehingga program kita cuma mencetak hasil pencariannya aja (kalau tidak ada error yang terjadi).

Perhatikan bahwa fungsi pencarian bakal mengumpulkan semua hasil ke dalam sebuah vector yang dikembalikannya sebelum terjadi pencetakan ke layar. Implementasi ini bisa jadi agak lambat untuk menampilkan hasil ketika mencari di file yang berukuran sangat besar karena hasilnya tidak langsung dicetak saat ditemukan; kita bakal mendiskusikan kemungkinan untuk memperbaiki ini dengan memakai iterators di Bab 13.

Huft! Kerjaan yang lumayan banyak ya, tapi kita udah nyiapin diri buat kesuksesan di masa depan. Sekarang jauh lebih gampang buat menangani error, dan kita sudah membuat kodenya jadi lebih modular. Mulai dari sini, hampir semua pekerjaan kita bakal dilakukan di src/lib.rs.

Mari kita manfaatkan modularitas yang baru kita dapatkan ini dengan melakukan sesuatu yang bakal susah sekali dilakuin sama kode kita yang lama tapi sangat gampang dengan kode yang baru: kita bakal nulis beberapa tests (pengujian)!

Menambah Fungsionalitas dengan Test Driven Development

Mengembangkan Fungsionalitas Library dengan Test-Driven Development

Sekarang setelah logika pencarian kita terpisah di src/lib.rs dari fungsi main, jauh lebih mudah untuk menulis pengujian buat fungsionalitas inti dari kode kita. Kita bisa memanggil fungsi secara langsung dengan berbagai argumen dan mengecek nilai kembaliannya tanpa perlu menjalankan binary kita dari command line.

Di bagian ini, kita bakal menambahkan logika pencarian ke program minigrep memakai proses test-driven development (TDD) dengan langkah-langkah berikut:

  1. Tulis sebuah pengujian yang gagal lalu jalankan buat memastikan kalau pengujian itu gagal dengan alasan yang kita harapkan.
  2. Tulis atau ubah kode secukupnya saja buat bikin pengujian baru itu sukses.
  3. Refactor (rombak) kode yang baru saja ditambahkan atau diubah dan pastikan pengujiannya tetap sukses.
  4. Ulangi lagi dari langkah 1!

Meskipun ini cuma salah satu dari sekian banyak cara buat nulis software, TDD bisa membantu mengarahkan desain kode. Menulis pengujian sebelum kita menulis kode yang bakal membuat pengujian itu sukses membantu mempertahankan test coverage (cakupan pengujian) yang tinggi di sepanjang proses.

Kita bakal menguji-kembangkan (test-drive) implementasi fungsionalitas yang nantinya bakal benar-benar melakukan pencarian string kueri di dalam isi file lalu menghasilkan daftar baris yang cocok dengan kueri tersebut. Kita bakal menambahkan fungsionalitas ini ke dalam sebuah fungsi bernama search.

Di src/lib.rs, kita bakal menambahkan modul tests dengan fungsi pengujiannya, seperti yang kita lakukan di Bab 11. Fungsi pengujian ini menentukan perilaku yang kita inginkan dari fungsi search: fungsi ini bakal menerima sebuah kueri dan teks tempat kita mencari, lalu ia cuma bakal mengembalikan baris-baris dari teks tersebut yang mengandung kuerinya. Listing 12-15 menunjukkan pengujian ini.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}

// --snip--

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-15: Membuat pengujian yang gagal buat fungsi search untuk fungsionalitas yang kita harapkan ada

Pengujian ini mencari string "duct". Teks yang lagi kita cari ada tiga baris, dan cuma satu yang mengandung "duct" (perhatikan bahwa tanda backslash setelah tanda kutip ganda pembuka memberi tahu Rust buat tidak menambahkan karakter newline alias baris baru di awal konten string literal ini). Kita menegaskan bahwa nilai yang dikembalikan oleh fungsi search cuma berisi baris yang kita harapkan.

Kalau kita menjalankan pengujian ini sekarang, ia bakal gagal karena macro unimplemented! mengalami panic dengan pesan “not implemented”. Mengikuti prinsip-prinsip TDD, kita bakal mengambil langkah kecil dengan menambahkan kode secukupnya agar pengujian ini tidak panic saat memanggil fungsi tersebut; kita lakukan dengan mendefinisikan fungsi search agar selalu mengembalikan vector kosong, seperti yang ditunjukkan di Listing 12-16. Kemudian pengujian ini seharusnya berhasil di-compile dan lalu gagal karena vector kosong tidak cocok dengan vector yang isinya baris "safe, fast, productive."

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-16: Mendefinisikan fungsi search secukupnya saja sehingga ia tidak panic saat dipanggil

Sekarang mari kita bahas kenapa kita perlu mendefinisikan lifetime 'a secara eksplisit di signature fungsi search dan memakai lifetime itu bersama argumen contents dan nilai kembaliannya. Ingat kembali di Bab 10 bahwa parameter lifetime menentukan lifetime argumen mana yang terhubung dengan lifetime nilai kembaliannya. Di kasus ini, kita mengindikasikan kalau vector yang dikembalikan seharusnya berisi string slices yang merujuk pada slices dari argumen contents (bukannya dari argumen query).

Dengan kata lain, kita memberi tahu Rust kalau data yang dikembalikan oleh fungsi search bakal hidup (live) selama data yang diteruskan ke dalam fungsi search lewat argumen contents. Ini penting! Data yang dirujuk oleh sebuah slice harus tetap valid agar referensinya juga ikut valid; kalau compiler mengasumsikan kalau kita lagi bikin string slices dari query bukannya contents, pengecekan keamanannya bakal dilakukan dengan tidak tepat.

Kalau kita kelupaan menganotasi lifetime dan mencoba men-compile fungsi ini, kita bakal dapat error ini:

$ cargo build
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
 --> src/lib.rs:1:51
  |
1 | pub fn search(query: &str, contents: &str) -> Vec<&str> {
  |                      ----            ----         ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `query` or `contents`
help: consider introducing a named lifetime parameter
  |
1 | pub fn search<'a>(query: &'a str, contents: &'a str) -> Vec<&'a str> {
  |              ++++         ++                 ++              ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep` (lib) due to 1 previous error

Rust tidak bisa tahu pasti dari kedua parameter ini mana yang kita butuhkan buat outputnya, jadi kita harus memberitahukannya secara eksplisit. Perhatikan bahwa teks bantuannya menyarankan buat menentukan parameter lifetime yang sama buat semua parameter beserta tipe outputnya, yang mana itu salah! Karena contents adalah parameter yang berisi semua teks kita dan kita mau mengembalikan bagian-bagian dari teks itu yang cocok, kita jadi tahu kalau cuma parameter contents yang seharusnya dihubungkan dengan nilai kembaliannya menggunakan sintaks lifetime.

Bahasa pemrograman lain biasanya tidak mengharuskan kita buat menghubungkan argumen dengan nilai kembalian di signature-nya, tapi praktik ini bakal terasa lebih mudah seiring berjalannya waktu. Kita mungkin mau membandingkan contoh ini dengan contoh-contoh di bagian “Memvalidasi Referensi dengan Lifetimes” di Bab 10.

Saat ini, pengujian kita gagal karena kita selalu mengembalikan vector kosong. Buat memperbaikinya dan mengimplementasikan search, program kita harus mengikuti langkah-langkah berikut:

  1. Iterasi melewati setiap baris dari konten.
  2. Mengecek apakah baris tersebut mengandung string kueri kita.
  3. Jika iya, tambahkan baris itu ke daftar nilai yang bakal kita kembalikan.
  4. Jika tidak, jangan lakukan apa-apa.
  5. Kembalikan daftar hasil yang cocok.

Mari kerjakan satu per satu setiap langkahnya, dimulai dari iterasi melewati baris-baris.

Iterasi Melewati Baris dengan Method lines

Rust punya method yang membantu sekali buat menangani iterasi baris per baris dari strings, dinamai dengan nama yang nyaman yaitu lines, yang bekerja seperti yang ditunjukkan di Listing 12-17. Perhatikan kalau kode ini belum bisa di-compile.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // do something with line
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-17: Iterasi melewati tiap baris di contents

Method lines mengembalikan sebuah iterator. Kita bakal membahas iterator lebih mendalam di Bab 13, tapi ingat kembali bahwa kita pernah melihat cara memakai iterator seperti ini di Listing 3-5, di mana kita memakai for loop dengan sebuah iterator buat menjalankan beberapa kode pada setiap item di dalam sebuah collection (koleksi).

Mencari Kueri di Setiap Baris

Berikutnya, kita bakal mengecek apakah baris saat ini mengandung string kueri kita. Untungnya, tipe string punya method pembantu (helper) bernama contains yang ngelakuin persis apa yang kita butuhkan! Tambahkan pemanggilan ke method contains di dalam fungsi search, seperti yang ditunjukkan di Listing 12-18. Perhatikan kalau kode ini masih belum bisa di-compile.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // do something with line
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-18: Menambahkan fungsionalitas buat ngecek apakah barisnya mengandung string di query

Saat ini, kita baru membangun fungsionalitasnya. Supaya kodenya bisa di-compile, kita perlu mengembalikan sebuah nilai dari body fungsi sesuai indikasi yang kita berikan di signature fungsi.

Menyimpan Baris yang Cocok

Buat menyelesaikan fungsi ini, kita butuh cara buat menyimpan baris-baris yang cocok yang mau kita kembalikan. Buat melakukan itu, kita bisa membuat vector mutable sebelum for loop lalu memanggil method push buat menyimpan line ke dalam vector tersebut. Setelah for loop selesai, kita mengembalikan vector-nya, seperti yang ditunjukkan di Listing 12-19.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-19: Menyimpan baris-baris yang cocok supaya kita bisa mengembalikannya

Sekarang fungsi search seharusnya hanya mengembalikan baris-baris yang mengandung query, dan pengujian kita seharusnya sukses. Mari kita jalankan pengujiannya:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Pengujian kita sukses, jadi kita tahu kalau ini berfungsi!

Di titik ini, kita bisa mempertimbangkan peluang buat me-refactor implementasi dari fungsi pencarian sambil menjaga agar pengujiannya tetap sukses demi mempertahankan fungsionalitas yang sama. Kode di fungsi pencariannya tidak terlalu jelek sih, tapi dia tidak memanfaatkan beberapa fitur iterator yang sangat berguna. Kita bakal kembali lagi ke contoh ini di Bab 13, di mana kita bakal mengeksplorasi iterator lebih dalam, dan melihat gimana cara meningkatkannya.

Sekarang keseluruhan programnya seharusnya sudah bisa jalan! Mari kita coba, pertama dengan kata yang seharusnya cuma mengembalikan satu baris persis dari puisi Emily Dickinson: frog.

$ cargo run -- frog poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/minigrep frog poem.txt`
How public, like a frog

Keren! Sekarang mari kita coba pakai kata yang bakal cocok dengan beberapa baris sekaligus, misalnya body:

$ cargo run -- body poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!

Dan terakhir, mari kita pastikan kalau kita tidak dapat baris apa pun ketika kita mencari kata yang sama sekali tidak ada di dalam puisinya, misalnya monomorphization:

$ cargo run -- monomorphization poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep monomorphization poem.txt`

Luar biasa! Kita sudah membangun versi mini kita sendiri dari alat klasik dan belajar banyak hal soal cara menata aplikasi. Kita juga belajar sedikit tentang input dan output file, lifetimes, pengujian, dan penguraian command line.

Buat melengkapi project ini, kita bakal mendemonstrasikan secara singkat gimana cara berurusan dengan environment variables (variabel lingkungan) dan gimana cara mencetak pesan ke standard error, yang mana keduanya sangat berguna pas kita lagi nulis program command line.

Bekerja dengan Variabel Lingkungan (Environment Variables)

Berurusan dengan Environment Variables

Kita bakal meningkatkan binary minigrep dengan menambahkan fitur tambahan: opsi pencarian case-insensitive (tidak membedakan huruf besar/kecil) yang bisa dinyalakan oleh user via environment variable (variabel lingkungan). Kita bisa saja bikin fitur ini jadi opsi di command line dan mewajibkan user buat memasukkannya setiap kali mereka mau fitur itu aktif, tapi dengan menjadikannya environment variable, kita membiarkan para user untuk menge-set environment variable-nya sekali saja dan semua pencarian mereka bakal jadi case-insensitive selama sesi terminal (terminal session) tersebut.

Pertama-tama kita menambahkan fungsi search_case_insensitive baru ke library minigrep yang bakal dipanggil pas environment variable-nya punya nilai. Kita bakal terus ngikutin proses TDD, jadi langkah pertamanya adalah kembali menulis pengujian yang gagal. Kita bakal menambahkan pengujian baru buat fungsi baru search_case_insensitive ini lalu me-rename pengujian lama kita dari one_result jadi case_sensitive buat memperjelas perbedaan di antara kedua pengujian ini, seperti yang ditunjukkan di Listing 12-20.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 12-20: Menambahkan pengujian baru yang gagal untuk fungsi case-insensitive yang mau kita tambahkan

Perhatikan bahwa kita juga sudah ngedit contents di pengujian lama. Kita menambahkan satu baris baru dengan teks "Duct tape." yang memakai huruf D kapital yang tidak boleh cocok dengan kueri "duct" ketika kita lagi mencari dengan metode pencarian yang case-sensitive. Ngubah pengujian lama dengan cara ini ngebantu kita memastikan kalau kita tidak bakal tidak sengaja merusak fungsionalitas pencarian case-sensitive yang sudah kita implementasikan. Pengujian ini seharusnya sukses sekarang dan bakal terus sukses saat kita ngerjain fungsi pencarian yang case-insensitive.

Pengujian baru buat pencarian yang case-insensitive ini memakai "rUsT" sebagai kuerinya. Di dalam fungsi search_case_insensitive yang bakal kita tambahkan nanti, kueri "rUsT" ini seharusnya cocok sama baris yang mengandung "Rust:" dengan huruf R kapital, dan juga cocok sama baris "Trust me." biarpun casing (huruf besar/kecil)-nya berbeda dari kueri aslinya. Ini adalah pengujian kita yang gagal, dan pengujian ini bakal gagal di-compile karena kita belum mendefinisikan fungsi search_case_insensitive. Kalau mau, silakan tambahkan implementasi kerangkanya yang selalu mengembalikan vector kosong, mirip kayak yang kita lakukan buat fungsi search di Listing 12-16 buat melihat pengujiannya berhasil di-compile lalu gagal.

Mengimplementasikan Fungsi search_case_insensitive

Fungsi search_case_insensitive, yang ditunjukkan di Listing 12-21, bakal mirip sekali sama fungsi search. Satu-satunya perbedaan adalah kita bakal mengubah query dan setiap line jadi huruf kecil (lowercase) agar tidak peduli apa casing dari argumen inputnya, mereka bakal punya casing yang sama pas kita mengecek apakah baris tersebut mengandung kuerinya.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 12-21: Mendefinisikan fungsi search_case_insensitive agar mengubah kueri dan baris teks jadi huruf kecil sebelum membandingkan keduanya

Pertama kita ngubah string query jadi huruf kecil lalu menyimpannya ke dalam variabel baru dengan nama yang sama, menimpa (shadowing) variabel query aslinya. Memanggil to_lowercase pada kueri ini diperlukan supaya tidak peduli apakah kueri dari user itu "rust", "RUST", "Rust", atau "rUsT", kita bakal memperlakukan kueri tersebut seolah-olah itu "rust" dan tidak mempedulikan casing-nya. Meskipun to_lowercase bakal menangani Unicode dasar, fungsi ini tidak 100 persen akurat. Kalau kita lagi bikin aplikasi sungguhan, kita bakal mau bekerja sedikit lebih jauh di sini, tapi bagian ini membahas tentang environment variables, bukan Unicode, jadi kita biarkan saja seperti ini buat sekarang.

Perhatikan bahwa query sekarang adalah tipe String bukannya string slice karena pemanggilan to_lowercase itu membikin data baru alih-alih merujuk ke data yang sudah ada. Katakanlah kuerinya itu "rUsT", sebagai contoh: string slice tersebut tidak mengandung karakter u atau t kecil yang bisa kita pakai, jadi kita harus mengalokasikan memori buat String baru yang mengandung "rust". Saat kita meneruskan query sebagai argumen ke method contains sekarang, kita perlu menambahkan ampersand (&) karena signature dari contains didefinisikan buat nerima string slice.

Selanjutnya, kita nambahin panggilan ke to_lowercase di setiap line buat mengubah semua karakternya jadi huruf kecil. Sekarang karena kita udah mengonversi baik line maupun query jadi huruf kecil, kita bakal menemukan baris yang cocok tidak peduli apa casing kuerinya.

Mari kita lihat apakah implementasi ini berhasil melewati pengujian:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.33s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 2 tests
test tests::case_insensitive ... ok
test tests::case_sensitive ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Mantap! Mereka sukses. Sekarang, mari kita panggil fungsi search_case_insensitive baru ini dari dalam fungsi run. Pertama kita bakal menambahkan opsi konfigurasi ke struct Config buat beralih antara metode pencarian case-sensitive dan case-insensitive. Nambahin field ini bakal menghasilkan error compiler karena kita belum menginisialisasi field ini di mana pun:

Nama file: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

// --snip--


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

Kita nambahin field ignore_case yang menampung sebuah Boolean. Selanjutnya, kita butuh fungsi run buat mengecek nilai dari field ignore_case lalu memakai nilai itu buat menentukan apakah harus memanggil fungsi search atau fungsi search_case_insensitive, seperti yang ditunjukkan di Listing 12-22. Ini masih belum bisa di-compile.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

// --snip--


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}
Listing 12-22: Memanggil search atau search_case_insensitive berdasarkan nilai dari config.ignore_case

Terakhir, kita perlu mengecek apakah environment variable-nya diset. Fungsi-fungsi buat berinteraksi dengan environment variables berada di dalam modul env di standard library, yang mana sudah berada di dalam scope di bagian paling atas src/main.rs. Kita bakal memakai fungsi var dari modul env buat mengecek dan melihat apakah ada nilai yang sudah diset buat environment variable bernama IGNORE_CASE, seperti yang ditunjukkan di Listing 12-23.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}
Listing 12-23: Mengecek jika ada nilai di environment variable yang bernama IGNORE_CASE

Di sini, kita membuat variabel baru, ignore_case. Buat menge-set nilainya, kita memanggil fungsi env::var dan meneruskan nama dari environment variable IGNORE_CASE kepadanya. Fungsi env::var mengembalikan sebuah Result yang bakal jadi varian sukses Ok yang berisi nilai dari environment variable tersebut jika ia telah diset dengan nilai apa pun. Ia bakal mengembalikan varian Err kalau environment variable-nya tidak diset.

Kita memakai method is_ok pada Result tersebut buat mengecek apakah environment variable-nya diset, yang berarti programnya seharusnya melakukan pencarian secara case-insensitive. Kalau environment variable IGNORE_CASE tidak diset sama sekali, is_ok bakal mengembalikan false dan programnya bakal melakukan pencarian case-sensitive. Kita tidak peduli dengan nilai dari environment variable-nya, yang penting dia diset atau tidak aja, makanya kita memakai is_ok dan tidak memakai unwrap, expect, atau method lain dari Result yang sudah kita lihat sebelumnya.

Kita meneruskan nilai di variabel ignore_case ke instance Config agar fungsi run bisa ngebaca nilai itu dan memutuskan apakah bakal memanggil search_case_insensitive atau search, seperti yang sudah kita implementasikan di Listing 12-22.

Mari kita coba! Pertama kita bakal jalankan program kita tanpa menge-set environment variable apa pun dan memakai kueri to, yang mana seharusnya cocok dengan baris mana pun yang mengandung kata to dalam huruf kecil semua:

$ cargo run -- to poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!

Sepertinya masih jalan dengan baik! Sekarang mari kita jalankan programnya dengan IGNORE_CASE diset ke 1 tapi dengan kueri to yang sama:

$ IGNORE_CASE=1 cargo run -- to poem.txt

Kalau kita pakai PowerShell, kita perlu menge-set environment variable-nya lalu menjalankan programnya sebagai dua command yang terpisah:

PS> $Env:IGNORE_CASE=1; cargo run -- to poem.txt

Ini bakal membikin IGNORE_CASE bertahan (persist) sepanjang sisa sesi shell kita. Ia bisa di-unset pakai cmdlet Remove-Item:

PS> Remove-Item Env:IGNORE_CASE

Kita seharusnya mendapatkan baris-baris yang mengandung to yang mungkin huruf-hurufnya kapital:

Are you nobody, too?
How dreary to be somebody!
To tell your name the livelong day
To an admiring bog!

Hebat, kita juga dapat baris yang mengandung To! Program minigrep kita sekarang bisa melakukan pencarian case-insensitive yang dikendalikan oleh sebuah environment variable. Sekarang kita tahu gimana caranya mengatur opsi yang diset melalui argumen command line maupun environment variables.

Beberapa program mengizinkan pemakaian argumen dan environment variables buat tujuan konfigurasi yang sama. Di kasus seperti itu, program-program tersebut harus memutuskan mana yang harus didahulukan (precedence). Sebagai latihan, cobalah mengatur opsi case sensitivity (kepekaan huruf besar/kecil) melalui argumen command line atau environment variable. Putuskan apakah argumen command line atau environment variable yang seharusnya lebih didahulukan kalau ternyata programnya dijalankan dengan salah satu opsi dijadikan case-sensitive sementara opsi lainnya diset untuk mengabaikan casing.

Modul std::env berisi banyak lagi fitur yang berguna buat berurusan dengan environment variables: cek dokumentasinya buat melihat fitur apa saja yang tersedia.

Mengarahkan Error ke Standard Error

Saat ini, kita menulis semua output ke terminal memakai macro println!. Di sebagian besar terminal, ada dua jenis output: standard output (stdout) buat informasi umum dan standard error (stderr) buat pesan-pesan error. Pemisahan ini memungkinkan para user untuk memilih buat mengarahkan (redirect) output yang sukses dari suatu program ke sebuah file, tapi tetap bisa mencetak pesan-pesan error ke layar.

Macro println! cuma bisa mencetak ke standard output, jadi kita harus pakai sesuatu yang lain buat bisa mencetak ke standard error.

Mengecek Ke Mana Error Ditulis

Pertama-tama mari kita amati gimana konten yang dicetak oleh minigrep saat ini ditulis ke standard output, termasuk pesan-pesan error apa pun yang sebenarnya mau kita tulis ke standard error. Kita bakal ngelakuin itu dengan mengarahkan stream standard output ke sebuah file sementara kita sengaja membikin sebuah error. Kita tidak akan mengarahkan stream standard error, jadi konten apa pun yang dikirim ke standard error bakal tetap ditampilkan di layar.

Program-program command line umumnya diharapkan bakal mengirim pesan error ke stream standard error supaya kita masih bisa melihat pesan-pesan error di layar bahkan kalau kita mengarahkan stream standard output ke sebuah file. Program kita saat ini belum berperilaku dengan baik: kita bakal segera melihat kalau dia malah menyimpan output pesan error-nya ke sebuah file!

Buat mendemonstrasikan perilaku ini, kita bakal menjalankan programnya dengan > dan path file, output.txt, ke mana kita mau mengarahkan stream standard output. Kita tidak akan memasukkan argumen apa pun, yang mana seharusnya bakal menyebabkan error:

$ cargo run > output.txt

Sintaks > memberi tahu shell (terminal) buat menulis konten dari standard output ke output.txt ketimbang menampilkannya ke layar. Kita ternyata tidak melihat pesan error yang kita harapkan tercetak di layar, jadi itu artinya pesan tersebut pasti berujung di dalam file itu. Inilah apa yang terkandung di dalam output.txt:

Problem parsing arguments: not enough arguments

Yup, pesan error kita benar-benar dicetak ke standard output. Bakal jauh lebih berguna kalau pesan-pesan error semacam ini dicetak ke standard error sehingga hanya data dari proses yang berjalan dengan sukses saja yang bakal berakhir di dalam file tersebut. Kita bakal mengubah hal itu.

Mencetak Error ke Standard Error

Kita bakal memakai kode di Listing 12-24 buat mengubah gimana pesan-pesan error dicetak. Karena perombakan (refactoring) yang kita lakuin sebelumnya di bab ini, semua kode yang mencetak pesan error ada di satu fungsi aja, yaitu main. Standard library menyediakan macro eprintln! yang bisa mencetak ke stream standard error, jadi mari ubah dua tempat di mana kita memakai println! buat mencetak error agar memakai eprintln! sebagai gantinya.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}
Listing 12-24: Menulis pesan error ke standard error bukannya standard output menggunakan eprintln!

Sekarang mari kita coba jalankan lagi programnya pakai cara yang sama, tanpa argumen apa pun dan mengarahkan standard output pakai >:

$ cargo run > output.txt
Problem parsing arguments: not enough arguments

Sekarang kita bisa melihat pesan error-nya di layar dan output.txt isinya kosong, yang mana ini persis seperti perilaku yang kita harapkan dari program-program command line.

Mari kita jalankan programnya sekali lagi, kali ini dengan argumen-argumen yang tidak bakal menyebabkan error tapi kita tetap mengarahkan standard output ke sebuah file, kayak gini:

$ cargo run -- to poem.txt > output.txt

Kita tidak akan melihat output apa pun di terminal, dan output.txt bakal berisi hasil-hasil pencarian kita:

Nama file: output.txt

Are you nobody, too?
How dreary to be somebody!

Ini mendemonstrasikan kalau sekarang kita sudah memakai standard output buat output yang sukses dan memakai standard error buat output yang berupa error sebagaimana mestinya.

Ringkasan

Bab ini merangkum (recap) beberapa konsep besar yang udah kita pelajarin sejauh ini dan membahas soal gimana caranya ngejalanin operasi I/O (Input/Output) umum di Rust. Dengan memakai argumen command line, file, environment variables, dan macro eprintln! buat mencetak error, kita sekarang sudah siap buat nulis berbagai aplikasi command line. Digabungkan dengan konsep-konsep dari bab-bab sebelumnya, kode kita bakal terorganisir dengan rapi, menyimpan data secara efektif di struktur data yang tepat, menangani error dengan apik, dan telah teruji dengan baik.

Berikutnya, kita bakal mengeksplorasi beberapa fitur di Rust yang dipengaruhi oleh bahasa pemrograman fungsional: closures dan iterators.

Fitur Bahasa Fungsional: Iterators dan Closures

Desain Rust mengambil inspirasi dari banyak bahasa dan teknik pemrograman yang sudah ada, dan salah satu pengaruh yang signifikan adalah functional programming (pemrograman fungsional). Pemrograman dengan gaya fungsional sering kali mencakup penggunaan fungsi sebagai nilai, yaitu dengan memasukkan fungsi sebagai argumen, mengembalikannya dari fungsi lain, menaruhnya ke variabel untuk dieksekusi nanti, dan seterusnya.

Di bab ini, kita tidak akan berdebat soal apa itu pemrograman fungsional atau apa yang bukan, tapi kita bakal membahas beberapa fitur Rust yang mirip dengan fitur-fitur di banyak bahasa pemrograman yang sering disebut sebagai bahasa fungsional.

Secara lebih spesifik, kita bakal membahas:

  • Closures, sebuah struktur mirip fungsi yang bisa kita simpan di dalam sebuah variabel
  • Iterators, sebuah cara buat memproses serangkaian elemen
  • Gimana cara memakai closures dan iterators buat meningkatkan project I/O yang kita buat di Bab 12
  • Performa dari closures dan iterators (bocoran: performa mereka lebih cepat dari yang mungkin kita bayangkan!)

Kita sebenarnya sudah membahas beberapa fitur Rust lainnya, seperti pattern matching dan enums, yang juga terpengaruh oleh gaya fungsional. Karena menguasai closures dan iterators adalah bagian penting dari menulis kode Rust yang idiomatik dan kencang, kita bakal mendedikasikan seluruh bab ini buat membahas mereka.

Closures

Closures: Fungsi Anonim yang Bisa Menangkap Lingkungannya

Closures di Rust adalah fungsi anonim (tanpa nama) yang bisa kita simpan di dalam sebuah variabel atau diteruskan sebagai argumen ke fungsi lain. Kita bisa membuat sebuah closure di satu tempat lalu memanggil closure tersebut di tempat lain untuk dievaluasi dalam konteks yang berbeda. Tidak seperti fungsi biasa, closures bisa menangkap (capture) nilai-nilai dari scope tempat mereka didefinisikan. Kita bakal mendemonstrasikan gimana fitur-fitur closure ini memungkinkan penggunaan ulang kode dan kustomisasi perilaku.

Menangkap Lingkungan Menggunakan Closures

Pertama-tama kita bakal meneliti gimana kita bisa memakai closures buat menangkap nilai dari lingkungan tempat mereka didefinisikan untuk dipakai nanti. Berikut skenarionya: sesekali, perusahaan kaos kita membagikan kaos edisi terbatas eksklusif kepada seseorang di mailing list kita sebagai bentuk promosi. Orang-orang di mailing list bisa secara opsional menambahkan warna favorit mereka ke profilnya. Kalau orang yang terpilih untuk dapat kaos gratis itu sudah menge-set warna favoritnya, dia bakal dapat kaos dengan warna itu. Tapi kalau orang tersebut belum menentukan warna favorit, dia bakal dapat warna apa pun yang saat itu stoknya paling banyak di perusahaan.

Ada banyak cara buat mengimplementasikan ini. Buat contoh ini, kita bakal memakai sebuah enum bernama ShirtColor yang punya varian Red (merah) dan Blue (biru) (kita membatasi jumlah warna yang ada biar simpel). Kita mewakili stok barang milik perusahaan dengan sebuah struct Inventory yang punya field bernama shirts yang berisi sebuah Vec<ShirtColor> yang merepresentasikan warna-warna kaos yang saat ini ada di stok. Method giveaway yang didefinisikan pada Inventory menerima preferensi warna kaos opsional dari pemenang kaos gratis, lalu mengembalikan warna kaos yang bakal didapat oleh orang tersebut. Persiapan ini ditunjukkan di Listing 13-1.

Filename: src/main.rs
#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor {
    Red,
    Blue,
}

struct Inventory {
    shirts: Vec<ShirtColor>,
}

impl Inventory {
    fn giveaway(&self, user_preference: Option<ShirtColor>) -> ShirtColor {
        user_preference.unwrap_or_else(|| self.most_stocked())
    }

    fn most_stocked(&self) -> ShirtColor {
        let mut num_red = 0;
        let mut num_blue = 0;

        for color in &self.shirts {
            match color {
                ShirtColor::Red => num_red += 1,
                ShirtColor::Blue => num_blue += 1,
            }
        }
        if num_red > num_blue {
            ShirtColor::Red
        } else {
            ShirtColor::Blue
        }
    }
}

fn main() {
    let store = Inventory {
        shirts: vec![ShirtColor::Blue, ShirtColor::Red, ShirtColor::Blue],
    };

    let user_pref1 = Some(ShirtColor::Red);
    let giveaway1 = store.giveaway(user_pref1);
    println!(
        "The user with preference {:?} gets {:?}",
        user_pref1, giveaway1
    );

    let user_pref2 = None;
    let giveaway2 = store.giveaway(user_pref2);
    println!(
        "The user with preference {:?} gets {:?}",
        user_pref2, giveaway2
    );
}
Listing 13-1: Skenario pembagian hadiah dari perusahaan kaos

Toko (store) yang didefinisikan di fungsi main punya dua kaos biru dan satu kaos merah yang tersisa untuk dibagikan dalam promosi edisi terbatas ini. Kita memanggil method giveaway untuk seorang user dengan preferensi kaos merah dan seorang user tanpa preferensi sama sekali.

Sekali lagi, kode ini bisa saja diimplementasikan dengan banyak cara, dan di sini, untuk fokus ke closures, kita cuma memakai konsep-konsep yang sudah kita pelajari, kecuali buat body dari method giveaway yang memakai sebuah closure. Di method giveaway, kita menerima preferensi user sebagai sebuah parameter bertipe Option<ShirtColor> lalu memanggil method unwrap_or_else pada user_preference. Method unwrap_or_else pada Option<T> didefinisikan oleh standard library. Method ini menerima satu argumen: sebuah closure tanpa argumen apa pun yang mengembalikan sebuah nilai bertipe T (tipe yang sama dengan yang disimpan di varian Some dari Option<T>, yang mana di kasus ini adalah ShirtColor). Kalau Option<T> itu adalah varian Some, unwrap_or_else bakal mengembalikan nilai dari dalam Some tersebut. Tapi kalau Option<T> adalah varian None, unwrap_or_else bakal memanggil closure-nya dan mengembalikan nilai yang dikembalikan oleh closure tersebut.

Kita menentukan ekspresi closure || self.most_stocked() sebagai argumen untuk unwrap_or_else. Ini adalah sebuah closure yang tidak menerima parameter apa pun (kalau closure-nya punya parameter, parameternya bakal muncul di antara dua garis vertikal). Body dari closure ini memanggil self.most_stocked(). Kita mendefinisikan closure-nya di sini, dan implementasi dari unwrap_or_else nanti bakal mengevaluasi closure ini kalau hasilnya memang dibutuhkan.

Menjalankan kode ini bakal mencetak output berikut:

$ cargo run
   Compiling shirt-company v0.1.0 (file:///projects/shirt-company)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/shirt-company`
The user with preference Some(Red) gets Red
The user with preference None gets Blue

Satu aspek yang menarik di sini adalah kita sudah meneruskan sebuah closure yang memanggil self.most_stocked() pada instance Inventory saat ini. Standard library tidak perlu tahu apa-apa tentang tipe Inventory atau ShirtColor yang kita definisikan, ataupun logika yang mau kita pakai di skenario ini. Closure ini menangkap sebuah referensi immutable ke instance Inventory self dan meneruskannya bersama kode yang kita tentukan ke method unwrap_or_else. Di sisi lain, fungsi biasa tidak bisa menangkap lingkungan mereka dengan cara seperti ini.

Inference dan Anotasi Tipe untuk Closure

Ada lebih banyak perbedaan antara fungsi biasa dan closures. Closures biasanya tidak mengharuskan kita untuk menganotasi tipe dari parameter atau nilai kembalian seperti yang diwajibkan oleh fungsi fn. Anotasi tipe diwajibkan pada fungsi karena tipe-tipe tersebut adalah bagian dari antarmuka eksplisit yang diekspos ke para pengguna fungsi tersebut. Mendefinisikan antarmuka ini secara kaku penting untuk memastikan bahwa semua orang sepakat mengenai tipe-tipe nilai yang dipakai dan dikembalikan oleh sebuah fungsi. Sebaliknya, closures tidak dipakai dalam antarmuka yang terekspos seperti itu: mereka disimpan di dalam variabel dan dipakai tanpa menamai mereka atau mengeksposnya ke pengguna library kita.

Closures biasanya berukuran pendek dan hanya relevan di dalam konteks yang sempit, bukan di sembarang skenario acak. Di dalam batasan konteks ini, compiler bisa menebak (infer) tipe-tipe parameternya dan tipe kembaliannya, mirip dengan bagaimana ia bisa menebak tipe dari sebagian besar variabel (walaupun ada kasus-kasus langka di mana compiler juga membutuhkan anotasi tipe untuk closure).

Sama halnya dengan variabel, kita bisa menambahkan anotasi tipe kalau kita mau meningkatkan kejelasan secara eksplisit walau akibatnya kode kita bakal sedikit lebih bertele-tele (verbose) dari yang sebenarnya diperlukan. Menganotasi tipe buat sebuah closure bakal kelihatan seperti definisi yang ditunjukkan di Listing 13-2. Di contoh ini, kita mendefinisikan sebuah closure dan menyimpannya di dalam sebuah variabel, bukannya mendefinisikan closure tersebut tepat di tempat kita meneruskannya sebagai argumen seperti yang kita lakukan di Listing 13-1.

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn generate_workout(intensity: u32, random_number: u32) {
    let expensive_closure = |num: u32| -> u32 {
        println!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
    };

    if intensity < 25 {
        println!("Today, do {} pushups!", expensive_closure(intensity));
        println!("Next, do {} situps!", expensive_closure(intensity));
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!(
                "Today, run for {} minutes!",
                expensive_closure(intensity)
            );
        }
    }
}

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(simulated_user_specified_value, simulated_random_number);
}
Listing 13-2: Menambahkan anotasi tipe opsional buat tipe parameter dan nilai kembalian di closure

Dengan menambahkan anotasi tipe, sintaks closures jadi kelihatan lebih mirip dengan sintaks fungsi biasa. Di sini, kita mendefinisikan sebuah fungsi yang menambahkan 1 ke parameternya dan sebuah closure yang punya perilaku yang sama, sebagai perbandingan. Kita sudah menambahkan sedikit spasi supaya bagian-bagian yang relevan sejajar. Ini mengilustrasikan gimana sintaks closure itu mirip dengan sintaks fungsi, kecuali di penggunaan garis vertikal (|) dan seberapa banyak sintaks yang sifatnya opsional:

fn  add_one_v1   (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x|             { x + 1 };
let add_one_v4 = |x|               x + 1  ;

Baris pertama menunjukkan sebuah definisi fungsi dan baris kedua menunjukkan definisi closure yang dianotasi secara penuh. Di baris ketiga, kita membuang anotasi tipe dari definisi closure. Di baris keempat, kita membuang kurung kurawal, yang mana jadi opsional karena body dari closure ini hanya punya satu ekspresi. Ini semua adalah definisi yang valid dan bakal menghasilkan perilaku yang sama saat mereka dipanggil. Baris add_one_v3 dan add_one_v4 mewajibkan closures tersebut untuk dievaluasi agar kodenya bisa di-compile, karena tipe-tipenya bakal ditebak berdasarkan gimana closures tersebut dipakai. Ini mirip dengan bagaimana let v = Vec::new(); membutuhkan entah anotasi tipe atau adanya nilai dengan tipe tertentu yang dimasukkan ke dalam Vec agar Rust bisa menebak tipenya.

Buat definisi closure, compiler bakal menebak satu tipe konkret untuk masing- masing parameternya dan juga untuk nilai kembaliannya. Misalnya, Listing 13-3 menunjukkan definisi closure singkat yang cuma mengembalikan nilai yang dia terima sebagai parameter. Closure ini sebenarnya tidak terlalu berguna kecuali buat tujuan contoh ini. Perhatikan bahwa kita tidak menambahkan anotasi tipe apa pun di definisinya. Karena tidak ada anotasi tipe, kita bisa memanggil closure ini dengan tipe apa pun, yang mana kita lakukan di sini dengan tipe String untuk panggilan pertama. Kalau kita kemudian mencoba memanggil example_closure dengan sebuah integer, kita bakal dapat error.

Filename: src/main.rs
fn main() {
    let example_closure = |x| x;

    let s = example_closure(String::from("hello"));
    let n = example_closure(5);
}
Listing 13-3: Mencoba memanggil sebuah closure yang tipenya masih ditebak memakai dua tipe yang berbeda

Compiler ngasih kita error ini:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
error[E0308]: mismatched types
 --> src/main.rs:5:29
  |
5 |     let n = example_closure(5);
  |             --------------- ^ expected `String`, found integer
  |             |
  |             arguments to this function are incorrect
  |
note: expected because the closure was earlier called with an argument of type `String`
 --> src/main.rs:4:29
  |
4 |     let s = example_closure(String::from("hello"));
  |             --------------- ^^^^^^^^^^^^^^^^^^^^^ expected because this argument is of type `String`
  |             |
  |             in this closure call
note: closure parameter defined here
 --> src/main.rs:2:28
  |
2 |     let example_closure = |x| x;
  |                            ^
help: try using a conversion method
  |
5 |     let n = example_closure(5.to_string());
  |                              ++++++++++++

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

Saat pertama kali kita memanggil example_closure memakai nilai String, compiler menebak kalau tipe dari x dan tipe kembalian dari closure itu adalah String. Tipe-tipe itu kemudian “terkunci” ke dalam closure di example_closure, dan kita bakal dapat type error (error tipe) saat kita mencoba memakai tipe yang berbeda dengan closure yang sama.

Menangkap Referensi atau Memindahkan Kepemilikan (Ownership)

Closures bisa menangkap nilai dari lingkungannya memakai tiga cara, yang mana berkorelasi langsung dengan tiga cara sebuah fungsi bisa menerima parameter: meminjam secara immutable, meminjam secara mutable, dan mengambil kepemilikan (taking ownership). Closure bakal memutuskan cara mana yang mau dipakai berdasarkan apa yang dilakukan oleh isi fungsinya terhadap nilai-nilai yang ditangkapnya.

Di Listing 13-4, kita mendefinisikan sebuah closure yang menangkap referensi immutable ke vector bernama list karena closure itu cuma butuh referensi immutable buat mencetak nilainya.

Filename: src/main.rs
fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    let only_borrows = || println!("From closure: {list:?}");

    println!("Before calling closure: {list:?}");
    only_borrows();
    println!("After calling closure: {list:?}");
}
Listing 13-4: Mendefinisikan dan memanggil sebuah closure yang menangkap sebuah referensi immutable

Contoh ini juga mengilustrasikan kalau sebuah variabel bisa diikat ke definisi sebuah closure, dan nanti kita bisa memanggil closure tersebut memakai nama variabel dan tanda kurung, seolah-olah nama variabel itu adalah nama sebuah fungsi.

Karena kita bisa punya banyak referensi immutable ke list di waktu yang bersamaan, list masih bisa diakses dari kode sebelum definisi closure, di antara definisi closure namun sebelum closure-nya dipanggil, dan setelah closure-nya dipanggil. Kode ini berhasil di-compile, jalan, dan mencetak:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
Before calling closure: [1, 2, 3]
From closure: [1, 2, 3]
After calling closure: [1, 2, 3]

Selanjutnya, di Listing 13-5, kita ngubah body closure-nya biar dia nambahin sebuah elemen ke vector list. Closure ini sekarang menangkap referensi mutable.

Filename: src/main.rs
fn main() {
    let mut list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    let mut borrows_mutably = || list.push(7);

    borrows_mutably();
    println!("After calling closure: {list:?}");
}
Listing 13-5: Mendefinisikan dan memanggil sebuah closure yang menangkap sebuah referensi mutable

Kode ini berhasil di-compile, jalan, dan mencetak:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
After calling closure: [1, 2, 3, 7]

Perhatikan bahwa sekarang tidak ada lagi println! di antara definisi dan pemanggilan closure borrows_mutably: ketika borrows_mutably didefinisikan, ia menangkap referensi mutable ke list. Kita tidak lagi menggunakan closure ini setelah ia dipanggil, jadi peminjaman mutable itu pun berakhir. Di antara definisi closure dan pemanggilannya, kita tidak diizinkan buat melakukan peminjaman immutable untuk mencetaknya karena peminjaman lain tidak diperbolehkan selama masih ada peminjaman mutable. Coba tambahkan println! di situ buat melihat pesan error apa yang bakal kita dapatkan!

Kalau kita mau memaksa closure buat mengambil ownership dari nilai yang dia pakai dari lingkungannya, biarpun isi closure-nya sebenarnya tidak mewajibkan ownership, kita bisa menambahkan keyword move sebelum daftar parameternya.

Teknik ini biasanya sangat berguna pas kita mau meneruskan sebuah closure ke thread baru untuk memindahkan datanya supaya ia dimiliki oleh thread baru tersebut. Kita bakal bahas threads dan kenapa kita mau menggunakannya secara mendetail di Bab 16 saat kita ngomongin soal konkurensi (concurrency), tapi buat sekarang, mari kita eksplor secara singkat cara bikin thread baru yang memakai sebuah closure yang membutuhkan keyword move. Listing 13-6 menampilkan kode dari Listing 13-4 yang diubah buat mencetak isi vector di sebuah thread baru, bukannya di thread utama (main thread).

Filename: src/main.rs
use std::thread;

fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    thread::spawn(move || println!("From thread: {list:?}"))
        .join()
        .unwrap();
}
Listing 13-6: Memakai move buat memaksa closure di thread baru untuk mengambil kepemilikan dari list

Kita membuat thread baru, sambil memberikan sebuah closure buat dijalankan sebagai argumen untuk thread tersebut. Isi closure-nya mencetak daftar list. Di Listing 13-4, closure tersebut cuma menangkap list dengan memakai referensi immutable karena itulah hak akses minimal yang dibutuhkan ke list buat mencetaknya. Di contoh ini, meskipun isi closure-nya masih cuma butuh referensi immutable, kita harus menentukan secara spesifik bahwa list seharusnya dipindahkan (moved) ke dalam closure dengan menaruh keyword move di awal definisi closure-nya. Kalau thread utamanya melakukan lebih banyak operasi sebelum memanggil join pada thread barunya, thread baru tersebut bisa saja selesai sebelum thread utamanya selesai, atau sebaliknya thread utama bisa saja selesai lebih dulu. Kalau thread utama masih mempertahankan ownership dari list tapi dia berakhir lebih dulu sebelum thread barunya dan men-drop (membuang) list tersebut, referensi immutable di thread baru bakal jadi tidak valid. Maka dari itu, compiler mewajibkan list untuk dipindahkan ke dalam closure yang diberikan ke thread baru supaya referensinya bakal dipastikan valid. Coba hapus keyword move-nya atau coba pakai variabel list di thread utama setelah closure itu didefinisikan buat melihat error compiler apa yang bakal muncul!

Memindahkan Nilai yang Ditangkap ke Luar Closures dan Traits Fn

Begitu sebuah closure sudah menangkap referensi atau mengambil kepemilikan dari sebuah nilai dari lingkungan tempat closure itu didefinisikan (yang artinya, hal itu memengaruhi apa yang dipindahkan ke dalam closure-nya), kode di dalam isi closure-nya bakal menentukan apa yang terjadi sama referensi atau nilai tersebut pas closure-nya dievaluasi nantinya (yang artinya, hal ini memengaruhi apa yang dipindahkan ke luar dari closure-nya).

Isi dari sebuah closure bisa ngelakuin mana aja dari hal-hal berikut: memindahkan nilai yang ditangkap ke luar dari closure, memutasi (mengubah) nilai yang ditangkap, tidak memindahkan atau memutasi nilainya, atau dari awal emang tidak menangkap apa pun dari lingkungannya.

Cara sebuah closure menangkap dan menangani nilai dari lingkungannya memengaruhi trait mana yang diimplementasikan oleh closure tersebut, dan traits adalah cara bagaimana fungsi dan struct bisa menentukan jenis closures apa yang bisa mereka terima. Closures bakal secara otomatis mengimplementasikan satu, dua, atau ketiga traits Fn ini secara aditif, tergantung dari gimana isi closure-nya menangani nilai-nilai tersebut:

  • FnOnce berlaku untuk closures yang bisa dipanggil sekali saja. Semua closures minimal mengimplementasikan trait ini karena semua closures itu bisa dipanggil. Closure yang memindahkan nilai yang ditangkap keluar dari isi kodenya cuma bakal mengimplementasikan FnOnce dan bukan trait Fn lainnya karena ia cuma bisa dipanggil satu kali.
  • FnMut berlaku untuk closures yang tidak memindahkan nilai yang ditangkap keluar dari isinya, tapi yang mungkin memutasi nilai-nilai tersebut. Closures jenis ini bisa dipanggil lebih dari sekali.
  • Fn berlaku untuk closures yang tidak memindahkan nilai yang ditangkap keluar dari isinya dan tidak memutasi nilai-nilai tersebut, serta berlaku juga buat closures yang memang tidak menangkap apa pun dari lingkungannya. Closures seperti ini bisa dipanggil lebih dari sekali tanpa memutasi lingkungannya, yang mana ini penting buat kasus-kasus seperti saat kita memanggil sebuah closure berkali-kali secara konruen (bersamaan).

Mari kita lihat definisi dari method unwrap_or_else pada Option<T> yang kita pakai di Listing 13-1:

impl<T> Option<T> {
    pub fn unwrap_or_else<F>(self, f: F) -> T
    where
        F: FnOnce() -> T
    {
        match self {
            Some(x) => x,
            None => f(),
        }
    }
}

Ingat kembali bahwa T adalah tipe generik yang merepresentasikan tipe dari nilai di dalam varian Some milik sebuah Option. Tipe T itu juga adalah tipe kembalian (return type) dari fungsi unwrap_or_else: kode yang memanggil unwrap_or_else pada sebuah Option<String>, misalnya, bakal mendapatkan sebuah String.

Selanjutnya, perhatikan bahwa fungsi unwrap_or_else juga punya parameter tipe generik tambahan F. Tipe F adalah tipe dari parameter bernama f, yang mana itu adalah closure yang kita kasih saat memanggil unwrap_or_else.

Trait bound yang ditentukan pada tipe generik F adalah FnOnce() -> T, yang berarti F harus bisa dipanggil sekali, tidak menerima argumen apa pun, dan mengembalikan sebuah T. Memakai FnOnce di trait bound ini mengekspresikan batasan bahwa unwrap_or_else cuma bakal memanggil f maksimal satu kali saja. Di dalam isi unwrap_or_else, kita bisa melihat kalau Option-nya itu Some, f tidak bakal dipanggil. Kalau Option-nya itu None, f bakal dipanggil sekali. Karena semua closures mengimplementasikan FnOnce, unwrap_or_else bisa menerima ketiga jenis closures dan dia fleksibel sekali.

Catatan: Kalau hal yang mau kita lakukan tidak mewajibkan kita menangkap nilai dari lingkungannya, kita bisa aja memakai nama sebuah fungsi sebagai ganti dari closure di tempat kita butuh sesuatu yang mengimplementasikan salah satu dari trait Fn. Contohnya, pada nilai Option<Vec<T>>, kita bisa manggil unwrap_or_else(Vec::new) buat mendapatkan vector baru yang kosong kalau nilainya adalah None. Compiler bakal secara otomatis mengimplementasikan trait Fn yang paling pas buat definisi fungsi tersebut.

Sekarang mari kita lihat method standard library sort_by_key, yang didefinisikan pada slices, buat melihat bedanya dari unwrap_or_else dan kenapa sort_by_key memakai FnMut dan bukannya FnOnce buat trait bound-nya. Closure ini menerima satu argumen berupa referensi ke item saat ini yang ada di slice yang lagi diperiksa, lalu mengembalikan nilai bertipe K yang bisa diurutkan (ordered). Fungsi ini berguna pas kita mau mengurutkan sebuah slice berdasarkan atribut spesifik dari setiap itemnya. Di Listing 13-7, kita punya daftar berisi instance Rectangle dan kita memakai sort_by_key buat mengurutkan mereka berdasarkan atribut width (lebar) mereka dari yang terkecil sampai terbesar.

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    list.sort_by_key(|r| r.width);
    println!("{list:#?}");
}
Listing 13-7: Memakai sort_by_key buat mengurutkan persegi panjang berdasarkan lebarnya

Kode ini mencetak:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/rectangles`
[
    Rectangle {
        width: 3,
        height: 5,
    },
    Rectangle {
        width: 7,
        height: 12,
    },
    Rectangle {
        width: 10,
        height: 1,
    },
]

Alasan kenapa sort_by_key didefinisikan buat menerima closure FnMut adalah karena method itu memanggil closure-nya berkali-kali: satu kali untuk setiap item di slice-nya. Closure |r| r.width tidak menangkap, memutasi, atau memindahkan apa pun keluar dari lingkungannya, jadi ia memenuhi syarat trait bound-nya.

Sebaliknya, Listing 13-8 menunjukkan contoh closure yang cuma mengimplementasikan trait FnOnce, karena dia memindahkan sebuah nilai ke luar dari lingkungannya. Compiler tidak bakal ngebiarin kita pakai closure ini dengan sort_by_key.

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    let mut sort_operations = vec![];
    let value = String::from("closure called");

    list.sort_by_key(|r| {
        sort_operations.push(value);
        r.width
    });
    println!("{list:#?}");
}
Listing 13-8: Mencoba memakai closure FnOnce bersama sort_by_key

Ini adalah cara aneh yang dibikin-bikin (dan tidak bisa jalan) buat mencoba menghitung berapa kali sort_by_key memanggil closure-nya saat ia mengurutkan list. Kode ini mencoba melakukan penghitungan ini dengan memasukkan (pushing) value—sebuah String dari lingkungan closure-nya—ke dalam vector sort_operations. Closure ini menangkap value lalu memindahkan value tersebut keluar dari closure dengan mentransfer kepemilikan (ownership) value ke vector sort_operations. Closure ini bisa dipanggil satu kali; mencoba memanggilnya untuk kedua kalinya tidak bakal berhasil karena value sudah tidak ada lagi di lingkungan closure-nya buat dimasukkan ke dalam sort_operations! Maka dari itu, closure ini cuma mengimplementasikan FnOnce. Pas kita mencoba men-compile kode ini, kita bakal dapat error yang bilang kalau value tidak bisa dipindahkan keluar dari closure karena closure-nya harus mengimplementasikan FnMut:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
error[E0507]: cannot move out of `value`, a captured variable in an `FnMut` closure
  --> src/main.rs:18:30
   |
15 |     let value = String::from("closure called");
   |         -----   ------------------------------ move occurs because `value` has type `String`, which does not implement the `Copy` trait
   |         |
   |         captured outer variable
16 |
17 |     list.sort_by_key(|r| {
   |                      --- captured by this `FnMut` closure
18 |         sort_operations.push(value);
   |                              ^^^^^ `value` is moved here
   |
help: consider cloning the value if the performance cost is acceptable
   |
18 |         sort_operations.push(value.clone());
   |                                   ++++++++

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

Error ini menunjuk ke baris di dalam isi closure yang memindahkan value keluar dari lingkungannya. Buat memperbaikinya, kita harus mengubah isi closure agar ia tidak memindahkan nilai-nilai keluar dari lingkungannya. Menyimpan sebuah variabel counter di lingkungan dan menambah nilainya dari dalam closure adalah cara yang jauh lebih masuk akal buat menghitung berapa kali closure tersebut dipanggil. Closure di Listing 13-9 berhasil jalan dengan sort_by_key karena ia cuma menangkap referensi mutable ke counter num_sort_operations dan makanya bisa dipanggil lebih dari sekali:

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    let mut num_sort_operations = 0;
    list.sort_by_key(|r| {
        num_sort_operations += 1;
        r.width
    });
    println!("{list:#?}, sorted in {num_sort_operations} operations");
}
Listing 13-9: Memakai closure FnMut bersama sort_by_key diperbolehkan

Traits Fn penting pas kita mendefinisikan atau memakai fungsi atau tipe yang memakai closures. Di bagian selanjutnya, kita bakal membahas iterators. Banyak method iterator yang menerima argumen closure, jadi tetap ingat detail-detail tentang closures ini ya saat kita lanjut belajar!

Memproses Serangkaian Item Memakai Iterator

Memproses Serangkaian Item dengan Iterators

Pola (pattern) iterator memungkinkan kita buat melakukan suatu tugas secara berurutan pada serangkaian item. Sebuah iterator bertanggung jawab atas logika untuk melakukan iterasi melewati setiap item dan menentukan kapan rangkaian tersebut sudah selesai. Saat kita memakai iterator, kita tidak perlu repot-repot mengimplementasikan ulang logika tersebut sendiri.

Di Rust, iterators itu lazy (malas), yang artinya mereka tidak punya efek apa-apa sampai kita memanggil method-method yang bakal mengonsumsi (consume) iterator itu untuk memakainya sampai habis. Sebagai contoh, kode di Listing 13-10 membuat sebuah iterator atas item-item di dalam vector v1 dengan memanggil method iter yang didefinisikan pada Vec<T>. Kode ini kalau berdiri sendiri tidak melakukan hal berguna apa pun.

Filename: src/main.rs
fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();
}
Listing 13-10: Membuat sebuah iterator

Iterator ini disimpan di dalam variabel v1_iter. Setelah kita membuat sebuah iterator, kita bisa memakainya dalam berbagai cara. Di Listing 3-5, kita beriterasi melewati sebuah array memakai for loop untuk mengeksekusi beberapa kode pada masing-masing itemnya. Di balik layar, hal ini secara implisit membuat lalu mengonsumsi sebuah iterator, tapi kita melewatkan detail tentang gimana sebenarnya itu bekerja sampai saat ini.

Di contoh pada Listing 13-11, kita memisahkan proses pembuatan iterator dari penggunaan iterator tersebut di dalam for loop. Saat for loop ini dipanggil memakai iterator di v1_iter, setiap elemen di iterator tersebut dipakai dalam satu putaran (iteration) loop, yang mana mencetak setiap nilainya.

Filename: src/main.rs
fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();

    for val in v1_iter {
        println!("Got: {val}");
    }
}
Listing 13-11: Memakai iterator di dalam sebuah for loop

Di bahasa pemrograman yang tidak menyediakan iterator dari standard library-nya, kita kemungkinan bakal menulis fungsionalitas yang sama ini dengan memulai sebuah variabel di indeks 0, memakai variabel tersebut buat mengindeks ke dalam vector untuk mendapatkan nilai, lalu menambah nilai variabel itu di dalam loop sampai jumlahnya mencapai total item yang ada di vector.

Iterators menangani semua logika tersebut buat kita, mengurangi kode yang berulang-ulang (repetitive code) yang mana bisa saja kita bikin salah. Iterators ngasih kita fleksibilitas lebih buat memakai logika yang sama dengan berbagai jenis urutan data, bukan cuma struktur data yang bisa kita indeks aja, kayak vector. Mari kita teliti gimana cara iterators melakukan itu.

Trait Iterator dan Method next

Semua iterators mengimplementasikan sebuah trait bernama Iterator yang didefinisikan di standard library. Definisi dari trait ini kelihatan kayak gini:

#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;

    // methods dengan implementasi default dihilangkan
}
}

Perhatikan bahwa definisi ini memakai beberapa sintaks baru: type Item dan Self::Item, yang mendefinisikan sebuah associated type dengan trait ini. Kita bakal membahas associated types lebih mendalam di Bab 20. Buat sekarang, yang perlu kita tahu adalah bahwa kode ini bilang kalau buat mengimplementasikan trait Iterator, kita juga harus mendefinisikan tipe Item, dan tipe Item ini dipakai di tipe kembalian (return type) dari method next. Dengan kata lain, tipe Item bakal jadi tipe yang dikembalikan dari iterator tersebut.

Trait Iterator cuma mewajibkan para peng-implementasi (implementors) buat mendefinisikan satu method saja: yaitu method next, yang mengembalikan satu item dari iterator pada satu waktu, dibungkus di dalam Some, dan, ketika iterasi selesai, mengembalikan None.

Kita bisa memanggil method next pada iterators secara langsung; Listing 13-12 mendemonstrasikan nilai-nilai apa aja yang dikembalikan dari pemanggilan berulang ke next pada iterator yang dibikin dari vector.

Filename: src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn iterator_demonstration() {
        let v1 = vec![1, 2, 3];

        let mut v1_iter = v1.iter();

        assert_eq!(v1_iter.next(), Some(&1));
        assert_eq!(v1_iter.next(), Some(&2));
        assert_eq!(v1_iter.next(), Some(&3));
        assert_eq!(v1_iter.next(), None);
    }
}
Listing 13-12: Memanggil method next pada sebuah iterator

Perhatikan bahwa kita harus membikin v1_iter jadi mutable: memanggil method next pada sebuah iterator bakal mengubah state (keadaan) internal yang dipakai sama iterator tersebut untuk melacak (keep track of) ada di mana dia saat ini di urutan tersebut. Dengan kata lain, kode ini mengonsumsi (consumes), atau menghabiskan, iterator-nya. Setiap pemanggilan ke next bakal “memakan” satu item dari iterator-nya. Kita tidak perlu membikin v1_iter jadi mutable saat kita memakai for loop karena loop tersebut mengambil kepemilikan (ownership) dari v1_iter dan membikinnya mutable di balik layar.

Perhatikan juga bahwa nilai yang kita dapat dari pemanggilan next adalah referensi immutable ke nilai-nilai yang ada di vector-nya. Method iter menghasilkan sebuah iterator yang berisi referensi immutable. Kalau kita mau membuat iterator yang mengambil kepemilikan dari v1 lalu mengembalikan nilai yang owned (dimiliki), kita bisa memanggil into_iter alih-alih iter. Sama juga halnya, kalau kita mau beriterasi melewati referensi mutable, kita bisa memanggil iter_mut alih-alih iter.

Method-method yang Mengonsumsi Iterator

Trait Iterator punya sejumlah method berbeda dengan implementasi default yang disediakan oleh standard library; kita bisa tahu soal method-method ini dengan melihat dokumentasi API standard library untuk trait Iterator. Beberapa dari method ini memanggil method next di dalam definisi mereka, dan inilah alasannya kenapa kita diwajibkan buat mengimplementasikan method next saat kita mau mengimplementasikan trait Iterator.

Method-method yang memanggil next ini disebut consuming adapters, karena memanggil mereka bakal menghabiskan iterator-nya. Salah satu contohnya adalah method sum, yang mengambil kepemilikan dari iterator lalu beriterasi melewati item-itemnya dengan memanggil next secara berulang, yang mana ini bakal mengonsumsi iterator tersebut. Selama ia beriterasi, method ini menambahkan tiap item ke dalam sebuah total berjalan (running total) lalu mengembalikan totalnya saat iterasi selesai. Listing 13-13 punya contoh tes yang mengilustrasikan pemakaian method sum.

Filename: src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn iterator_sum() {
        let v1 = vec![1, 2, 3];

        let v1_iter = v1.iter();

        let total: i32 = v1_iter.sum();

        assert_eq!(total, 6);
    }
}
Listing 13-13: Memanggil method sum buat mendapatkan total dari semua item di dalam iterator

Kita tidak diizinkan buat memakai v1_iter lagi setelah memanggil sum karena sum mengambil kepemilikan dari iterator tempat dia dipanggil.

Method-method yang Menghasilkan Iterator Lain

Iterator adapters adalah method-method yang didefinisikan pada trait Iterator yang tidak mengonsumsi iterator-nya. Alih-alih mengonsumsi, mereka malah menghasilkan iterator-iterator yang berbeda dengan cara mengubah beberapa aspek dari iterator aslinya.

Listing 13-14 menunjukkan contoh dari pemanggilan method iterator adapter map, yang menerima sebuah closure buat dipanggil pada setiap item saat item-item tersebut dilewati dalam proses iterasi. Method map mengembalikan sebuah iterator baru yang menghasilkan item-item yang sudah dimodifikasi tersebut. Closure di sini membuat iterator baru di mana tiap item dari vector bakal ditambahkan 1.

Filename: src/main.rs
fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    v1.iter().map(|x| x + 1);
}
Listing 13-14: Memanggil iterator adapter map buat membuat iterator baru

Namun, kode ini menghasilkan sebuah peringatan (warning):

$ cargo run
   Compiling iterators v0.1.0 (file:///projects/iterators)
warning: unused `Map` that must be used
 --> src/main.rs:4:5
  |
4 |     v1.iter().map(|x| x + 1);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: iterators are lazy and do nothing unless consumed
  = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
  |
4 |     let _ = v1.iter().map(|x| x + 1);
  |     +++++++

warning: `iterators` (bin "iterators") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.47s
     Running `target/debug/iterators`

Kode di Listing 13-14 tidak melakukan apa-apa; closure yang sudah kita tentukan itu tidak akan pernah dipanggil. Peringatannya mengingatkan kita soal alasannya kenapa: iterator adapters itu lazy (malas), dan kita harus mengonsumsi iterator-nya di sini.

Untuk membereskan peringatan ini dan mengonsumsi iterator-nya, kita bakal memakai method collect, yang sudah kita pakai bersama env::args di Listing 12-1. Method ini mengonsumsi iterator-nya lalu mengumpulkan (collects) nilai- nilai yang dihasilkan ke dalam sebuah tipe data koleksi.

Di Listing 13-15, kita mengumpulkan hasil dari iterasi iterator yang dikembalikan oleh panggilan ke map menjadi sebuah vector. Vector ini nantinya bakal berisi setiap item dari vector aslinya, masing-masing ditambah 1.

Filename: src/main.rs
fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();

    assert_eq!(v2, vec![2, 3, 4]);
}
Listing 13-15: Memanggil method map buat membuat iterator baru, lalu memanggil method collect buat mengonsumsi iterator baru itu dan membuat sebuah vector

Karena map menerima sebuah closure, kita bisa menentukan operasi apa pun yang mau kita lakukan pada tiap itemnya. Ini adalah contoh yang bagus tentang gimana closures memungkinkan kita buat mengkustomisasi beberapa perilaku tertentu sambil menggunakan kembali perilaku iterasi yang disediakan sama trait Iterator.

Kita bisa menyambung (chain) beberapa panggilan ke berbagai iterator adapters buat melakukan tindakan yang rumit pakai cara yang tetap enak dibaca. Tapi karena semua iterator itu lazy, kita harus memanggil salah satu dari method consuming adapter buat mendapatkan hasil dari rentetan panggilan ke iterator adapters tersebut.

Menggunakan Closures yang Menangkap Lingkungannya

Banyak iterator adapters menerima closures sebagai argumen, dan biasanya closures yang bakal kita berikan sebagai argumen ke iterator adapters itu adalah closures yang bakal menangkap lingkungan (environment) mereka.

Untuk contoh ini, kita bakal memakai method filter yang menerima sebuah closure. Closure ini mengambil item dari iterator lalu mengembalikan sebuah bool. Kalau closure-nya mengembalikan true, nilainya bakal diikutsertakan dalam iterasi yang dihasilkan oleh filter. Kalau closure-nya mengembalikan false, nilainya tidak bakal diikutsertakan.

Di Listing 13-16, kita memakai filter bersama sebuah closure yang menangkap variabel shoe_size dari lingkungannya untuk beriterasi melewati sekumpulan instance struct Shoe. Ini cuma bakal mengembalikan sepatu-sepatu yang punya ukuran sama dengan yang ditentukan.

Filename: src/lib.rs
#[derive(PartialEq, Debug)]
struct Shoe {
    size: u32,
    style: String,
}

fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
    shoes.into_iter().filter(|s| s.size == shoe_size).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn filters_by_size() {
        let shoes = vec![
            Shoe {
                size: 10,
                style: String::from("sneaker"),
            },
            Shoe {
                size: 13,
                style: String::from("sandal"),
            },
            Shoe {
                size: 10,
                style: String::from("boot"),
            },
        ];

        let in_my_size = shoes_in_size(shoes, 10);

        assert_eq!(
            in_my_size,
            vec![
                Shoe {
                    size: 10,
                    style: String::from("sneaker")
                },
                Shoe {
                    size: 10,
                    style: String::from("boot")
                },
            ]
        );
    }
}
Listing 13-16: Memakai method filter bersama sebuah closure yang menangkap shoe_size

Fungsi shoes_in_size mengambil kepemilikan dari sebuah vector sepatu (shoes) dan ukuran sepatu sebagai parameternya. Ia mengembalikan vector yang hanya berisi sepatu-sepatu dengan ukuran yang ditentukan tersebut.

Di dalam body dari shoes_in_size, kita memanggil into_iter buat membuat sebuah iterator yang mengambil kepemilikan dari vector-nya. Kemudian kita memanggil filter buat mengadaptasi iterator itu menjadi iterator baru yang hanya mengandung elemen-elemen yang bikin closure-nya mengembalikan true.

Closure-nya menangkap parameter shoe_size dari lingkungan di sekitarnya dan membandingkan nilai itu dengan setiap ukuran dari sepatunya, mempertahankan hanya sepatu-sepatu dengan ukuran yang sesuai. Terakhir, memanggil collect bakal mengumpulkan (gathers) nilai-nilai yang dikembalikan oleh iterator yang diadaptasi ini ke dalam sebuah vector yang kemudian dikembalikan oleh fungsi tersebut.

Pengujian ini menunjukkan bahwa saat kita memanggil shoes_in_size, kita cuma bakal dapat balik sepatu-sepatu yang punya ukuran yang sama dengan nilai yang kita tentukan.

Meningkatkan Project I/O Kita

Meningkatkan Project I/O Kita

Dengan pengetahuan baru tentang iterators ini, kita bisa meningkatkan project I/O di Bab 12 dengan memakai iterators buat bikin beberapa bagian kodenya jadi lebih jelas dan lebih ringkas. Mari kita lihat gimana iterators bisa meningkatkan implementasi dari fungsi Config::build dan fungsi search kita.

Menghilangkan clone Menggunakan Iterator

Di Listing 12-6, kita menambahkan kode yang mengambil slice berisi nilai String lalu membuat instance dari struct Config dengan mengindeks ke dalam slice tersebut dan meng-clone (menyalin) nilai-nilainya, yang memungkinkan struct Config buat memiliki (own) nilai-nilai itu. Di Listing 13-17, kita menampilkan ulang implementasi dari fungsi Config::build persis seperti yang ada di Listing 12-23.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}
Listing 13-17: Menampilkan ulang fungsi Config::build dari Listing 12-23

Waktu itu, kita bilang buat tidak mengkhawatirkan panggilan clone yang kurang efisien karena kita bakal menghapusnya di masa depan. Nah, sekaranglah saatnya!

Kita membutuhkan clone di sini karena kita punya sebuah slice berisi elemen-elemen String di parameter args, tapi fungsi build tidak mengambil kepemilikan (ownership) atas args. Buat mengembalikan kepemilikan dari instance Config, kita harus meng-clone nilai-nilai dari field query dan file_path milik Config supaya instance Config tersebut bisa memiliki nilai-nilainya.

Dengan pengetahuan baru kita tentang iterators, kita bisa mengubah fungsi build agar mengambil kepemilikan dari sebuah iterator sebagai argumennya, ketimbang meminjam (borrow) sebuah slice. Kita bakal memakai fungsionalitas iterator alih-alih kode yang mengecek panjang dari slice dan mengindeks ke lokasi spesifik. Ini bakal memperjelas apa yang sedang dilakukan oleh fungsi Config::build karena iterator-lah yang bakal mengakses nilai-nilainya.

Begitu Config::build mengambil kepemilikan atas iterator dan berhenti memakai operasi indexing yang sifatnya meminjam, kita bisa memindahkan (move) nilai-nilai String dari iterator tersebut ke dalam Config alih-alih memanggil clone dan membuat alokasi baru.

Memakai Iterator yang Dikembalikan secara Langsung

Buka file src/main.rs dari project I/O kita, yang seharusnya kelihatan seperti ini:

Nama file: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

Pertama-tama kita bakal mengubah bagian awal dari fungsi main yang kita punya di Listing 12-24 jadi kode yang ada di Listing 13-18, yang mana kali ini memakai iterator. Ini belum bisa di-compile sampai kita meng-update Config::build juga.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}
Listing 13-18: Meneruskan nilai yang dikembalikan oleh env::args ke Config::build

Fungsi env::args mengembalikan sebuah iterator! Alih-alih mengumpulkan nilai-nilai iterator itu ke dalam sebuah vector terus meneruskan sebuah slice ke Config::build, sekarang kita meneruskan kepemilikan dari iterator yang dikembalikan dari env::args ke Config::build secara langsung.

Berikutnya, kita harus meng-update definisi dari Config::build. Mari kita ubah signature (tanda tangan) dari Config::build agar kelihatan seperti Listing 13-19. Ini masih belum bisa di-compile, karena kita harus meng-update isi (body) fungsinya.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        // --snip--
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}
Listing 13-19: Meng-update signature dari Config::build buat mengharapkan sebuah iterator

Dokumentasi standard library untuk fungsi env::args menunjukkan bahwa tipe dari iterator yang dikembalikannya adalah std::env::Args, dan tipe tersebut mengimplementasikan trait Iterator serta mengembalikan nilai String.

Kita sudah meng-update signature dari fungsi Config::build jadi parameter args punya tipe generik dengan trait bounds impl Iterator<Item = String> bukannya &[String]. Penggunaan sintaks impl Trait yang kita bahas di bagian “Traits sebagai Parameter” di Bab 10 ini berarti args bisa berupa tipe apa pun yang mengimplementasikan trait Iterator dan mengembalikan item berupa String.

Karena kita mengambil kepemilikan atas args dan kita bakal memutasi (mengubah) args saat kita beriterasi melewatinya, kita bisa menambahkan keyword mut ke dalam spesifikasi parameter args buat membikinnya jadi mutable.

Memakai Method Trait Iterator Alih-Alih Indexing

Berikutnya, kita bakal memperbaiki isi dari Config::build. Karena args mengimplementasikan trait Iterator, kita tahu kalau kita bisa memanggil method next padanya! Listing 13-20 meng-update kode dari Listing 12-23 buat memakai method next.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a query string"),
        };

        let file_path = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file path"),
        };

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}
Listing 13-20: Mengubah isi dari Config::build buat memakai method iterator

Ingat kembali bahwa nilai pertama di dalam nilai yang dikembalikan oleh env::args adalah nama dari programnya. Kita mau mengabaikan itu dan lanjut ke nilai berikutnya, jadi pertama kita memanggil next dan tidak ngelakuin apa-apa dengan nilai yang dikembalikannya. Terus kita panggil next lagi buat dapet nilai yang mau kita masukin ke field query dari Config. Kalau next mengembalikan Some, kita pakai match buat mengekstrak nilainya. Kalau dia mengembalikan None, itu berarti argumen yang diberikan tidak cukup dan kita bisa keluar lebih awal dengan nilai Err. Kita ngelakuin hal yang sama buat nilai file_path.

Membikin Kode Lebih Jelas dengan Iterator Adapters

Kita juga bisa memanfaatkan iterators di fungsi search dari project I/O kita, yang ditampilkan ulang di Listing 13-21 persis seperti yang ada di Listing 12-19.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 13-21: Implementasi dari fungsi search dari Listing 12-19

Kita bisa nulis kode ini dengan cara yang lebih ringkas memakai method iterator adapter. Dengan begitu, kita juga terhindar dari kewajiban punya vector results menengah (intermediate) yang mutable. Gaya pemrograman fungsional lebih suka meminimalisir mutable state (keadaan yang bisa berubah) demi bikin kodenya jadi lebih jelas. Membuang mutable state ini mungkin bisa memungkinkan adanya peningkatan di masa depan yang bakal bikin pencarian terjadi secara paralel karena kita tidak perlu pusing mengelola akses konruen (concurrent access) ke vector results tersebut. Listing 13-22 menunjukkan perubahan ini.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    contents
        .lines()
        .filter(|line| line.contains(query))
        .collect()
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 13-22: Memakai method iterator adapter di implementasi fungsi search

Ingat kembali bahwa tujuan dari fungsi search adalah mengembalikan semua baris di dalam contents yang mengandung query. Mirip seperti contoh filter di Listing 13-16, kode ini memakai adapter filter buat menyimpan cuma baris-baris di mana line.contains(query) mengembalikan true. Kita kemudian mengumpulkan baris-baris yang cocok itu ke dalam sebuah vector lain menggunakan collect. Jauh lebih simpel kan! Jangan ragu buat ngelakuin perubahan yang sama buat memakai method iterator di fungsi search_case_insensitive juga.

Sebagai peningkatan lanjutan, coba kembalikan sebuah iterator dari fungsi search dengan menghapus panggilan ke collect dan mengubah tipe kembaliannya jadi impl Iterator<Item = &'a str> supaya fungsi ini menjadi sebuah iterator adapter. Perhatikan bahwa kita juga bakal harus meng-update pengujiannya! Coba cari di dalam sebuah file berukuran besar menggunakan alat minigrep kita sebelum dan sesudah membikin perubahan ini untuk melihat perbedaan perilakunya. Sebelum perubahan ini, program tidak bakal mencetak hasil apa pun sampai ia selesai mengumpulkan semua hasilnya, tapi setelah perubahan itu, hasil bakal dicetak satu per satu setiap kali ada baris yang cocok karena for loop di fungsi run bisa memanfaatkan sifat lazy (malas) dari iterator-nya.

Memilih antara Loops dan Iterators

Pertanyaan masuk akal berikutnya adalah gaya mana yang sebaiknya kita pilih di kode kita sendiri dan kenapa: implementasi awal di Listing 13-21 atau versi yang memakai iterators di Listing 13-22 (dengan asumsi kita mengumpulkan semua hasilnya sebelum mengembalikannya bukannya mengembalikan iteratornya). Sebagian besar programmer Rust lebih suka memakai gaya iterator. Mungkin agak sedikit susah buat memahaminya di awal, tapi begitu kita sudah mulai merasakan (get a feel for) berbagai iterator adapters dan apa yang mereka lakukan, iterators bisa jadi lebih gampang buat dipahami. Ketimbang ribet ngurusin detail soal looping (perulangan) dan membikin vector baru, kode kita jadi bisa lebih fokus ke tujuan tingkat tinggi (high-level objective) dari loop tersebut. Ini mengabstraksi kode-kode umum (commonplace code) sehingga konsep-konsep yang unik buat kode ini jadi lebih mudah dilihat, seperti contohnya kondisi penyaringan (filtering condition) yang harus dilewati sama setiap elemen di dalam iterator.

Tapi apakah kedua implementasi ini benar-benar ekuivalen (sama)? Asumsi yang mungkin muncul secara intuitif adalah loop tingkat rendah (lower-level loop) bakal lebih cepat. Mari kita bahas soal performa.

Performa: Loops vs. Iterator

Membandingkan Performa: Loops vs. Iterators

Buat menentukan apakah kita sebaiknya memakai loops atau iterators, kita perlu tahu implementasi mana yang lebih cepat: versi fungsi search yang memakai for loop eksplisit atau versi yang memakai iterators.

Kita menjalankan sebuah benchmark (pengujian performa) dengan memuat seluruh isi buku The Adventures of Sherlock Holmes karya Sir Arthur Conan Doyle ke dalam sebuah String dan mencari kata the di dalam isinya. Berikut adalah hasil dari benchmark pada fungsi search versi for loop dan versi iterators:

test bench_search_for  ... bench:  19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench:  19,234,900 ns/iter (+/- 657,200)

Kedua implementasi ini punya performa yang mirip! Kita tidak bakal menjelaskan kode benchmark-nya di sini karena tujuannya bukanlah buat membuktikan kalau kedua versi itu persis ekuivalen, tapi buat ngasih gambaran umum soal gimana perbandingan performa kedua implementasi ini.

Untuk benchmark yang lebih komprehensif, kita sebaiknya menguji dengan memakai berbagai teks dari berbagai ukuran sebagai contents, kata yang berbeda-beda dan kata dengan panjang yang beda-beda sebagai query, dan berbagai jenis variasi lainnya. Poin utamanya adalah ini: iterators, meskipun merupakan sebuah abstraksi tingkat tinggi (high-level abstraction), bakal di-compile menjadi kode yang kurang lebih sama seperti kalau kita menulis sendiri kode tingkat rendah (lower-level code)-nya secara manual. Iterators adalah salah satu dari zero-cost abstractions (abstraksi tanpa biaya) di Rust, yang maksudnya adalah penggunaan abstraksi tersebut tidak menambahkan beban (overhead) apa pun pas runtime. Ini analog dengan gimana Bjarne Stroustrup, perancang dan pengimplementasi asli dari C++, mendefinisikan zero-overhead di “Foundations of C++” (2012):

Secara umum, implementasi C++ mematuhi prinsip zero-overhead: Apa yang tidak kita pakai, kita tidak perlu membayarnya. Dan lebih jauh lagi: Apa yang kita pakai, kita tidak bakal bisa nulis kodenya secara manual dengan lebih baik lagi.

Di banyak kasus, kode Rust yang memakai iterators di-compile menjadi kode assembly (bahasa rakitan) yang sama persis kayak yang bakal kita tulis pakai tangan sendiri. Berbagai optimasi kayak loop unrolling dan menghilangkan pengecekan batas (bounds checking) pada akses array bakal diterapkan dan membikin kode akhirnya jadi sangat efisien. Sekarang karena kita sudah tahu hal ini, kita bisa memakai iterators dan closures tanpa rasa takut! Mereka bikin kode kelihatan seperti di tingkat yang lebih tinggi (higher level) tapi tidak mengenakan hukuman performa (performance penalty) pas runtime karena ngelakuin hal tersebut.

Ringkasan

Closures dan iterators adalah fitur-fitur Rust yang terinspirasi dari ide-ide bahasa pemrograman fungsional. Mereka berkontribusi pada kemampuan Rust buat mengekspresikan ide-ide tingkat tinggi (high-level ideas) dengan jelas sembari mempertahankan performa tingkat rendah (low-level performance). Implementasi dari closures dan iterators dirancang sedemikian rupa sehingga performa pas runtime tidak terpengaruh. Ini adalah bagian dari tujuan Rust buat berjuang menyediakan zero-cost abstractions.

Sekarang setelah kita meningkatkan kemampuan ekspresi dari project I/O kita, mari kita lihat beberapa fitur cargo lainnya yang bakal membantu kita membagikan project kita dengan dunia.

Lebih Lanjut soal Cargo dan Crates.io

Sejauh ini, kita baru memakai fitur-fitur paling dasar dari Cargo buat mem-build, menjalankan, dan menguji kode kita, tapi dia bisa melakukan jauh lebih banyak lagi. Di bab ini, kita bakal membahas beberapa fitur Cargo lainnya yang lebih mahir (advanced) buat menunjukkan ke kita gimana cara ngelakuin hal-hal berikut:

  • Mengkustomisasi build kita melalui release profiles (profil rilis)
  • Memublikasikan (publish) libraries di crates.io
  • Mengatur project yang besar dengan workspaces
  • Menginstal binaries dari crates.io
  • Memperluas kemampuan Cargo memakai perintah kustom (custom commands)

Cargo bisa melakukan lebih dari fungsionalitas yang kita bahas di bab ini, jadi buat penjelasan selengkapnya tentang semua fiturnya, cek dokumentasinya.

Kustomisasi Build Memakai Profil Rilis

Mengkustomisasi Build dengan Release Profiles

Di Rust, release profiles (profil rilis) adalah profil yang sudah didefinisikan sebelumnya (predefined) dan bisa dikustomisasi dengan berbagai konfigurasi yang memungkinkan programmer buat punya lebih banyak kontrol atas berbagai opsi saat men-compile kode. Setiap profil dikonfigurasi secara independen satu sama lain.

Cargo punya dua profil utama: profil dev yang dipakai Cargo saat kita menjalankan cargo build, dan profil release yang dipakai Cargo saat kita menjalankan cargo build --release. Profil dev didefinisikan dengan default yang bagus buat fase development (pengembangan), dan profil release punya default yang bagus buat release builds (build versi rilis).

Nama-nama profil ini mungkin terasa familier dari output build yang pernah kita jalankan:

$ cargo build
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
$ cargo build --release
    Finished `release` profile [optimized] target(s) in 0.32s

dev dan release adalah profil-profil berbeda yang dipakai sama compiler.

Cargo punya pengaturan default buat setiap profil yang berlaku kalau kita belum menambahkan bagian [profile.*] secara eksplisit di file Cargo.toml milik project kita. Dengan menambahkan bagian [profile.*] buat profil mana pun yang mau kita kustomisasi, kita bisa menimpa (override) sebagian dari pengaturan default-nya. Misalnya, ini adalah nilai default buat pengaturan opt-level di profil dev dan release:

Nama file: Cargo.toml

[profile.dev]
opt-level = 0

[profile.release]
opt-level = 3

Pengaturan opt-level mengontrol seberapa banyak optimasi yang bakal diterapkan Rust ke kode kita, dengan rentang nilai dari 0 sampai 3. Menerapkan lebih banyak optimasi bakal memperpanjang waktu kompilasi, jadi kalau kita sedang dalam fase pengembangan dan sering men-compile kode kita, kita bakal mau lebih sedikit optimasi agar kodenya bisa di-compile lebih cepat meskipun kode akhirnya nanti berjalan lebih lambat. Oleh karena itu, nilai opt-level default buat dev adalah 0. Saat kita sudah siap merilis kode kita, hal terbaik adalah menghabiskan lebih banyak waktu buat kompilasi. Kita cuma bakal men-compile dalam mode release sesekali saja, tapi kita bakal menjalankan program yang sudah di-compile itu berkali-kali, jadi mode release menukar waktu kompilasi yang lebih lama demi mendapatkan kode yang berjalan lebih cepat. Itulah kenapa nilai opt-level default buat profil release adalah 3.

Kita bisa menimpa pengaturan default dengan menambahkan nilai yang berbeda untuknya di Cargo.toml. Misalnya, kalau kita mau memakai tingkat optimasi 1 di profil development, kita bisa menambahkan dua baris ini ke file Cargo.toml project kita:

Nama file: Cargo.toml

[profile.dev]
opt-level = 1

Kode ini menimpa pengaturan default yaitu 0. Sekarang pas kita menjalankan cargo build, Cargo bakal memakai nilai-nilai default buat profil dev ditambah dengan kustomisasi kita pada opt-level. Karena kita menge-set opt-level jadi 1, Cargo bakal menerapkan lebih banyak optimasi daripada nilai default-nya, tapi tidak sebanyak di versi release build.

Buat melihat daftar lengkap dari opsi konfigurasi dan nilai default dari setiap profil, silakan cek dokumentasi Cargo.

Mempublikasikan sebuah Crate ke Crates.io

Mempublikasikan Crate ke Crates.io

Kita sudah memakai packages dari crates.io sebagai dependencies buat project kita, tapi kita juga bisa nge-share kode kita sama orang lain dengan mempublikasikan packages milik kita sendiri. Registry crate di crates.io mendistribusikan source code dari packages kita, jadi ia pada dasarnya meng-host kode yang open source (sumber terbuka).

Rust dan Cargo punya berbagai fitur yang bikin package yang kita publikasikan jadi lebih gampang dicari dan dipakai orang. Kita bakal membahas beberapa fitur ini lalu menjelaskan gimana caranya mempublikasikan sebuah package.

Mendokumentasikan packages kita secara akurat bakal membantu para user lain tahu gimana dan kapan mereka bisa memakainya, jadi sangat sepadan menghabiskan waktu buat nulis dokumentasi. Di Bab 3, kita sudah membahas gimana cara memberi komentar di kode Rust menggunakan dua garis miring, //. Rust juga punya jenis komentar khusus buat dokumentasi, yang lebih enak disebut documentation comment (komentar dokumentasi), yang bakal men-generate dokumentasi dalam format HTML. HTML ini menampilkan isi dari komentar dokumentasi buat item-item API public yang ditujukan buat para programmer yang tertarik buat tahu gimana cara memakai crate kita, bukannya gimana crate kita itu diimplementasikan.

Komentar dokumentasi memakai tiga garis miring, ///, bukannya dua dan mendukung notasi Markdown buat memformat teksnya. Taruh komentar dokumentasi persis sebelum item yang lagi mereka dokumentasikan. Listing 14-1 menunjukkan komentar dokumentasi buat sebuah fungsi add_one di dalam sebuah crate bernama my_crate.

Filename: src/lib.rs
/// Adds one to the number given.
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}
Listing 14-1: Sebuah komentar dokumentasi buat sebuah fungsi

Di sini, kita memberikan deskripsi tentang apa yang dilakukan sama fungsi add_one, memulai sebuah bagian (section) dengan judul Examples (Contoh), dan kemudian menyediakan kode yang mendemonstrasikan gimana cara memakai fungsi add_one. Kita bisa men-generate dokumentasi HTML dari komentar dokumentasi ini dengan menjalankan cargo doc. Perintah ini menjalankan tool rustdoc yang didistribusikan bersama Rust dan menaruh dokumentasi HTML hasilnya ke dalam direktori target/doc.

Biar lebih praktis, menjalankan cargo doc --open bakal mem-build HTML buat dokumentasi dari crate kita saat ini (beserta dokumentasi buat semua dependencies crate kita) lalu membuka hasilnya di web browser. Arahkan navigasi ke fungsi add_one dan kita bakal melihat gimana teks di komentar dokumentasi tersebut dirender, seperti yang ditunjukkan di Gambar 14-1.

Dokumentasi HTML yang dirender untuk fungsi `add_one` dari `my_crate`

Gambar 14-1: Dokumentasi HTML untuk fungsi add_one

Bagian-bagian yang Sering Dipakai

Kita memakai heading Markdown # Examples di Listing 14-1 buat membikin sebuah bagian di HTML-nya dengan judul “Examples.” Berikut ini beberapa bagian lain yang sering sekali dipakai oleh para pembuat crate di dokumentasi mereka:

  • Panics: Skenario-skenario di mana fungsi yang didokumentasikan ini bisa mengalami panic. Kode pemanggil fungsi ini yang tidak mau programnya mengalami panic harus memastikan kalau mereka tidak memanggil fungsi ini di situasi-situasi tersebut.
  • Errors: Kalau fungsinya mengembalikan sebuah Result, mendeskripsikan jenis-jenis error apa aja yang mungkin terjadi dan kondisi apa yang mungkin menyebabkan error-error itu dikembalikan bisa sangat ngebantu pemanggil agar mereka bisa nulis kode buat menangani berbagai jenis error dengan cara yang berbeda-beda.
  • Safety: Kalau fungsinya itu unsafe (tidak aman) buat dipanggil (kita membahas soal unsafety di Bab 20), harusnya ada bagian yang menjelaskan kenapa fungsi itu tidak aman dan mencakup invariants (batasan) apa yang diharapkan oleh fungsi tersebut buat dipatuhi sama pemanggilnya.

Kebanyakan komentar dokumentasi tidak perlu punya semua bagian ini, tapi ini adalah checklist yang bagus buat ngingetin kita soal aspek-aspek kode kita yang bakal bikin user tertarik buat tahu.

Komentar Dokumentasi Sebagai Tests (Pengujian)

Menambahkan blok-blok contoh kode di dalam komentar dokumentasi kita bisa membantu mendemonstrasikan gimana cara memakai library kita, dan melakukannya punya keuntungan ekstra: menjalankan cargo test bakal menjalankan contoh kode di dokumentasi kita tersebut sebagai pengujian! Tidak ada yang lebih baik dari dokumentasi yang disertai contoh. Tapi tidak ada yang lebih parah dari contoh yang tidak jalan gara-gara kodenya udah diubah sejak dokumentasinya ditulis. Kalau kita menjalankan cargo test dengan dokumentasi buat fungsi add_one dari Listing 14-1, kita bakal melihat sebuah bagian di hasil pengujian yang kelihatan kayak gini:

   Doc-tests my_crate

running 1 test
test src/lib.rs - add_one (line 5) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s

Sekarang, kalau kita mengubah fungsinya atau contohnya sehingga assert_eq! di contoh tersebut mengalami panic, lalu menjalankan cargo test lagi, kita bakal melihat kalau doc tests bakal menangkap bahwa contoh dan kodenya sudah tidak sinkron lagi!

Mengomentari Item Penampungnya (Contained Items)

Gaya komentar dokumentasi //! menambahkan dokumentasi ke item yang menampung komentar tersebut, bukannya ke item-item yang mengikuti komentarnya. Kita biasanya memakai jenis komentar dokumentasi ini di dalam file crate root (src/lib.rs secara konvensi) atau di dalam sebuah modul buat mendokumentasikan crate atau modulnya secara keseluruhan.

Misalnya, buat menambahkan dokumentasi yang mendeskripsikan tujuan dari crate my_crate yang mengandung fungsi add_one, kita bisa menambahkan komentar dokumentasi yang dimulai dengan //! ke bagian paling awal dari file src/lib.rs, seperti yang ditunjukkan di Listing 14-2.

Filename: src/lib.rs
//! # My Crate
//!
//! `my_crate` is a collection of utilities to make performing certain
//! calculations more convenient.

/// Adds one to the number given.
// --snip--
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}
Listing 14-2: Dokumentasi untuk crate my_crate secara keseluruhan

Perhatikan bahwa tidak ada kode apa pun setelah baris terakhir yang dimulai dengan //!. Karena kita memulai komentarnya dengan //! bukannya ///, kita mendokumentasikan item yang menampung komentar ini, bukannya item yang ada setelah komentar ini. Di kasus ini, item tersebut adalah file src/lib.rs, yang mana itu adalah crate root. Komentar-komentar ini mendeskripsikan crate-nya secara keseluruhan.

Pas kita menjalankan cargo doc --open, komentar-komentar ini bakal tampil di halaman depan dokumentasi untuk my_crate tepat di atas daftar item-item public di crate tersebut, seperti yang ditunjukkan di Gambar 14-2.

Dokumentasi HTML yang dirender dengan komentar buat _crate_ secara keseluruhan

Gambar 14-2: Dokumentasi yang dirender buat my_crate, termasuk komentar yang mendeskripsikan crate secara keseluruhan

Komentar dokumentasi yang ditaruh di dalam item sangat berguna buat mendeskripsikan crates dan juga modules (modul). Pakailah mereka buat menjelaskan tujuan keseluruhan dari wadah (container)-nya demi membantu para user kita memahami organisasi crate tersebut.

Mengekspor API Public yang Nyaman dengan pub use

Struktur dari API public kita adalah pertimbangan besar saat mempublikasikan sebuah crate. Orang-orang yang memakai crate kita itu kurang familier dengan strukturnya dibandingkan kita dan mungkin bakal kesusahan mencari bagian-bagian yang mau mereka pakai kalau crate kita punya hierarki modul yang besar.

Di Bab 7, kita sudah membahas gimana cara membikin item jadi public memakai keyword pub, dan gimana cara membawa item ke dalam scope memakai keyword use. Namun, struktur yang menurut kita masuk akal saat kita lagi ngembangin sebuah crate mungkin aja tidak terlalu nyaman buat para user kita. Kita mungkin mau mengatur structs kita di dalam sebuah hierarki yang terdiri dari beberapa tingkat, tapi nanti orang yang mau memakai tipe yang sudah kita definisikan jauh di kedalaman hierarki itu mungkin bakal kesulitan buat tahu kalau tipe tersebut ada. Mereka juga mungkin bakal jengkel karena harus mengetikkan use my_crate::some_module::another_module::UsefulType; bukannya sekadar use my_crate::UsefulType;.

Kabar baiknya adalah kalau struktur yang kita buat tidak nyaman buat orang lain pakai dari library mereka, kita tidak harus menata ulang organisasi internalnya: sebagai gantinya, kita bisa me-re-export (mengekspor ulang) item-item buat bikin sebuah struktur public yang beda dari struktur private kita dengan menggunakan pub use. Re-exporting mengambil sebuah item public di satu lokasi lalu membikinnya jadi public di lokasi lain, seolah-olah item tersebut didefinisikan di lokasi yang lain itu.

Misalnya, katakanlah kita bikin sebuah library bernama art untuk memodelkan konsep-konsep kesenian. Di dalam library ini ada dua modul: modul kinds yang berisi dua enum bernama PrimaryColor dan SecondaryColor, serta modul utils yang berisi fungsi bernama mix, seperti yang ditunjukkan di Listing 14-3.

Filename: src/lib.rs
//! # Art
//!
//! A library for modeling artistic concepts.

pub mod kinds {
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    use crate::kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        // --snip--
        unimplemented!();
    }
}
Listing 14-3: Sebuah library art yang punya item-item yang diatur ke dalam modul kinds dan utils

Gambar 14-3 menunjukkan seperti apa jadinya halaman depan dokumentasi untuk crate ini yang di-generate oleh cargo doc.

Dokumentasi yang dirender untuk _crate_ `art` yang mendaftarkan modul `kinds` dan `utils`

Gambar 14-3: Halaman depan dokumentasi untuk art yang mendaftarkan modul kinds dan utils

Perhatikan bahwa tipe PrimaryColor dan SecondaryColor tidak terdaftar di halaman depan, fungsi mix juga tidak ada. Kita harus mengklik kinds dan utils buat melihat mereka.

Crate lain yang bergantung pada library ini bakal butuh statements use yang membawa item-item dari art ke dalam scope, dengan menentukan struktur modul yang saat ini didefinisikan. Listing 14-4 menunjukkan contoh sebuah crate yang memakai item PrimaryColor dan mix dari crate art.

Filename: src/main.rs
use art::kinds::PrimaryColor;
use art::utils::mix;

fn main() {
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}
Listing 14-4: Sebuah crate yang memakai item dari crate art dengan struktur internalnya yang terekspor

Pembuat kode di Listing 14-4, yang memakai crate art, harus mencari tahu kalau PrimaryColor ada di dalam modul kinds dan mix ada di dalam modul utils. Struktur modul dari crate art ini lebih relevan buat para developer yang mengerjakan crate art tersebut dibanding buat orang yang memakainya. Struktur internalnya tidak memberikan informasi yang berguna buat seseorang yang lagi mencoba buat paham gimana cara memakai crate art tersebut, melainkan malah bikin bingung karena developer yang memakainya harus nyari tahu di mana harus mencari, dan harus mengetikkan nama-nama modulnya di statement use.

Buat menghilangkan organisasi internalnya dari API public, kita bisa memodifikasi kode crate art di Listing 14-3 dengan menambahkan statements pub use buat me-re-export item-item itu di tingkat paling atas, seperti yang ditunjukkan di Listing 14-5.

Filename: src/lib.rs
//! # Art
//!
//! A library for modeling artistic concepts.

pub use self::kinds::PrimaryColor;
pub use self::kinds::SecondaryColor;
pub use self::utils::mix;

pub mod kinds {
    // --snip--
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    // --snip--
    use crate::kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        SecondaryColor::Orange
    }
}
Listing 14-5: Menambahkan statements pub use buat me-re-export item

Dokumentasi API yang di-generate sama cargo doc buat crate ini sekarang bakal mendaftarkan dan menaruh tautan ke re-exports tersebut di halaman depan, seperti yang ditunjukkan di Gambar 14-4, sehingga bikin tipe PrimaryColor dan SecondaryColor serta fungsi mix jadi lebih gampang dicari.

Dokumentasi yang dirender untuk _crate_ `art` dengan hasil *re-exports* di halaman depan

Gambar 14-4: Halaman depan dokumentasi buat art yang mendaftarkan hasil re-exports

Para pengguna crate art masih bisa melihat dan memakai struktur internalnya dari Listing 14-3 seperti yang didemonstrasikan di Listing 14-4, atau mereka bisa memakai struktur yang lebih nyaman dari Listing 14-5, seperti yang ditunjukkan di Listing 14-6.

Filename: src/main.rs
use art::PrimaryColor;
use art::mix;

fn main() {
    // --snip--
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}
Listing 14-6: Sebuah program yang memakai item yang di-re-export dari crate art

Di kasus di mana ada banyak modul yang bersarang (nested modules), me-re-export tipe di level teratas dengan pub use bisa bikin perbedaan yang sangat besar dalam pengalaman (experience) orang-orang yang memakai crate tersebut. Kegunaan umum lain dari pub use adalah buat me-re-export definisi dari sebuah dependency (dependensi) di crate saat ini buat bikin definisi crate itu jadi bagian dari API public crate kita.

Membikin struktur API public yang berguna itu lebih ke arah seni ketimbang sains eksak, dan kita bisa melakukan iterasi buat nemuin API apa yang bekerja paling baik buat para user kita. Memilih buat pakai pub use ngasih kita fleksibilitas dalam mengatur gimana struktur internal crate kita dan melepaskan kaitan (decouples) antara struktur internal itu dari apa yang kita tampilkan ke para user kita. Coba deh lihat beberapa kode dari crates yang udah kita instal buat melihat apakah struktur internal mereka berbeda dari API public mereka.

Menyiapkan Akun Crates.io

Sebelum kita bisa mempublikasikan crates apa pun, kita harus bikin akun dulu di crates.io dan dapetin API token. Buat melakukannya, kunjungi halaman depan di crates.io lalu login pakai akun GitHub. (Akun GitHub saat ini adalah syarat wajibnya, tapi situsnya mungkin bakal mendukung cara lain buat bikin akun di masa depan.) Setelah kita login, kunjungi pengaturan akun kita di https://crates.io/me/ lalu ambil kunci (key) API kita. Kemudian jalankan perintah cargo login dan paste kunci API kita pas diminta, kayak gini:

$ cargo login
abcdefghijklmnopqrstuvwxyz012345

Perintah ini bakal ngasih tahu Cargo soal token API kita lalu menyimpannya secara lokal di ~/.cargo/credentials.toml. Perhatikan bahwa token ini adalah sebuah rahasia (secret): jangan bagikan ke orang lain. Kalau kita membagikannya ke orang lain dengan alasan apa pun, kita harus menariknya (revoke) dan membuat token baru di crates.io.

Menambahkan Metadata ke Crate Baru

Katakanlah kita punya sebuah crate yang mau kita publikasikan. Sebelum dipublish, kita harus menambahkan beberapa metadata di bagian [package] dari file Cargo.toml milik crate tersebut.

Crate kita bakal butuh nama yang unik. Selama kita mengerjakan crate secara lokal, kita bisa menamai crate sesuka kita. Namun, nama crate di crates.io itu dialokasikan berdasarkan siapa cepat dia dapat (first-come, first-served). Begitu sebuah nama crate sudah diambil, tidak ada orang lain yang bisa mempublikasikan crate dengan nama tersebut. Sebelum mencoba buat mempublikasikan sebuah crate, carilah nama yang pengen kita pakai. Kalau nama tersebut sudah terpakai, kita harus mencari nama lain lalu mengedit field name di dalam file Cargo.toml di bawah bagian [package] buat memakai nama baru tersebut untuk publikasi, kayak gini:

Nama file: Cargo.toml

[package]
name = "guessing_game"

Meskipun kita sudah milih nama yang unik, saat kita menjalankan cargo publish buat mempublikasikan crate di titik ini, kita bakal dapat peringatan dan lalu sebuah error:

$ cargo publish
    Updating crates.io index
warning: manifest has no description, license, license-file, documentation, homepage or repository.
See https://doc.rust-lang.org/cargo/reference/manifest.html#package-metadata for more info.
--snip--
error: failed to publish to registry at https://crates.io

Caused by:
  the remote server responded with an error (status 400 Bad Request): missing or empty metadata fields: description, license. Please see https://doc.rust-lang.org/cargo/reference/manifest.html for more information on configuring these fields

Ini menghasilkan error karena kita kelupaan beberapa informasi yang krusial: sebuah deskripsi (description) dan lisensi (license) diwajibkan supaya orang-orang bisa tahu apa yang dilakukan sama crate kita dan di bawah ketentuan (terms) apa mereka bisa memakainya. Di Cargo.toml, tambahkan sebuah deskripsi yang hanya terdiri dari satu atau dua kalimat aja, karena itu bakal muncul bareng crate kita di hasil pencarian. Buat field license, kita harus memberikan nilai pengenal lisensi (license identifier value). Software Package Data Exchange (SPDX) milik Linux Foundation mendaftarkan pengenal yang bisa kita pakai buat nilai ini. Misalnya, buat menentukan kalau kita melisensikan crate kita memakai Lisensi MIT, tambahkan pengenal MIT:

Nama file: Cargo.toml

[package]
name = "guessing_game"
license = "MIT"

Kalau kita mau memakai lisensi yang tidak muncul di SPDX, kita perlu menaruh teks dari lisensi tersebut di sebuah file, memasukkan file itu di project kita, lalu memakai license-file buat menentukan nama dari file tersebut sebagai ganti dari memakai key license.

Panduan soal lisensi mana yang pas buat project kita ada di luar cakupan buku ini. Banyak orang di komunitas Rust melisensikan project mereka pakai cara yang sama kayak Rust dengan memakai dual license (lisensi ganda) MIT OR Apache-2.0. Praktik ini menunjukkan kalau kita juga bisa menentukan lebih dari satu pengenal lisensi yang dipisahkan oleh OR buat punya banyak lisensi di project kita.

Dengan nama yang unik, versi, deskripsi kita, dan lisensi yang sudah ditambahkan, file Cargo.toml untuk project yang siap dipublish mungkin bakal kelihatan kayak gini:

Nama file: Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2024"
description = "A fun game where you guess what number the computer has chosen."
license = "MIT OR Apache-2.0"

[dependencies]

Dokumentasi Cargo mendeskripsikan metadata lain yang bisa kita tentukan buat memastikan orang lain bisa lebih gampang menemukan dan memakai crate kita.

Mempublikasikan ke Crates.io

Sekarang karena kita udah bikin akun, nyimpan token API kita, memilih nama buat crate kita, dan menentukan metadata yang dibutuhin, kita sudah siap buat melakukan publikasi! Mempublikasikan sebuah crate mengunggah versi spesifiknya ke crates.io biar orang lain bisa pakai.

Hati-hati ya, karena mempublikasikan itu sifatnya permanen. Versi itu tidak akan pernah bisa ditimpa (overwritten), dan kodenya tidak bisa dihapus kecuali di situasi-situasi tertentu. Salah satu tujuan utama dari Crates.io adalah bertindak sebagai arsip kode yang permanen sehingga proses build dari semua project yang bergantung pada crates dari crates.io bakal terus berfungsi. Mengizinkan penghapusan versi bakal bikin pemenuhan tujuan tersebut jadi mustahil. Namun, tidak ada batas buat seberapa banyak versi crate yang bisa kita publikasikan.

Jalankan perintah cargo publish lagi. Kali ini harusnya udah berhasil:

$ cargo publish
    Updating crates.io index
   Packaging guessing_game v0.1.0 (file:///projects/guessing_game)
    Packaged 6 files, 1.2KiB (895.0B compressed)
   Verifying guessing_game v0.1.0 (file:///projects/guessing_game)
   Compiling guessing_game v0.1.0
(file:///projects/guessing_game/target/package/guessing_game-0.1.0)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.19s
   Uploading guessing_game v0.1.0 (file:///projects/guessing_game)
    Uploaded guessing_game v0.1.0 to registry `crates-io`
note: waiting for `guessing_game v0.1.0` to be available at registry
`crates-io`.
You may press ctrl-c to skip waiting; the crate should be available shortly.
   Published guessing_game v0.1.0 at registry `crates-io`

Selamat! Kita sekarang sudah nge-share kode kita sama komunitas Rust, dan siapa pun bisa dengan gampang nambahin crate kita sebagai dependency di project mereka.

Mempublikasikan Versi Baru dari Crate yang Sudah Ada

Saat kita bikin perubahan di crate kita dan udah siap buat ngerilis versi baru, kita cukup ganti nilai version yang ditentukan di file Cargo.toml kita dan publish ulang (republish). Pakai aturan Semantic Versioning buat nentuin apa nomor versi selanjutnya yang paling pas, berdasarkan jenis perubahan yang sudah kita buat. Terus jalankan cargo publish buat mengunggah versi barunya.

Melarang Penggunaan Versi Lama dari Crates.io dengan cargo yank

Meskipun kita tidak bisa menghapus versi lama dari sebuah crate, kita bisa mencegah project-project di masa depan buat menambahkan versi tersebut sebagai dependency baru. Hal ini berguna pas sebuah versi crate ternyata rusak (broken) karena suatu alasan tertentu. Di situasi semacam itu, Cargo mendukung aksi “menyentak” (yanking) sebuah versi crate.

Yanking sebuah versi mencegah project baru untuk bisa bergantung pada versi tersebut sekaligus tetap membiarkan semua project yang sudah ada yang bergantung pada versi itu buat terus berjalan. Pada dasarnya, aksi yank berarti bahwa semua project yang sudah punya file Cargo.lock tidak akan rusak, dan file Cargo.lock apa pun yang di-generate di masa depan tidak bakal memakai versi yang di-yank tersebut.

Buat menge-yank sebuah versi crate, dari dalam direktori crate yang tadinya sudah kita publikasikan, jalankan cargo yank dan tentukan versi mana yang mau kita yank. Misalnya, kalau kita sudah mempublikasikan sebuah crate bernama guessing_game versi 1.0.1 dan kita mau menge-yank-nya, di dalam direktori project buat guessing_game kita bakal menjalankan:

$ cargo yank --vers 1.0.1
    Updating crates.io index
        Yank guessing_game@1.0.1

Dengan menambahkan --undo ke dalam command-nya, kita juga bisa membatalkan aksi yank (meng-unyank) lalu mengizinkan project-project buat mulai bergantung lagi pada sebuah versi:

$ cargo yank --vers 1.0.1 --undo
    Updating crates.io index
      Unyank guessing_game@1.0.1

Aksi yank sama sekali tidak menghapus kode apa pun. Ia tidak bisa, misalnya, menghapus data rahasia (secrets) yang tidak sengaja terunggah. Kalau hal seperti itu terjadi, kita harus langsung nge-reset rahasia tersebut.

Cargo Workspaces

Cargo Workspaces

Di Bab 12, kita udah bikin sebuah package yang isinya satu binary crate sama satu library crate. Seiring berkembangnya project kita, kita mungkin menemukan bahwa library crate kita terus jadi makin besar dan kita mau membagi package kita lebih jauh lagi jadi beberapa library crates. Cargo menawarkan fitur bernama workspaces (ruang kerja) yang bisa membantu mengelola beberapa packages yang saling terkait yang dikembangkan secara beriringan (in tandem).

Membuat Workspace

Sebuah workspace adalah sekumpulan packages yang berbagi Cargo.lock dan direktori output yang sama. Mari kita bikin project yang memakai workspace—kita bakal pakai kode yang sepele biar kita bisa fokus ke struktur dari workspace tersebut. Ada banyak cara buat menata struktur sebuah workspace, jadi kita cuma bakal nunjukin satu cara yang umum. Kita bakal punya sebuah workspace yang berisi satu binary dan dua libraries. Si binary, yang bakal menyediakan fungsionalitas utama, bakal bergantung pada (depend on) kedua libraries itu. Satu library bakal menyediakan fungsi add_one dan library yang satunya lagi menyediakan fungsi add_two. Ketiga crates ini bakal jadi bagian dari workspace yang sama. Kita bakal memulainya dengan membuat direktori baru buat workspace tersebut:

$ mkdir add
$ cd add

Berikutnya, di dalam direktori add, kita bikin file Cargo.toml yang bakal mengkonfigurasi seluruh workspace. File ini tidak bakal punya bagian [package]. Sebaliknya, file ini bakal diawali dengan bagian [workspace] yang bakal memungkinkan kita buat menambahkan anggota (members) ke dalam workspace. Kita juga sengaja menentukan buat memakai algoritma resolver (pemecah) Cargo versi yang paling baru dan paling bagus di workspace kita dengan menge-set nilai resolver ke "3".

Nama file: Cargo.toml

[workspace]
resolver = "3"

Selanjutnya, kita bakal membikin binary crate adder dengan menjalankan cargo new di dalam direktori add:

$ cargo new adder
     Created binary (application) `adder` package
      Adding `adder` as member of workspace at `file:///projects/add`

Menjalankan cargo new di dalam sebuah workspace juga secara otomatis menambahkan package yang baru dibuat itu ke dalam key members di definisi [workspace] yang ada di Cargo.toml tingkat workspace, kayak gini:

[workspace]
resolver = "3"
members = ["adder"]

Pada titik ini, kita bisa mem-build workspace ini dengan menjalankan cargo build. File-file di direktori add kita seharusnya kelihatan seperti ini:

├── Cargo.lock
├── Cargo.toml
├── adder
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

Workspace ini cuma punya satu direktori target di tingkat teratas (top level) tempat artefak hasil kompilasi bakal ditaruh; package adder tidak punya direktori target-nya sendiri. Bahkan kalau pun kita menjalankan cargo build dari dalam direktori adder, artefak hasil kompilasinya bakal tetap berujung di add/target bukannya di add/adder/target. Cargo menata struktur direktori target di sebuah workspace seperti ini karena crates di dalam sebuah workspace itu memang ditujukan buat bergantung satu sama lain. Kalau setiap crate punya direktori target-nya sendiri, setiap crate harus men-compile ulang setiap crate lainnya di dalam workspace itu buat menaruh artefaknya di direktori target-nya sendiri-sendiri. Dengan berbagi satu direktori target, crates bisa menghindari kompilasi ulang yang tidak diperlukan.

Membuat Package Kedua di dalam Workspace

Berikutnya, mari kita buat member package (paket anggota) lain di dalam workspace ini dan namakan dia add_one. Generate sebuah library crate baru bernama add_one:

$ cargo new add_one --lib
     Created library `add_one` package
      Adding `add_one` as member of workspace at `file:///projects/add`

File Cargo.toml tingkat teratas sekarang bakal menyertakan path add_one ke dalam daftar members:

Nama file: Cargo.toml

[workspace]
resolver = "3"
members = ["adder", "add_one"]

Direktori add kita seharusnya sekarang punya direktori dan file berikut ini:

├── Cargo.lock
├── Cargo.toml
├── add_one
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── adder
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

Di dalam file add_one/src/lib.rs, mari kita tambahkan sebuah fungsi add_one:

Nama file: add_one/src/lib.rs

pub fn add_one(x: i32) -> i32 {
    x + 1
}

Sekarang kita bisa bikin package adder dengan binary kita bergantung pada package add_one yang punya library kita. Pertama-tama, kita harus menambahkan path dependency pada add_one ke dalam adder/Cargo.toml.

Nama file: adder/Cargo.toml

[dependencies]
add_one = { path = "../add_one" }

Cargo tidak mengasumsikan kalau crates di dalam sebuah workspace bakal bergantung satu sama lain, jadi kita harus secara eksplisit mendefinisikan hubungan dependensi (ketergantungan) mereka.

Selanjutnya, mari kita pakai fungsi add_one (dari crate add_one) di dalam crate adder. Buka file adder/src/main.rs dan ubah fungsi main buat memanggil fungsi add_one, seperti di Listing 14-7.

Filename: adder/src/main.rs
fn main() {
    let num = 10;
    println!("Hello, world! {num} plus one is {}!", add_one::add_one(num));
}
Listing 14-7: Memakai library crate add_one dari dalam crate adder

Mari kita build workspace ini dengan menjalankan cargo build di direktori tingkat teratas add!

$ cargo build
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.22s

Buat menjalankan binary crate tersebut dari direktori add, kita bisa menentukan package mana di dalam workspace yang mau kita jalankan dengan memakai argumen -p beserta nama package-nya dengan cargo run:

$ cargo run -p adder
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/adder`
Hello, world! 10 plus one is 11!

Ini bakal menjalankan kode di adder/src/main.rs, yang mana bergantung pada crate add_one.

Bergantung pada Package Eksternal di dalam Workspace

Perhatikan bahwa workspace ini cuma punya satu file Cargo.lock di tingkat teratas, bukannya punya file Cargo.lock di setiap direktori crate. Ini memastikan kalau semua crates memakai versi yang persis sama buat semua dependensinya. Kalau kita menambahkan package rand ke dalam file adder/Cargo.toml dan add_one/Cargo.toml, Cargo bakal me-resolve keduanya ke satu versi dari rand dan mencatat hal itu di dalam satu file Cargo.lock tersebut. Membuat semua crates di workspace memakai dependensi yang sama berarti semua crates tersebut bakal selalu kompatibel satu sama lain. Mari kita tambahkan crate rand ke bagian [dependencies] di file add_one/Cargo.toml supaya kita bisa memakai crate rand di dalam crate add_one:

Nama file: add_one/Cargo.toml

[dependencies]
rand = "0.8.5"

Sekarang kita bisa menambahkan use rand; ke dalam file add_one/src/lib.rs, dan saat mem-build seluruh workspace dengan menjalankan cargo build di direktori add bakal ikut membawa dan men-compile crate rand. Kita bakal dapat satu peringatan (warning) karena kita belum memakai rand yang sudah kita bawa ke dalam scope tersebut:

$ cargo build
    Updating crates.io index
  Downloaded rand v0.8.5
   --snip--
   Compiling rand v0.8.5
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
warning: unused import: `rand`
 --> add_one/src/lib.rs:1:5
  |
1 | use rand;
  |     ^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

warning: `add_one` (lib) generated 1 warning (run `cargo fix --lib -p add_one` to apply 1 suggestion)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s

File Cargo.lock di tingkat teratas sekarang berisi informasi mengenai dependensi dari add_one terhadap rand. Namun, meskipun rand sudah dipakai di suatu tempat di dalam workspace, kita tidak bisa memakainya di crates lainnya di workspace ini kecuali kita menambahkan rand ke dalam file Cargo.toml mereka juga. Misalnya, kalau kita menambahkan use rand; ke dalam file adder/src/main.rs untuk package adder, kita bakal dapat error:

$ cargo build
  --snip--
   Compiling adder v0.1.0 (file:///projects/add/adder)
error[E0432]: unresolved import `rand`
 --> adder/src/main.rs:2:5
  |
2 | use rand;
  |     ^^^^ no external crate `rand`

Buat memperbaikinya, edit file Cargo.toml untuk package adder dan indikasikan kalau rand juga adalah sebuah dependensi buatnya. Mem-build package adder bakal menambahkan rand ke dalam daftar dependensi untuk adder di dalam Cargo.lock, tapi tidak akan ada salinan tambahan dari rand yang bakal di-download. Cargo bakal memastikan kalau setiap crate di setiap package di dalam workspace yang memakai package rand bakal memakai versi yang persis sama selama mereka menentukan versi dari rand yang kompatibel, hal ini menghemat kapasitas penyimpanan kita dan memastikan kalau semua crates di workspace ini bakal kompatibel satu sama lain.

Kalau crates di workspace menentukan versi yang tidak kompatibel dari dependensi yang sama, Cargo bakal mencoba me-resolve masing-masing dari mereka, tapi tetap bakal berusaha me-resolve ke sesedikit mungkin versi.

Menambahkan Pengujian ke Workspace

Untuk peningkatan selanjutnya, mari kita tambahkan sebuah pengujian buat fungsi add_one::add_one di dalam crate add_one:

Nama file: add_one/src/lib.rs

pub fn add_one(x: i32) -> i32 {
    x + 1
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        assert_eq!(3, add_one(2));
    }
}

Sekarang jalankan cargo test di dalam direktori add di tingkat teratas. Menjalankan cargo test di sebuah workspace yang ditata seperti ini bakal menjalankan pengujian untuk semua crates di dalam workspace tersebut:

$ cargo test
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.20s
     Running unittests src/lib.rs (target/debug/deps/add_one-93c49ee75dc46543)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/adder-3a47283c568d2b6a)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests add_one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Bagian pertama dari outputnya menunjukkan kalau pengujian it_works di dalam crate add_one itu sukses (passed). Bagian selanjutnya menunjukkan kalau ada nol pengujian yang ditemukan di dalam crate adder, dan lalu bagian terakhir menunjukkan kalau ada nol pengujian dokumentasi yang ditemukan di dalam crate add_one.

Kita juga bisa menjalankan pengujian buat satu crate tertentu di dalam sebuah workspace dari direktori tingkat teratas dengan memakai flag -p dan menentukan nama dari crate yang mau kita uji:

$ cargo test -p add_one
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running unittests src/lib.rs (target/debug/deps/add_one-93c49ee75dc46543)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests add_one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Output ini menunjukkan kalau cargo test cuma menjalankan pengujian untuk crate add_one dan tidak menjalankan pengujian untuk crate adder.

Kalau kita mempublikasikan crates yang ada di dalam workspace ke crates.io, setiap crate di dalam workspace itu harus dipublikasikan secara terpisah. Sama seperti cargo test, kita bisa mempublikasikan satu crate tertentu di dalam workspace kita dengan memakai flag -p dan menentukan nama dari crate yang mau kita publikasikan.

Sebagai latihan tambahan, coba tambahkan crate add_two ke dalam workspace ini dengan cara yang sama seperti crate add_one!

Seiring project kita bertambah besar, pertimbangkanlah buat memakai sebuah workspace: ini memungkinkan kita buat bekerja dengan komponen-komponen yang lebih kecil dan lebih gampang dipahami ketimbang bekerja dengan satu gumpalan kode (blob of code) yang super besar. Selain itu, menyimpan crates di dalam sebuah workspace bisa bikin koordinasi antar crates jadi lebih mudah kalau mereka sering diubah secara bersamaan.

Menginstal Binari Memakai cargo install

Menginstal Binaries dengan cargo install

Perintah cargo install memungkinkan kita buat menginstal dan memakai binary crates secara lokal. Ini tidak ditujukan buat menggantikan sistem packages; ini ditujukan sebagai cara yang praktis buat para developer Rust untuk menginstal tools yang udah di-share sama orang lain di crates.io. Perhatikan bahwa kita cuma bisa menginstal packages yang punya binary targets. Sebuah binary target adalah program yang bisa dijalankan (runnable) yang dibikin kalau crate tersebut punya file src/main.rs atau file lain yang ditentukan sebagai binary, kebalikan dari library target yang tidak bisa dijalankan secara mandiri melainkan cocok buat dimasukkan ke dalam program lain. Biasanya, crates punya informasi di dalam file README soal apakah sebuah crate itu library, punya binary target, atau dua-duanya.

Semua binaries yang diinstal pakai cargo install disimpan di dalam folder bin di direktori root instalasi. Kalau kita menginstal Rust pakai rustup.rs dan tidak punya konfigurasi kustom apa pun, direktori ini bakal ada di $HOME/.cargo/bin. Pastikan direktori tersebut ada di dalam $PATH kita biar kita bisa menjalankan program-program yang udah kita instal pakai cargo install.

Misalnya, di Bab 12 kita sempat menyinggung kalau ada implementasi Rust dari tool grep yang bernama ripgrep buat nyari-nyari file. Buat menginstal ripgrep, kita bisa menjalankan yang berikut ini:

$ cargo install ripgrep
    Updating crates.io index
  Downloaded ripgrep v14.1.1
  Downloaded 1 crate (213.6 KB) in 0.40s
  Installing ripgrep v14.1.1
--snip--
   Compiling grep v0.3.2
    Finished `release` profile [optimized + debuginfo] target(s) in 6.73s
  Installing ~/.cargo/bin/rg
   Installed package `ripgrep v14.1.1` (executable `rg`)

Dua baris terakhir dari output-nya menunjukkan lokasi dan nama dari binary yang udah diinstal, yang mana di kasus ripgrep namanya adalah rg. Selama direktori instalasinya ada di dalam $PATH kita, seperti yang udah disebutkan sebelumnya, kita kemudian bisa menjalankan rg --help dan mulai memakai tool yang lebih kencang dan lebih bergaya Rust buat nyari file!

Memperluas Cargo Memakai Command Kustom

Memperluas Kemampuan Cargo dengan Custom Commands (Perintah Kustom)

Cargo didesain biar kita bisa memperluas kemampuannya dengan subcommands baru tanpa harus memodifikasi Cargo itu sendiri. Kalau sebuah binary di dalam $PATH kita bernama cargo-sesuatu, kita bisa menjalankannya seolah-olah itu adalah subcommand Cargo dengan menjalankan cargo sesuatu. Custom commands kayak gini juga terdaftar saat kita menjalankan cargo --list. Kemampuan buat memakai cargo install untuk menginstal ekstensi-ekstensi lalu menjalankannya persis seperti tools bawaan Cargo adalah salah satu keuntungan super-nyaman dari desain Cargo!

Ringkasan

Menge-share kode pakai Cargo dan crates.io adalah bagian dari hal yang bikin ekosistem Rust jadi berguna buat berbagai macam tugas. Standard library Rust itu kecil dan stabil, tapi crates itu gampang sekali buat di-share, dipakai, dan ditingkatkan di timeline (garis waktu) yang beda dari timeline bahasa Rust itu sendiri. Jangan malu-malu buat nge-share kode yang berguna buat kita di crates.io; kemungkinan besar kode itu bakal berguna buat orang lain juga!

Smart Pointers

Pointer (penunjuk) adalah sebuah konsep umum buat sebuah variabel yang mengandung sebuah alamat di memori. Alamat ini merujuk ke, atau “menunjuk ke,” data lain. Jenis pointer yang paling umum di Rust adalah sebuah referensi, yang udah kita pelajari di Bab 4. Referensi ditandai oleh simbol & dan meminjam (borrow) nilai yang mereka tunjuk. Mereka tidak punya kemampuan spesial apa-apa selain cuma merujuk ke data, dan mereka tidak punya biaya overhead.

Smart pointers (penunjuk pintar), di sisi lain, adalah struktur data yang bertindak seperti sebuah pointer tapi juga punya metadata dan kemampuan tambahan. Konsep smart pointers ini bukan cuma ada di Rust: smart pointers asalnya dari C++ dan ada di bahasa pemrograman lain juga. Rust punya berbagai macam smart pointers yang didefinisikan di standard library yang menyediakan fungsionalitas lebih dari sekadar apa yang disediakan oleh referensi biasa. Buat mengeksplorasi konsep umumnya, kita bakal melihat beberapa contoh smart pointers yang berbeda, termasuk tipe smart pointer reference counting (penghitungan referensi). Pointer ini memungkinkan kita buat membiarkan sebuah data punya banyak pemilik (owners) dengan melacak (keeping track of) jumlah pemilik yang ada dan, saat tidak ada pemilik yang tersisa, membersihkan (cleaning up) datanya.

Rust, dengan konsep ownership dan borrowing-nya, punya perbedaan tambahan antara referensi dan smart pointers: walaupun referensi cuma meminjam data, di banyak kasus smart pointers memiliki (own) data yang mereka tunjuk.

Smart pointers biasanya diimplementasikan memakai structs. Tidak seperti struct biasa, smart pointers mengimplementasikan trait Deref dan Drop. Trait Deref memungkinkan sebuah instance dari struct smart pointer berperilaku seperti sebuah referensi sehingga kita bisa menulis kode yang bisa bekerja buat referensi maupun buat smart pointers. Trait Drop memungkinkan kita buat mengkustomisasi kode yang bakal dijalankan saat sebuah instance dari smart pointer keluar dari scope. Di bab ini, kita bakal membahas kedua trait tersebut dan mendemonstrasikan kenapa mereka itu penting buat smart pointers.

Mengingat kalau smart pointer itu adalah sebuah desain pola yang umum dan sering sekali dipakai di Rust, bab ini tidak akan membahas setiap smart pointer yang pernah ada. Banyak libraries punya smart pointers mereka sendiri, dan kita bahkan bisa bikin smart pointer kita sendiri. Kita bakal membahas smart pointers yang paling umum di standard library:

  • Box<T>, buat mengalokasikan nilai di heap
  • Rc<T>, sebuah tipe reference counting yang memungkinkan kepemilikan ganda (multiple ownership)
  • Ref<T> dan RefMut<T>, yang diakses melalui RefCell<T>, sebuah tipe yang menerapkan (enforces) aturan borrowing saat runtime ketimbang pas compile time

Selain itu, kita bakal ngebahas desain pola interior mutability (mutabilitas interior) di mana sebuah tipe yang immutable (tidak bisa diubah) mengekspos sebuah API buat memutasi nilai internalnya. Kita juga bakal ngebahas siklus referensi (reference cycles): gimana mereka bisa membocorkan memori (leak memory) dan gimana cara mencegahnya.

Mari kita selami!

Memakai Box<T> buat Menunjuk ke Data di Heap

Memakai Box<T> buat Menunjuk ke Data di Heap

Smart pointer yang paling sederhana adalah box (kotak), yang tipenya ditulis Box<T>. Boxes memungkinkan kita buat nyimpan data di heap ketimbang di stack. Apa yang tersisa di stack adalah si pointer yang menunjuk ke data di heap tersebut. Silakan merujuk lagi ke Bab 4 buat me-review perbedaan antara stack dan heap.

Boxes tidak punya overhead performa, selain harus menyimpan data mereka di heap alih-alih di stack. Tapi mereka juga tidak punya banyak kemampuan ekstra. Kita bakal paling sering memakainya di situasi-situasi berikut:

  • Saat kita punya sebuah tipe yang ukurannya tidak bisa diketahui secara pasti pas compile time dan kita mau memakai sebuah nilai dari tipe itu di konteks yang membutuhkan ukuran yang persis
  • Saat kita punya jumlah data yang besar dan kita mau mentransfer kepemilikannya (ownership) tapi tetap pengen memastikan kalau datanya tidak bakal disalin (copied) saat kita melakukannya
  • Saat kita pengen memiliki sebuah nilai dan kita cuma peduli kalau nilai itu adalah tipe yang mengimplementasikan trait tertentu, bukannya merupakan suatu tipe spesifik

Kita bakal mendemonstrasikan situasi pertama di “Memungkinkan Tipe Rekursif dengan Boxes”. Di kasus kedua, mentransfer kepemilikan dari data yang sangat besar bisa memakan waktu lama karena datanya bakal disalin mondar-mandir di stack. Buat meningkatkan performa di situasi ini, kita bisa menyimpan jumlah data yang besar itu di heap di dalam sebuah box. Dengan begitu, cuma data pointer yang kecil itu doang yang bakal disalin mondar-mandir di stack, sementara data yang ditunjuknya tetap diam di satu tempat di heap. Kasus ketiga dikenal sebagai trait object, dan “Memakai Trait Objects yang Mengizinkan Nilai Dari Tipe yang Berbeda-beda” di Bab 18 memang dikhususkan untuk membahas topik tersebut. Jadi, apa yang kita pelajari di sini bakal kita terapkan lagi di bagian itu!

Memakai Box<T> buat Menyimpan Data di Heap

Sebelum kita membahas kegunaan penyimpanan di heap untuk Box<T>, kita bakal membahas sintaksnya dan gimana cara berinteraksi sama nilai yang disimpan di dalam Box<T>.

Listing 15-1 menunjukkan gimana cara memakai box buat menyimpan nilai i32 di heap.

Filename: src/main.rs
fn main() {
    let b = Box::new(5);
    println!("b = {b}");
}
Listing 15-1: Menyimpan nilai i32 di heap menggunakan sebuah box

Kita mendefinisikan variabel b buat punya nilai berupa sebuah Box yang menunjuk ke nilai 5, yang mana nilainya itu dialokasikan di heap. Program ini bakal mencetak b = 5; di kasus ini, kita bisa mengakses data yang ada di dalam box ini mirip kayak kalau datanya ada di stack. Sama kayak nilai (owned value) lainnya, pas sebuah box keluar dari scope, sebagaimana yang terjadi pada b di akhir dari main, dia bakal di-deallocate (dihapus dari memori). Proses dealokasi ini terjadi baik untuk box-nya (yang disimpan di stack) maupun untuk data yang ditunjuknya (yang disimpan di heap).

Menaruh satu nilai tunggal di heap itu tidak terlalu berguna, jadi kita tidak akan terlalu sering memakai boxes sendirian kayak gini. Membiarkan nilai-like satu i32 tunggal ada di stack, di mana memang di situlah mereka disimpan secara default, itu lebih tepat buat mayoritas situasi. Mari kita lihat sebuah kasus di mana boxes memungkinkan kita buat mendefinisikan tipe yang tidak bakal diizinkan untuk didefinisikan kalau kita tidak punya boxes.

Memungkinkan Tipe Rekursif dengan Boxes

Nilai dari sebuah tipe rekursif (recursive type) bisa punya nilai lain dari tipe yang sama sebagai bagian dari dirinya sendiri. Tipe rekursif ini menimbulkan masalah karena Rust perlu tahu saat compile time seberapa banyak ruang (space) yang dipakai sama sebuah tipe. Namun, nilai yang bersarang (nesting) di tipe rekursif ini secara teoritis bisa terus berlanjut tanpa batas, jadi Rust tidak bisa tahu berapa banyak ruang yang dibutuhkan sama nilai tersebut. Karena boxes punya ukuran yang sudah pasti diketahui, kita bisa memungkinkan tipe rekursif ini dengan menyelipkan (inserting) sebuah box ke dalam definisi tipe rekursifnya.

Sebagai contoh tipe rekursif, mari kita eksplorasi cons list. Ini adalah tipe data yang sering sekali dijumpai di bahasa pemrograman fungsional. Tipe cons list yang bakal kita definisikan itu mudah dipahami kecuali pada bagian rekursinya; maka dari itu, konsep-konsep di dalam contoh yang bakal kita kerjakan ini bakal berguna kapan pun kita masuk ke situasi yang lebih kompleks yang melibatkan tipe rekursif.

Info Lebih Lanjut soal Cons List

Sebuah cons list adalah struktur data yang berasal dari bahasa pemrograman Lisp dan dialek-dialeknya, yang disusun dari pasangan (pairs) yang bersarang, dan ini adalah versi Lisp dari linked list. Namanya datang dari fungsi cons (kependekan dari fungsi construct) di Lisp yang mengonstruksi sebuah pasangan baru dari dua argumennya. Dengan memanggil cons pada sebuah pasangan yang terdiri dari sebuah nilai dan pasangan lain, kita bisa mengonstruksi cons lists yang terbuat dari pasangan yang rekursif.

Misalnya, ini adalah representasi pseudocode (kode semu) dari sebuah cons list yang berisi list 1, 2, 3 dengan setiap pasangan ditaruh di dalam tanda kurung:

(1, (2, (3, Nil)))

Tiap item di dalam cons list terdiri dari dua elemen: nilai dari item saat ini dan item selanjutnya. Item terakhir di dalam list hanya terdiri dari sebuah nilai bernama Nil tanpa ada item berikutnya. Sebuah cons list diproduksi dengan memanggil fungsi cons secara rekursif. Nama standar (canonical name) untuk menyebut kasus dasar (base case) dari rekursi ini adalah Nil. Perhatikan bahwa ini tidak sama dengan konsep “null” atau “nil” yang dibahas di Bab 6, yang mana itu artinya nilai yang tidak valid atau absen.

Cons list bukanlah struktur data yang sering dipakai di Rust. Kebanyakan waktunya saat kita punya sebuah list yang berisi item-item di Rust, Vec<T> adalah pilihan yang lebih baik buat dipakai. Namun, tipe data rekursif lainnya yang lebih kompleks memang berguna di berbagai situasi, tapi dengan memulai pakai cons list di bab ini, kita bisa mengeksplorasi gimana boxes memungkinkan kita buat mendefinisikan tipe data rekursif tanpa banyak gangguan.

Listing 15-2 mengandung sebuah definisi enum untuk sebuah cons list. Perhatikan kalau kode ini belum bisa di-compile karena tipe List tidak punya ukuran yang pasti, yang mana bakal kita demonstrasikan.

Filename: src/main.rs
enum List {
    Cons(i32, List),
    Nil,
}

fn main() {}
Listing 15-2: Usaha pertama buat mendefinisikan sebuah enum buat merepresentasikan struktur data cons list dari nilai i32

Catatan: Kita mengimplementasikan sebuah cons list yang menampung cuma nilai i32 aja demi tujuan contoh ini. Kita bisa saja mengimplementasikannya memakai generik (generics), seperti yang sudah kita bahas di Bab 10, buat mendefinisikan tipe cons list yang bisa menyimpan nilai dari tipe apa pun.

Memakai tipe List buat menyimpan list 1, 2, 3 bakal kelihatan seperti kode di Listing 15-3.

Filename: src/main.rs
enum List {
    Cons(i32, List),
    Nil,
}

// --snip--

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}
Listing 15-3: Memakai enum List buat menyimpan list 1, 2, 3

Nilai Cons pertama menampung 1 dan nilai List lain. Nilai List ini adalah nilai Cons lain yang menampung 2 dan sebuah nilai List lainnya. Nilai List ini adalah satu lagi nilai Cons yang menampung 3 dan sebuah nilai List, yang mana pada akhirnya adalah Nil, yaitu varian non-rekursif yang menandakan akhir dari list tersebut.

Kalau kita nyoba men-compile kode di Listing 15-3, kita dapat error yang ditunjukkan di Listing 15-4.

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0072]: recursive type `List` has infinite size
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
2 |     Cons(i32, List),
  |               ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

error[E0391]: cycle detected when computing when `List` needs drop
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
  |
  = note: ...which immediately requires computing when `List` needs drop again
  = note: cycle used when computing whether `List` needs drop
  = note: see https://rustc-dev-guide.rust-lang.org/overview.html#queries and https://rustc-dev-guide.rust-lang.org/query.html for more information

Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 2 previous errors
Listing 15-4: Error yang kita dapat saat mencoba mendefinisikan sebuah enum yang rekursif

Error-nya bilang kalau tipe ini “punya ukuran tak terbatas” (has infinite size). Alasannya adalah karena kita mendefinisikan List dengan sebuah varian yang rekursif: ia memegang nilai lain dari dirinya sendiri secara langsung. Sebagai hasilnya, Rust tidak bisa menghitung seberapa banyak ruang yang ia butuhkan buat menyimpan sebuah nilai List. Mari kita pecahkan masalah kenapa kita dapat error ini. Pertama kita bakal melihat gimana cara Rust memutuskan berapa banyak ruang yang ia butuhkan buat menyimpan nilai dari tipe non-rekursif.

Menghitung Ukuran dari Tipe Non-Rekursif

Ingat kembali enum Message yang kita definisikan di Listing 6-2 saat kita membahas definisi enum di Bab 6:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

Buat menentukan seberapa banyak ruang yang harus dialokasikan untuk nilai Message, Rust menelusuri setiap varian yang ada buat melihat varian mana yang butuh ruang paling banyak. Rust melihat kalau Message::Quit tidak butuh ruang sama sekali, Message::Move butuh ruang yang cukup buat menyimpan dua nilai i32, dan seterusnya. Karena cuma satu varian saja yang bakal dipakai dalam satu waktu, ruang maksimal yang bakal dibutuhkan oleh sebuah nilai Message adalah ruang yang dibutuhkan buat menyimpan varian terbesarnya.

Bandingkan ini sama apa yang terjadi saat Rust mencoba menentukan seberapa banyak ruang yang dibutuhkan oleh sebuah tipe rekursif kayak enum List di Listing 15-2. Compiler mulai dengan melihat varian Cons, yang mana memegang sebuah nilai tipe i32 dan sebuah nilai tipe List. Maka dari itu, Cons butuh jumlah ruang yang setara dengan ukuran dari i32 ditambah sama ukuran dari List. Buat menghitung seberapa besar memori yang dibutuhkan oleh tipe List, compiler melihat variannya, yang dimulai dengan varian Cons. Varian Cons memegang nilai bertipe i32 dan sebuah nilai bertipe List, dan proses ini terus berlanjut tanpa batas, seperti yang ditunjukkan di Gambar 15-1.

Sebuah Cons list yang tak terhingga: sebuah persegi panjang dengan label 'Cons' dibelah jadi dua persegi panjang yang lebih kecil. Persegi panjang kecil pertama berisi label 'i32', dan persegi panjang kecil kedua berisi label 'Cons' dan sebuah versi kecil dari persegi panjang 'Cons' yang ada di luarnya. Persegi panjang 'Cons' ini bakal terus memegang versi diri mereka sendiri yang makin mengecil sampai akhirnya sebuah persegi panjang yang berukuran wajar memegang sebuah simbol tak terhingga (infinity), yang menandakan kalau perulangan ini terjadi selamanya

Gambar 15-1: Sebuah List yang tidak terhingga yang terdiri dari varian Cons yang tidak terhingga juga

Memakai Box<T> buat Mendapatkan Tipe Rekursif dengan Ukuran Pasti

Karena Rust tidak bisa mencari tahu seberapa banyak ruang yang harus dialokasikan buat tipe-tipe yang definisinya rekursif, compiler mengeluarkan error dengan saran yang ngebantu ini:

help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

Di saran ini, indirection berarti bahwa ketimbang menyimpan nilainya secara langsung, kita sebaiknya mengubah struktur datanya buat menyimpan nilai itu secara tidak langsung dengan menyimpan pointer yang merujuk ke nilai tersebut.

Karena sebuah Box<T> itu adalah sebuah pointer, Rust bakal selalu tahu seberapa banyak ruang yang dibutuhkan oleh sebuah Box<T>: ukuran dari sebuah pointer tidak bakal berubah tidak peduli seberapa banyak data yang dia tunjuk. Ini artinya kita bisa menaruh sebuah Box<T> di dalam varian Cons ketimbang menaruh nilai List lain secara langsung. Box<T> tersebut bakal menunjuk ke nilai List berikutnya yang mana bakal berada di heap dan bukannya berada di dalam varian Cons. Secara konsep, kita masih punya sebuah list, yang dibikin dari list yang memegang list lainnya, tapi implementasi yang ini sekarang lebih mirip dengan menaruh item-item tersebut bersebelahan satu sama lain ketimbang di dalam satu sama lain.

Kita bisa mengubah definisi dari enum List di Listing 15-2 dan pemakaian dari List di Listing 15-3 menjadi kode di Listing 15-5, yang mana sekarang bakal bisa di-compile.

Filename: src/main.rs
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}
Listing 15-5: Definisi List yang memakai Box<T> biar bisa punya ukuran yang pasti diketahui

Varian Cons butuh ukuran sebesar sebuah i32 ditambah sama ruang buat menyimpan data pointer dari box tersebut. Varian Nil tidak menyimpan nilai apa pun, jadi ia butuh lebih sedikit ruang di stack dibandingkan varian Cons. Kita sekarang tahu kalau nilai List apa pun cuma bakal memakan ruang sebesar i32 ditambah dengan ukuran dari data pointer di box-nya. Dengan memakai sebuah box, kita sudah memutuskan (broken) rantai yang tidak terhingga dan rekursif tadi, jadi compiler sekarang bisa menghitung ukuran yang dia butuhkan buat menyimpan nilai List. Gambar 15-2 menunjukkan seperti apa rupa dari varian Cons itu sekarang.

Sebuah persegi panjang berlabel 'Cons' dibelah jadi dua persegi panjang yang lebih kecil. Persegi panjang kecil pertama memegang label 'i32', dan persegi panjang kecil kedua memegang label 'Box' dengan satu persegi panjang di dalamnya yang mengandung label 'usize', merepresentasikan ukuran terbatas dari pointer _box_ tersebut

Gambar 15-2: Sebuah List yang ukurannya tidak lagi tidak terhingga karena Cons sekarang memegang sebuah Box

Boxes hanya menyediakan proses penyimpanan tidak langsung (indirection) beserta alokasi di heap; mereka tidak punya kapabilitas spesial lainnya, seperti yang bakal kita lihat di tipe-tipe smart pointer lainnya. Mereka juga tidak punya overhead performa dari kapabilitas spesial tersebut, jadi mereka bakal berguna di kasus-cases seperti cons list di mana indirection itu adalah satu-satunya fitur yang kita butuhin. Kita bakal melihat lebih banyak contoh pemakaian buat boxes di Bab 18.

Tipe Box<T> adalah sebuah smart pointer karena ia mengimplementasikan trait Deref, yang memungkinkan nilai Box<T> buat diperlakukan seperti layaknya sebuah referensi biasa. Pas sebuah nilai Box<T> keluar dari scope, data di heap yang ditunjuk sama box tersebut juga bakal dibersihkan karena adanya implementasi trait Drop. Dua trait ini bakal jadi lebih penting lagi dalam memahami fungsionalitas yang disediakan oleh tipe-tipe smart pointer lain yang bakal kita bahas di sisa bab ini. Mari kita eksplorasi dua trait ini secara lebih detail.

Memperlakukan Smart Pointers kayak Referensi Biasa

Memperlakukan Smart Pointers seperti Referensi Biasa dengan Deref

Mengimplementasikan trait Deref memungkinkan kita buat mengkustomisasi perilaku dari dereference operator (operator dereferensi) * (jangan tertukar sama operator perkalian atau operator glob). Dengan mengimplementasikan Deref sedemikian rupa sehingga sebuah smart pointer bisa diperlakukan seperti referensi biasa, kita bisa menulis kode yang beroperasi pada referensi dan memakai kode tersebut buat smart pointers juga.

Mari kita lihat dulu gimana cara kerja operator dereferensi pada referensi biasa. Kemudian kita bakal mencoba mendefinisikan sebuah tipe kustom yang berperilaku mirip Box<T>, dan melihat kenapa operator dereferensi tidak bekerja seperti referensi pada tipe yang baru kita definisikan itu. Kita bakal mengeksplorasi gimana mengimplementasikan trait Deref membikin smart pointers mungkin buat bekerja dengan cara yang mirip seperti referensi. Kemudian kita bakal melihat fitur deref coercion di Rust dan gimana ia membiarkan kita bekerja entah itu pakai referensi maupun pakai smart pointers.

Mengikuti Referensi ke Nilainya

Sebuah referensi biasa adalah salah satu jenis pointer, dan salah satu cara buat membayangkan sebuah pointer adalah sebagai tanda panah yang menunjuk ke sebuah nilai yang disimpan di tempat lain. Di Listing 15-6, kita membuat sebuah referensi ke nilai i32 lalu memakai operator dereferensi buat mengikuti referensi tersebut ke nilainya.

Filename: src/main.rs
fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-6: Memakai operator dereferensi buat mengikuti sebuah referensi ke nilai i32

Variabel x memegang sebuah nilai i32 yaitu 5. Kita menge-set y agar sama dengan sebuah referensi ke x. Kita bisa menegaskan (assert) kalau x itu sama dengan 5. Namun, kalau kita mau bikin penegasan soal nilai di dalam y, kita harus memakai *y buat mengikuti referensi tersebut ke nilai yang lagi dia tunjuk (karenanya disebut dereference) supaya compiler bisa membandingkan nilai aslinya. Begitu kita men-dereferensi y, kita punya akses ke nilai integer yang ditunjuk sama y yang bisa kita bandingkan dengan 5.

Kalau kita malah mencoba nulis assert_eq!(5, y);, kita bakal dapat error kompilasi ini:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0277]: can't compare `{integer}` with `&{integer}`
 --> src/main.rs:6:5
  |
6 |     assert_eq!(5, y);
  |     ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
  |
  = help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
  = note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)

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

Membandingkan sebuah angka dan sebuah referensi ke angka itu tidak diperbolehkan karena mereka adalah tipe yang berbeda. Kita harus memakai operator dereferensi buat mengikuti referensi tersebut ke nilai yang ditunjuknya.

Memakai Box<T> Layaknya Sebuah Referensi

Kita bisa menulis ulang kode di Listing 15-6 buat memakai sebuah Box<T> bukannya referensi; operator dereferensi yang dipakai pada Box<T> di Listing 15-7 berfungsi dengan cara yang sama kayak operator dereferensi yang dipakai pada referensi di Listing 15-6.

Filename: src/main.rs
fn main() {
    let x = 5;
    let y = Box::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-7: Memakai operator dereferensi pada sebuah Box<i32>

Perbedaan utama antara Listing 15-7 dan Listing 15-6 adalah di sini kita menge-set y agar menjadi sebuah instance dari sebuah box yang menunjuk ke salinan nilai dari x ketimbang sebuah referensi yang menunjuk ke nilai dari x. Di penegasan terakhir, kita bisa memakai operator dereferensi buat mengikuti pointer box tersebut dengan cara yang sama seperti saat y tadinya adalah sebuah referensi. Selanjutnya, kita bakal mengeksplorasi apa yang spesial dari Box<T> yang memungkinkan kita buat memakai operator dereferensi dengan mendefinisikan tipe box kita sendiri.

Mendefinisikan Smart Pointer Kita Sendiri

Mari kita bikin sebuah tipe pembungkus (wrapper type) yang mirip sama tipe Box<T> yang disediakan sama standard library buat merasakan gimana tipe smart pointer berperilaku secara berbeda dari referensi secara default. Kemudian kita bakal melihat gimana cara menambahkan kemampuan buat memakai operator dereferensi.

Catatan: Ada satu perbedaan besar antara tipe MyBox<T> yang mau kita bikin ini dengan Box<T> yang asli: versi kita ini tidak bakal menyimpan datanya di heap. Kita memfokuskan contoh ini pada Deref, jadi di mana datanya sebenarnya disimpan itu kurang penting dibanding perilaku yang mirip pointer-nya.

Tipe Box<T> pada akhirnya didefinisikan sebagai sebuah tuple struct dengan satu elemen, jadi Listing 15-8 mendefinisikan sebuah tipe MyBox<T> dengan cara yang sama. Kita juga bakal mendefinisikan sebuah fungsi new agar cocok sama fungsi new yang didefinisikan pada Box<T>.

Filename: src/main.rs
struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {}
Listing 15-8: Mendefinisikan sebuah tipe MyBox<T>

Kita mendefinisikan sebuah struct bernama MyBox dan mendeklarasikan sebuah parameter generik T karena kita mau tipe kita bisa memegang nilai dari tipe apa pun. Tipe MyBox adalah sebuah tuple struct dengan satu elemen bertipe T. Fungsi MyBox::new menerima satu parameter bertipe T dan mengembalikan sebuah instance MyBox yang memegang nilai yang dimasukkan.

Mari coba tambahkan fungsi main di Listing 15-7 ke Listing 15-8 lalu ubah kodenya biar memakai tipe MyBox<T> yang sudah kita definisikan bukannya Box<T>. Kode di Listing 15-9 tidak bakal bisa di-compile karena Rust tidak tahu gimana cara men-dereferensi MyBox.

Filename: src/main.rs
struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-9: Mencoba memakai MyBox<T> dengan cara yang sama seperti kita memakai referensi dan Box<T>

Berikut adalah error kompilasi yang dihasilkan:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
  --> src/main.rs:14:19
   |
14 |     assert_eq!(5, *y);
   |                   ^^ can't be dereferenced

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

Tipe MyBox<T> kita tidak bisa di-dereferensi karena kita belum mengimplementasikan kemampuan tersebut pada tipe kita. Untuk memungkinkan proses dereferensi dengan operator *, kita harus mengimplementasikan trait Deref.

Mengimplementasikan Trait Deref

Seperti yang sudah dibahas di “Mengimplementasikan sebuah Trait pada suatu Tipe” di Bab 10, untuk mengimplementasikan sebuah trait kita perlu menyediakan implementasi buat method-method yang diwajibkan oleh trait tersebut. Trait Deref, yang disediakan oleh standard library, mewajibkan kita buat mengimplementasikan satu method bernama deref yang meminjam self lalu mengembalikan sebuah referensi ke data internalnya. Listing 15-10 mengandung implementasi Deref buat ditambahkan ke definisi MyBox<T>.

Filename: src/main.rs
use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-10: Mengimplementasikan Deref pada MyBox<T>

Sintaks type Target = T; mendefinisikan sebuah associated type buat dipakai oleh trait Deref. Associated types adalah cara yang sedikit berbeda dalam mendeklarasikan sebuah parameter generik, tapi kita tidak usah pusing dulu soal itu buat sekarang; kita bakal membahasnya lebih detail di Bab 20.

Kita mengisi body dari method deref dengan &self.0 supaya deref mengembalikan referensi ke nilai yang mau kita akses dengan operator *; ingat kembali dari “Memakai Tuple Structs tanpa Field Bernama buat Bikin Tipe yang Beda” di Bab 5 kalau .0 mengakses nilai pertama di dalam sebuah tuple struct. Fungsi main di Listing 15-9 yang memanggil * pada nilai MyBox<T> sekarang sudah bisa di-compile, dan penegasannya sukses!

Tanpa trait Deref, compiler cuma bisa men-dereferensi referensi &. Method deref memberi compiler kemampuan buat mengambil sebuah nilai dari tipe apa pun yang mengimplementasikan Deref lalu memanggil method deref buat mendapatkan sebuah referensi & yang dia tahu gimana cara men-dereferensinya.

Saat kita memasukkan *y di Listing 15-9, di balik layar Rust sebenarnya menjalankan kode ini:

*(y.deref())

Rust mengganti operator * dengan pemanggilan ke method deref dan lalu sebuah dereferensi biasa sehingga kita tidak perlu repot mikirin apakah kita butuh memanggil method deref atau tidak. Fitur Rust ini membiarkan kita menulis kode yang fungsinya identik baik saat kita punya referensi biasa maupun tipe yang mengimplementasikan Deref.

Alasan kenapa method deref mengembalikan sebuah referensi ke sebuah nilai, dan kenapa dereferensi biasa di luar tanda kurung di *(y.deref()) itu tetap diperlukan, ada hubungannya sama sistem ownership. Kalau method deref mengembalikan nilainya secara langsung bukannya referensi ke nilainya, nilainya bakal di-move keluar dari self. Kita tidak mau mengambil kepemilikan (ownership) dari nilai internal di dalam MyBox<T> di kasus ini maupun di kebanyakan kasus di mana kita memakai operator dereferensi.

Perhatikan bahwa operator * digantikan dengan pemanggilan ke method deref dan kemudian pemanggilan ke operator * cuma satu kali saja, setiap kali kita memakai tanda * di kode kita. Karena penggantian operator * tersebut tidak berulang secara rekursif tanpa henti, kita akhirnya mendapatkan data bertipe i32, yang cocok dengan angka 5 di assert_eq! di Listing 15-9.

Deref Coercion Implisit dengan Fungsi dan Method

Deref coercion mengubah sebuah referensi ke sebuah tipe yang mengimplementasikan trait Deref menjadi sebuah referensi ke tipe lainnya. Misalnya, deref coercion bisa mengubah &String menjadi &str karena String mengimplementasikan trait Deref sedemikian rupa sehingga ia mengembalikan &str. Deref coercion adalah sebuah kemudahan yang dilakukan Rust pada argumen buat fungsi dan method, dan cuma bekerja pada tipe yang mengimplementasikan trait Deref. Hal ini terjadi secara otomatis saat kita meneruskan sebuah referensi ke nilai dari tipe tertentu sebagai argumen ke sebuah fungsi atau method yang tipenya tidak cocok dengan tipe parameter di definisi fungsi atau method tersebut. Serangkaian pemanggilan ke method deref mengubah tipe yang kita berikan menjadi tipe yang dibutuhkan sama parameternya.

Deref coercion ditambahkan ke Rust supaya para programmer yang menulis pemanggilan fungsi dan method tidak perlu menambahkan terlalu banyak referensi dan dereferensi eksplisit dengan & dan *. Fitur deref coercion juga membiarkan kita menulis lebih banyak kode yang bisa bekerja baik buat referensi maupun buat smart pointers.

Buat melihat deref coercion beraksi, mari kita gunakan tipe MyBox<T> yang kita definisikan di Listing 15-8 beserta implementasi Deref yang kita tambahkan di Listing 15-10. Listing 15-11 menunjukkan definisi dari sebuah fungsi yang punya parameter berupa string slice.

Filename: src/main.rs
fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {}
Listing 15-11: Sebuah fungsi hello yang punya parameter name bertipe &str

Kita bisa memanggil fungsi hello dengan sebuah string slice sebagai argumennya, misalnya hello("Rust");. Deref coercion memungkinkan kita buat memanggil hello dengan sebuah referensi ke sebuah nilai bertipe MyBox<String>, seperti yang ditunjukkan di Listing 15-12.

Filename: src/main.rs
use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&m);
}
Listing 15-12: Memanggil hello dengan referensi ke nilai MyBox<String>, yang mana berhasil berkat deref coercion

Di sini kita memanggil fungsi hello dengan argumen &m, yang mana adalah sebuah referensi ke sebuah nilai MyBox<String>. Karena kita mengimplementasikan trait Deref pada MyBox<T> di Listing 15-10, Rust bisa mengubah &MyBox<String> menjadi &String dengan memanggil deref. Standard library menyediakan sebuah implementasi Deref pada String yang mengembalikan sebuah string slice, dan ini ada di dokumentasi API buat Deref. Rust memanggil deref sekali lagi buat mengubah &String menjadi &str, yang mana cocok sama definisi fungsi hello.

Kalau Rust tidak mengimplementasikan deref coercion, kita harus menulis kode di Listing 15-13 bukannya kode di Listing 15-12 buat memanggil hello dengan sebuah nilai bertipe &MyBox<String>.

Filename: src/main.rs
use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&(*m)[..]);
}
Listing 15-13: Kode yang harus kita tulis kalau seandainya Rust tidak punya deref coercion

Tanda (*m) men-dereferensi MyBox<String> menjadi sebuah String. Kemudian tanda & dan [..] mengambil sebuah string slice dari String tersebut yang setara dengan keseluruhan string-nya agar cocok sama signature dari hello. Kode tanpa deref coercions ini lebih susah buat dibaca, ditulis, dan dipahami dengan semua simbol yang terlibat ini. Deref coercion membiarkan Rust menangani konversi-konversi ini buat kita secara otomatis.

Saat trait Deref didefinisikan buat tipe-tipe yang terlibat, Rust bakal menganalisis tipe-tipenya dan memakai Deref::deref sebanyak yang dibutuhkan buat mendapatkan sebuah referensi yang cocok sama tipe parameternya. Berapa kali Deref::deref perlu disisipkan itu sudah diselesaikan (resolved) pas compile time, jadi tidak ada hukuman performa saat runtime karena memanfaatkan deref coercion!

Gimana Deref Coercion Berinteraksi sama Mutabilitas

Sama seperti gimana kita memakai trait Deref buat menimpa operator * pada referensi immutable, kita juga bisa memakai trait DerefMut buat menimpa operator * pada referensi mutable.

Rust melakukan deref coercion saat ia menemukan tipe-tipe dan implementasi trait di tiga kasus ini:

  1. Dari &T ke &U saat T: Deref<Target=U>
  2. Dari &mut T ke &mut U saat T: DerefMut<Target=U>
  3. Dari &mut T ke &U saat T: Deref<Target=U>

Dua kasus pertama itu sama saja kecuali kalau yang kedua mengimplementasikan mutabilitas. Kasus pertama menyatakan kalau kita punya sebuah &T, dan T mengimplementasikan Deref ke suatu tipe U, kita bisa mendapatkan sebuah &U secara transparan. Kasus kedua menyatakan kalau deref coercion yang sama terjadi buat referensi mutable.

Kasus ketiga itu sedikit lebih tricky: Rust juga bakal me-coerce sebuah referensi mutable menjadi referensi immutable. Tapi kebalikannya itu tidak mungkin: referensi immutable tidak bakal pernah bisa di-coerce menjadi referensi mutable. Karena aturan borrowing, kalau kita punya sebuah referensi mutable, referensi mutable tersebut haruslah menjadi satu-satunya referensi ke data tersebut (kalau tidak, programnya tidak bakal bisa di-compile). Mengonversi satu referensi mutable menjadi satu referensi immutable tidak bakal pernah melanggar aturan borrowing. Mengonversi sebuah referensi immutable menjadi referensi mutable bakal mewajibkan kalau referensi immutable awalnya adalah satu-satunya referensi immutable ke data tersebut, tapi aturan borrowing tidak menjamin hal itu. Oleh karena itu, Rust tidak bisa membuat asumsi kalau mengonversi sebuah referensi immutable menjadi referensi mutable itu mungkin dilakukan.

Menjalankan Kode saat Bersih-bersih Memakai Trait Drop

Menjalankan Kode saat Proses Pembersihan (Cleanup) dengan Trait Drop

Trait kedua yang penting buat pola smart pointer adalah Drop, yang membiarkan kita mengkustomisasi apa yang terjadi saat sebuah nilai bakal keluar dari scope. Kita bisa menyediakan sebuah implementasi buat trait Drop di tipe apa pun, dan kode tersebut bisa dipakai buat melepaskan (release) resources kayak file atau koneksi jaringan.

Kita ngenalin Drop di konteks smart pointers karena fungsionalitas dari trait Drop hampir selalu dipakai pas kita mengimplementasikan sebuah smart pointer. Misalnya, saat sebuah Box<T> di-drop, dia bakal men-deallocate (melepaskan) ruang di heap yang ditunjuk sama box tersebut.

Di beberapa bahasa, buat tipe-tipe tertentu, si programmer harus memanggil kode buat membebaskan memori atau resources setiap kali mereka kelar memakai sebuah instance dari tipe-tipe tersebut. Contohnya termasuk file handles (pegangan file), sockets, sama locks. Kalau mereka lupa, sistem bisa jadi overloaded dan crash. Di Rust, kita bisa menentukan bahwa sekumpulan kode tertentu bakal dijalankan setiap kali sebuah nilai keluar dari scope, dan compiler bakal menyisipkan (insert) kode ini secara otomatis. Hasilnya, kita tidak perlu repot-repot menaruh kode cleanup (pembersihan) di mana-mana di dalam program setiap kali sebuah instance dari suatu tipe sudah selesai dipakai—dan kita tetap tidak bakal membocorkan (leak) resources!

Kita menentukan kode yang bakal jalan saat sebuah nilai keluar dari scope dengan mengimplementasikan trait Drop. Trait Drop mewajibkan kita buat mengimplementasikan satu method bernama drop yang menerima referensi mutable ke self. Buat melihat kapan Rust memanggil drop, mari kita mengimplementasikan drop dengan statements println! dulu buat sekarang.

Listing 15-14 menunjukkan sebuah struct CustomSmartPointer yang satu-satunya fungsionalitas kustom yang dia punya adalah dia bakal mencetak Dropping CustomSmartPointer! saat instance-nya keluar dari scope, buat menunjukkan kapan Rust menjalankan method drop.

Filename: src/main.rs
struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("my stuff"),
    };
    let d = CustomSmartPointer {
        data: String::from("other stuff"),
    };
    println!("CustomSmartPointers created");
}
Listing 15-14: Sebuah struct CustomSmartPointer yang mengimplementasikan trait Drop di mana kita bakal menaruh kode cleanup kita

Trait Drop sudah dimasukkan ke dalam prelude, jadi kita tidak perlu membawa trait itu ke dalam scope. Kita mengimplementasikan trait Drop pada CustomSmartPointer dan menyediakan sebuah implementasi buat method drop yang memanggil println!. Body dari method drop adalah tempat di mana kita bakal menaruh logika apa pun yang mau kita jalankan pas sebuah instance dari tipe kita keluar dari scope. Kita mencetak sedikit teks di sini buat mendemonstrasikan secara visual kapan Rust bakal memanggil drop.

Di main, kita bikin dua instance dari CustomSmartPointer lalu mencetak CustomSmartPointers created. Di akhir main, instance-instance dari CustomSmartPointer kita bakal keluar dari scope, dan Rust bakal memanggil kode yang kita taruh di method drop, yang mana mencetak pesan terakhir kita. Perhatikan bahwa kita tidak perlu memanggil method drop secara eksplisit.

Pas kita menjalankan program ini, kita bakal melihat output berikut:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running `target/debug/drop-example`
CustomSmartPointers created
Dropping CustomSmartPointer with data `other stuff`!
Dropping CustomSmartPointer with data `my stuff`!

Rust secara otomatis memanggil drop buat kita pas instance-instance kita keluar dari scope, dan menjalankan kode yang sudah kita tentukan. Variabel-variabel di-drop dalam urutan yang berlawanan dari pembuatannya, jadi d di-drop sebelum c. Contoh ini tujuannya adalah buat ngasih kita panduan visual soal gimana method drop itu bekerja; biasanya kita bakal menentukan kode cleanup yang dibutuhkan sama tipe kita, ketimbang cuma pesan print biasa.

Sayangnya, menonaktifkan fungsionalitas drop otomatis ini tidak gampang. Menonaktifkan drop biasanya juga tidak diperlukan; keseluruhan poin dari trait Drop adalah supaya hal itu diurus secara otomatis. Tapi, terkadang, kita mungkin pengen membersihkan (clean up) sebuah nilai lebih awal. Salah satu contohnya adalah pas lagi memakai smart pointers yang mengelola locks: kita mungkin pengen memaksa method drop yang bakal melepaskan lock itu agar kode lain di scope yang sama bisa mendapatkan lock tersebut. Rust tidak membiarkan kita buat memanggil method drop dari trait Drop secara manual; sebaliknya, kita harus memanggil fungsi std::mem::drop yang disediakan sama standard library kalau kita mau memaksa sebuah nilai buat di-drop sebelum akhir dari scope-nya.

Kalau kita mencoba memanggil method drop dari trait Drop secara manual dengan memodifikasi fungsi main dari Listing 15-14, seperti yang ditunjukkan di Listing 15-15, kita bakal dapat error compiler.

Filename: src/main.rs
struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("some data"),
    };
    println!("CustomSmartPointer created");
    c.drop();
    println!("CustomSmartPointer dropped before the end of main");
}
Listing 15-15: Mencoba memanggil method drop dari trait Drop secara manual untuk cleanup lebih awal

Pas kita nyoba men-compile kode ini, kita bakal dapat error ini:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
error[E0040]: explicit use of destructor method
  --> src/main.rs:16:7
   |
16 |     c.drop();
   |       ^^^^ explicit destructor calls not allowed
   |
help: consider using `drop` function
   |
16 -     c.drop();
16 +     drop(c);
   |

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

Pesan error ini menyatakan kalau kita tidak diizinkan buat memanggil drop secara eksplisit. Pesan error ini memakai istilah destructor (destruktor), yang mana adalah istilah pemrograman umum buat sebuah fungsi yang membersihkan sebuah instance. Sebuah destructor analog (mirip) dengan sebuah constructor (konstruktor), yang membikin sebuah instance. Fungsi drop di Rust adalah salah satu destruktor tertentu.

Rust tidak membiarkan kita memanggil drop secara eksplisit karena Rust tetap bakal secara otomatis memanggil drop pada nilainya di akhir dari main. Hal ini bakal menyebabkan error double free karena Rust bakal mencoba membersihkan nilai yang sama dua kali.

Kita tidak bisa menonaktifkan penyisipan drop secara otomatis pas sebuah nilai keluar dari scope, dan kita tidak bisa memanggil method drop secara eksplisit. Jadi, kalau kita butuh memaksa sebuah nilai buat dibersihkan lebih awal, kita memakai fungsi std::mem::drop.

Fungsi std::mem::drop berbeda dari method drop yang ada di trait Drop. Kita memanggilnya dengan meneruskan nilai yang mau kita paksa untuk di-drop sebagai argumennya. Fungsi ini ada di dalam prelude, jadi kita bisa memodifikasi main di Listing 15-15 buat memanggil fungsi drop tersebut, seperti yang ditunjukkan di Listing 15-16.

Filename: src/main.rs
struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("some data"),
    };
    println!("CustomSmartPointer created");
    drop(c);
    println!("CustomSmartPointer dropped before the end of main");
}
Listing 15-16: Memanggil std::mem::drop buat me-drop secara eksplisit sebuah nilai sebelum dia keluar dari scope

Menjalankan kode ini bakal mencetak yang berikut ini:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/drop-example`
CustomSmartPointer created
Dropping CustomSmartPointer with data `some data`!
CustomSmartPointer dropped before the end of main

Teks Dropping CustomSmartPointer with data `some data`! dicetak di antara teks CustomSmartPointer created. dan CustomSmartPointer dropped before the end of main., menunjukkan kalau kode method drop dipanggil buat me-drop c pada titik tersebut.

Kita bisa memakai kode yang ditentukan di dalam implementasi trait Drop pakai berbagai cara buat membikin cleanup jadi nyaman dan aman: misalnya, kita bisa memakainya buat membikin memory allocator kita sendiri! Dengan trait Drop dan sistem ownership di Rust, kita tidak perlu repot nginget-nginget buat cleanup karena Rust ngelakuin itu secara otomatis.

Kita juga tidak perlu khawatir soal masalah-masalah yang timbul dari secara tidak sengaja membersihkan nilai yang masih dipakai: sistem ownership yang memastikan kalau referensi bakal selalu valid juga memastikan kalau drop cuma bakal dipanggil satu kali pas nilainya memang sudah tidak dipakai lagi.

Sekarang setelah kita meneliti Box<T> dan beberapa karakteristik dari smart pointers, mari kita lihat beberapa smart pointers lain yang didefinisikan di standard library.

Rc<T>, si Smart Pointer yang Punya Reference Counting

Rc<T>, Smart Pointer Reference Counted

Di mayoritas kasus, kepemilikan (ownership) itu jelas: kita tahu persis variabel mana yang memiliki suatu nilai. Namun, ada kasus di mana sebuah nilai bisa punya banyak pemilik. Misalnya, di struktur data graf (graph), banyak edges (sisi) mungkin menunjuk ke node (simpul) yang sama, dan node tersebut secara konsep dimiliki oleh semua edges yang menunjuk kepadanya. Sebuah node tidak boleh dibersihkan kecuali jika dia sudah tidak punya edges yang menunjuk kepadanya, yang artinya dia tidak punya pemilik lagi.

Kita harus mengaktifkan kepemilikan ganda (multiple ownership) secara eksplisit dengan memakai tipe Rust Rc<T>, yang merupakan singkatan dari reference counting (penghitungan referensi). Tipe Rc<T> melacak (keeps track of) jumlah referensi ke sebuah nilai buat menentukan apakah nilai tersebut masih dipakai atau tidak. Kalau ada nol referensi ke sebuah nilai, nilai tersebut bisa dibersihkan tanpa membuat referensi apa pun jadi tidak valid.

Bayangkan Rc<T> itu seperti TV di ruang keluarga. Saat ada satu orang yang masuk buat nonton TV, dia menyalakannya. Orang-orang lain bisa ikut masuk dan menonton TV. Saat orang terakhir keluar ruangan, dia bakal mematikan TV tersebut karena sudah tidak dipakai lagi. Kalau ada orang yang mematikan TV padahal orang lain masih pada nonton, pasti bakal ada keributan dari penonton-penonton yang tersisa itu!

Kita memakai tipe Rc<T> saat kita mau mengalokasikan beberapa data di heap biar bisa dibaca sama beberapa bagian dari program kita, dan saat kita tidak bisa menentukan di compile time bagian mana yang bakal paling terakhir selesai memakai datanya. Kalau kita tahu bagian mana yang bakal selesai terakhir, kita bisa aja bikin bagian itu jadi pemilik (owner) dari datanya, dan aturan ownership normal yang ditegakkan saat compile time bakal berlaku.

Perhatikan bahwa Rc<T> cuma buat dipakai di skenario single-threaded (satu thread). Saat kita ngebahas konkurensi di Bab 16, kita bakal menutupi gimana cara melakukan reference counting di program multithreaded.

Memakai Rc<T> buat Berbagi Data

Mari kembali ke contoh cons list kita di Listing 15-5. Ingat kembali kalau kita mendefinisikannya memakai Box<T>. Kali ini, kita bakal membikin dua list yang dua-duanya berbagi kepemilikan atas sebuah list ketiga. Secara konsep, ini kelihatannya mirip seperti Gambar 15-3.

Sebuah linked list dengan label 'a' menunjuk ke tiga elemen: elemen pertama berisi integer 5 dan menunjuk ke elemen kedua. Elemen kedua berisi integer 10 dan menunjuk ke elemen ketiga. Elemen ketiga berisi nilai 'Nil' yang menandakan akhir dari list; ia tidak menunjuk ke mana-mana. Sebuah linked list dengan label 'b' menunjuk ke elemen yang berisi integer 3 dan menunjuk ke elemen pertama dari list 'a'. Sebuah linked list dengan label 'c' menunjuk ke elemen yang berisi integer 4 dan juga menunjuk ke elemen pertama dari list 'a', sehingga ekor dari list 'b' dan 'c' dua-duanya adalah list 'a'

Gambar 15-3: Dua list, b dan c, berbagi kepemilikan atas sebuah list ketiga, a

Kita bakal membikin list a yang berisi 5 lalu 10. Terus kita bakal membikin dua list lagi: b yang dimulai dengan 3 dan c yang dimulai dengan 4. Baik list b maupun c nantinya bakal berlanjut ke list a pertama yang berisi 5 dan 10. Dengan kata lain, kedua list ini bakal berbagi list pertama yang berisi 5 dan 10.

Mencoba mengimplementasikan skenario ini memakai definisi dari List dengan Box<T> tidak bakal berhasil, seperti yang ditunjukkan di Listing 15-17.

Filename: src/main.rs
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
    let b = Cons(3, Box::new(a));
    let c = Cons(4, Box::new(a));
}
Listing 15-17: Mendemonstrasikan kalau kita tidak diizinkan buat punya dua list memakai Box<T> yang mencoba berbagi kepemilikan atas list ketiga

Pas kita men-compile kode ini, kita bakal dapat error ini:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0382]: use of moved value: `a`
  --> src/main.rs:11:30
   |
 9 |     let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
   |         - move occurs because `a` has type `List`, which does not implement the `Copy` trait
10 |     let b = Cons(3, Box::new(a));
   |                              - value moved here
11 |     let c = Cons(4, Box::new(a));
   |                              ^ value used here after move
   |
note: if `List` implemented `Clone`, you could clone the value
  --> src/main.rs:1:1
   |
 1 | enum List {
   | ^^^^^^^^^ consider implementing `Clone` for this type
...
10 |     let b = Cons(3, Box::new(a));
   |                              - you could clone this value

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

Varian-varian Cons memiliki data yang mereka pegang, jadi saat kita membikin list b, a di-move ke dalam b dan b memiliki a. Kemudian, saat kita nyoba memakai a lagi pas lagi membikin c, kita tidak diizinkan buat melakukannya karena a sudah dipindahkan.

Kita bisa saja mengubah definisi dari Cons agar memegang referensi sebagai gantinya, tapi kalau begitu kita harus menentukan parameter lifetime. Dengan menentukan parameter lifetime, kita bakal menentukan kalau setiap elemen di dalam list tersebut bakal hidup setidaknya selama keseluruhan list itu hidup. Ini sesuai buat elemen-elemen dan list-list yang ada di Listing 15-17, tapi tidak sesuai di semua skenario.

Sebagai gantinya, kita bakal mengubah definisi dari List kita agar memakai Rc<T> di tempat Box<T>, seperti yang ditunjukkan di Listing 15-18. Setiap varian Cons sekarang bakal memegang sebuah nilai dan sebuah Rc<T> yang menunjuk ke sebuah List. Saat kita membikin b, ketimbang mengambil kepemilikan dari a, kita bakal meng-clone (menyalin) Rc<List> yang lagi dipegang sama a, sehingga menaikkan jumlah referensinya dari satu jadi dua dan membiarkan a serta b berbagi kepemilikan dari data di dalam Rc<List> tersebut. Kita juga bakal meng-clone a pas membikin c, yang mana menaikkan jumlah referensinya dari dua jadi tiga. Setiap kali kita memanggil Rc::clone, reference count (jumlah referensi) ke data di dalam Rc<List> bakal naik, dan datanya tidak bakal dibersihkan kecuali ada nol referensi yang tersisa padanya.

Filename: src/main.rs
enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    let b = Cons(3, Rc::clone(&a));
    let c = Cons(4, Rc::clone(&a));
}
Listing 15-18: Definisi dari List yang memakai Rc<T>

Kita harus menambahkan statement use buat membawa Rc<T> ke dalam scope karena dia tidak ada di dalam prelude. Di main, kita membikin list yang memegang 5 dan 10 lalu menyimpannya di dalam Rc<List> baru di a. Terus, pas kita membikin b dan c, kita memanggil fungsi Rc::clone dan memberikan referensi ke Rc<List> di a sebagai argumennya.

Kita bisa saja memanggil a.clone() ketimbang Rc::clone(&a), tapi konvensi Rust adalah memakai Rc::clone di kasus ini. Implementasi dari Rc::clone tidak membuat deep copy (salinan dalam/penuh) dari semua datanya seperti yang dilakukan sama kebanyakan implementasi clone di tipe lain. Pemanggilan ke Rc::clone cuma menaikkan jumlah referensinya aja, yang mana tidak memakan banyak waktu. Deep copies dari data bisa memakan waktu yang lama sekali. Dengan memakai Rc::clone buat reference counting, kita bisa secara visual membedakan antara jenis clone yang berupa deep-copy dan jenis clone yang cuma menaikkan jumlah referensi. Saat lagi mencari masalah performa di kode, kita cuma perlu mempertimbangkan clones yang deep-copy aja dan bisa mengabaikan pemanggilan ke Rc::clone.

Meng-clone Rc<T> Menaikkan Reference Count

Mari kita ubah contoh pekerjaan kita di Listing 15-18 supaya kita bisa melihat gimana jumlah referensinya berubah saat kita membikin dan men-drop referensi ke Rc<List> di a.

Di Listing 15-19, kita bakal mengubah main supaya ia punya scope di dalam (inner scope) yang mengelilingi list c; dengan begitu kita bisa melihat gimana jumlah referensinya berubah saat c keluar dari scope.

Filename: src/main.rs
enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

// --snip--

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    println!("count after creating a = {}", Rc::strong_count(&a));
    let b = Cons(3, Rc::clone(&a));
    println!("count after creating b = {}", Rc::strong_count(&a));
    {
        let c = Cons(4, Rc::clone(&a));
        println!("count after creating c = {}", Rc::strong_count(&a));
    }
    println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}
Listing 15-19: Mencetak jumlah referensi

Di tiap titik di program di mana jumlah referensinya berubah, kita mencetak jumlah referensinya, yang mana kita dapat dengan memanggil fungsi Rc::strong_count. Fungsi ini dinamakan strong_count ketimbang count karena tipe Rc<T> juga punya sebuah weak_count; kita bakal melihat buat apa weak_count dipakai di “Mencegah Reference Cycles Memakai Weak<T>.

Kode ini mencetak yang berikut ini:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.45s
     Running `target/debug/cons-list`
count after creating a = 1
count after creating b = 2
count after creating c = 3
count after c goes out of scope = 2

Kita bisa melihat kalau Rc<List> di a punya jumlah referensi awal sebesar 1; lalu setiap kali kita memanggil clone, jumlahnya naik 1. Saat c keluar dari scope, jumlahnya turun 1. Kita tidak perlu memanggil sebuah fungsi buat menurunkan jumlah referensinya seperti kita harus memanggil Rc::clone buat menaikkannya: implementasi dari trait Drop bakal menurunkan jumlah referensinya secara otomatis pas sebuah nilai Rc<T> keluar dari scope.

Apa yang tidak bisa kita lihat dari contoh ini adalah saat b dan kemudian a keluar dari scope di akhir main, jumlahnya menjadi 0, dan Rc<List> tersebut bakal dibersihkan seutuhnya. Memakai Rc<T> memungkinkan sebuah nilai tunggal buat punya banyak pemilik, dan penghitungan ini memastikan kalau nilainya bakal tetap valid selama salah satu dari para pemiliknya masih eksis.

Lewat referensi immutable, Rc<T> memungkinkan kita buat berbagi data di antara berbagai bagian program buat dibaca aja (reading only). Kalau Rc<T> juga mengizinkan kita buat punya banyak referensi mutable, kita mungkin bakal melanggar salah satu dari aturan borrowing yang dibahas di Bab 4: banyak referensi mutable ke tempat yang sama bisa menyebabkan data races (balapan data) dan inkonsistensi. Tapi bisa memutasi data itu kan berguna sekali! Di bagian selanjutnya, kita bakal membahas desain pola interior mutability dan tipe RefCell<T> yang bisa kita pakai bersamaan dengan sebuah Rc<T> buat menangani larangan immutability ini.

RefCell<T> dan Pola Interior Mutability

RefCell<T> dan Desain Pola Interior Mutability

Interior mutability (mutabilitas interior) adalah sebuah desain pola di Rust yang membiarkan kita memutasi (mengubah) data bahkan saat ada referensi immutable ke data tersebut; normalnya, tindakan ini tidak diizinkan oleh aturan borrowing. Buat memutasi data, pola ini memakai kode unsafe di dalam sebuah struktur data untuk membengkokkan aturan-aturan normal Rust yang mengatur mutasi dan borrowing. Kode unsafe mengindikasikan ke compiler bahwa kita memeriksa aturan-aturan tersebut secara manual ketimbang mengandalkan compiler buat mengeceknya untuk kita; kita bakal membahas kode unsafe lebih banyak di Bab 20.

Kita bisa memakai tipe yang menggunakan desain pola interior mutability cuma pas kita bisa memastikan kalau aturan borrowing bakal dipatuhi saat runtime, meskipun compiler tidak bisa menjamin hal itu. Kode unsafe yang terlibat kemudian dibungkus di dalam sebuah API yang aman (safe API), dan tipe yang ada di luar bakal tetap immutable.

Mari kita eksplorasi konsep ini dengan melihat tipe RefCell<T> yang mengikuti desain pola interior mutability ini.

Menegakkan Aturan Borrowing saat Runtime dengan RefCell<T>

Tidak seperti Rc<T>, tipe RefCell<T> merepresentasikan kepemilikan tunggal (single ownership) atas data yang dipegangnya. Jadi apa yang membikin RefCell<T> berbeda dari tipe seperti Box<T>? Ingat kembali aturan borrowing yang kita pelajari di Bab 4:

  • Pada waktu kapan pun, kita cuma boleh punya satu referensi mutable atau sejumlah referensi immutable (tapi tidak boleh dua-duanya sekaligus).
  • Referensi harus selalu valid.

Dengan referensi biasa dan Box<T>, invarian (aturan mutlak) dari aturan borrowing ini ditegakkan (enforced) saat compile time. Dengan RefCell<T>, invarian ini ditegakkan saat runtime. Dengan referensi, kalau kita melanggar aturan-aturan ini, kita bakal dapat error compiler. Dengan RefCell<T>, kalau kita melanggar aturan-aturan ini, program kita bakal mengalami panic dan keluar (exit).

Keuntungan dari mengecek aturan borrowing saat compile time adalah error bakal ditangkap lebih awal di proses development (pengembangan), dan tidak ada dampak pada performa runtime karena semua analisisnya udah diselesaikan sebelumnya. Karena alasan-alasan tersebut, mengecek aturan borrowing saat compile time adalah pilihan terbaik buat mayoritas kasus, yang mana itulah alasannya kenapa ini jadi default (bawaan) di Rust.

Keuntungan dari mengecek aturan borrowing saat runtime sebagai gantinya adalah ada beberapa skenario aman-memori (memory-safe) yang kemudian jadi diizinkan, di mana mereka tadinya tidak bakal diizinkan sama pengecekan compile time. Analisis statis, seperti compiler Rust, secara bawaan sifatnya konservatif. Beberapa properti (sifat) kode mustahil buat dideteksi dengan hanya menganalisis kodenya saja: contoh paling terkenal adalah Halting Problem, yang ada di luar cakupan buku ini tapi merupakan topik yang menarik buat diteliti.

Karena beberapa analisis itu mustahil, kalau compiler Rust tidak bisa yakin kodenya mematuhi aturan ownership, ia mungkin bakal menolak sebuah program yang sebenarnya benar; di sinilah letak sifat konservatifnya. Kalau Rust menerima program yang salah, para user tidak bakal bisa mempercayai jaminan yang diberikan Rust. Namun, kalau Rust menolak program yang benar, si programmer bakal direpotkan, tapi tidak ada bencana besar yang bakal terjadi. Tipe RefCell<T> ini berguna saat kita yakin kode kita mematuhi aturan borrowing tapi compiler tidak mampu memahami dan menjamin hal tersebut.

Sama halnya dengan Rc<T>, RefCell<T> cuma buat dipakai di skenario single-threaded dan bakal ngasih kita error compile-time kalau kita mencoba memakainya di konteks multithreaded. Kita bakal bahas gimana cara mendapatkan fungsionalitas dari RefCell<T> di dalam program multithreaded di Bab 16.

Berikut adalah rangkuman (recap) dari alasan memilih Box<T>, Rc<T>, atau RefCell<T>:

  • Rc<T> memungkinkan banyak pemilik (multiple owners) dari data yang sama; Box<T> dan RefCell<T> cuma punya pemilik tunggal (single owners).
  • Box<T> mengizinkan borrows (peminjaman) immutable atau mutable yang dicek saat compile time; Rc<T> cuma mengizinkan borrows immutable yang dicek saat compile time; RefCell<T> mengizinkan borrows immutable atau mutable yang dicek saat runtime.
  • Karena RefCell<T> mengizinkan borrows mutable yang dicek saat runtime, kita bisa memutasi nilai di dalam RefCell<T> bahkan saat RefCell<T> itu sendiri sifatnya immutable.

Memutasi nilai yang ada di dalam sebuah nilai yang immutable itulah yang disebut desain pola interior mutability. Mari kita lihat sebuah situasi di mana interior mutability itu berguna dan memeriksa gimana hal itu bisa dilakukan.

Interior Mutability: Peminjaman Mutable ke sebuah Nilai yang Immutable

Sebuah konsekuensi dari aturan borrowing adalah pas kita punya nilai yang immutable, kita tidak bisa meminjamnya secara mutable. Misalnya, kode ini tidak bakal bisa di-compile:

fn main() {
    let x = 5;
    let y = &mut x;
}

Kalau kita mencoba men-compile kode ini, kita bakal dapat error berikut:

$ cargo run
   Compiling borrowing v0.1.0 (file:///projects/borrowing)
error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable
 --> src/main.rs:3:13
  |
3 |     let y = &mut x;
  |             ^^^^^^ cannot borrow as mutable
  |
help: consider changing this to be mutable
  |
2 |     let mut x = 5;
  |         +++

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

Namun, ada beberapa situasi di mana bakal berguna buat sebuah nilai buat bisa memutasi dirinya sendiri di dalam method-methodnya tapi terlihat immutable bagi kode lain. Kode di luar method dari nilai tersebut tidak bakal bisa memutasi nilai itu. Memakai RefCell<T> adalah salah satu cara buat bisa mendapatkan kemampuan buat punya interior mutability, tapi RefCell<T> tidak sepenuhnya menghindari aturan borrowing: borrow checker di dalam compiler memang mengizinkan interior mutability ini, dan sebagai gantinya aturan borrowing dicek saat runtime. Kalau kita melanggar aturan-aturannya, kita bakal dapat panic! bukannya error compiler.

Mari kita kerjakan sebuah contoh praktis di mana kita bisa memakai RefCell<T> buat memutasi nilai yang immutable dan melihat kenapa hal itu berguna.

Use Case untuk Interior Mutability: Mock Objects

Kadang-kadang saat lagi testing (pengujian), seorang programmer bakal memakai sebuah tipe sebagai pengganti dari tipe lain, untuk mengobservasi perilaku tertentu dan menegaskan kalau perilakunya diimplementasikan dengan benar. Tipe placeholder ini disebut test double (pengganti tes). Bayangkan saja seperti pemeran pengganti (stunt double) di pembuatan film, di mana seseorang masuk dan menggantikan sang aktor buat melakukan adegan yang sangat berbahaya (tricky scene). Test doubles menggantikan tipe lain saat kita sedang menjalankan pengujian. Mock objects (objek pura-pura) adalah jenis test double spesifik yang merekam (records) apa yang terjadi selama pengujian berjalan sehingga kita bisa menegaskan kalau aksi yang benar telah terjadi.

Rust tidak punya objects (objek) dalam artian yang sama kayak bahasa lain punya objects, dan Rust tidak punya fungsionalitas mock object bawaan di standard library seperti yang ada di beberapa bahasa lain. Namun, kita tetap bisa membikin sebuah struct yang bakal melayani tujuan yang sama dengan sebuah mock object.

Ini skenario yang bakal kita uji: kita bakal bikin sebuah library yang melacak (tracks) sebuah nilai berdasarkan nilai maksimalnya dan mengirim pesan berdasarkan seberapa dekat nilai saat ini ke nilai maksimalnya. Library ini bisa dipakai buat melacak kuota user untuk jumlah pemanggilan API yang diizinkan buat mereka, contohnya.

Library kita cuma bakal menyediakan fungsionalitas buat melacak seberapa dekat suatu nilai ke nilai maksimalnya dan apa pesan yang seharusnya muncul di waktu kapan aja. Aplikasi-aplikasi yang memakai library kita bakal diharapkan buat menyediakan mekanisme pengiriman pesannya: aplikasi itu bisa aja menaruh pesannya di dalam aplikasinya, mengirim email, mengirim pesan teks (SMS), atau melakukan hal lain. Library-nya tidak perlu tahu detail tersebut. Yang ia butuhkan cuma sesuatu yang mengimplementasikan sebuah trait yang bakal kita sediakan bernama Messenger. Listing 15-20 menunjukkan kode dari library tersebut.

Filename: src/lib.rs
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}
Listing 15-20: Sebuah library buat melacak seberapa dekat suatu nilai ke nilai maksimalnya dan ngasih peringatan saat nilai tersebut mencapai tingkat tertentu

Satu bagian penting dari kode ini adalah trait Messenger punya satu method bernama send yang menerima sebuah referensi immutable ke self dan teks pesannya. Trait ini adalah interface (antarmuka) yang perlu diimplementasikan oleh mock object kita sehingga si mock (objek pura-pura) ini bisa dipakai dengan cara yang sama kayak objek aslinya dipakai. Bagian penting lainnya adalah kita mau menguji perilaku dari method set_value di LimitTracker. Kita bisa mengubah nilai apa yang kita teruskan ke dalam parameter value, tapi set_value tidak mengembalikan apa pun yang bisa kita pakai buat membuat penegasan (assertions). Kita mau bisa bilang kalau saat kita bikin sebuah LimitTracker dengan sesuatu yang mengimplementasikan trait Messenger dan sebuah nilai spesifik buat max, pas kita meneruskan angka-angka yang beda buat value maka si messenger (pengirim pesan) ini bakal disuruh mengirim pesan-pesan yang sesuai.

Kita butuh sebuah mock object yang, alih-alih mengirim email atau SMS saat kita memanggil send, dia cuma bakal mencatat pesan-pesan apa aja yang disuruh untuk dikirim. Kita bisa membikin sebuah instance baru dari mock object ini, membikin sebuah LimitTracker yang memakai mock object itu, memanggil method set_value di LimitTracker, dan kemudian mengecek kalau mock object itu memang punya pesan-pesan yang kita harapkan. Listing 15-21 menunjukkan sebuah usaha buat mengimplementasikan sebuah mock object buat melakukan hal itu persis, tapi borrow checker tidak bakal mengizinkannya.

Filename: src/lib.rs
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    struct MockMessenger {
        sent_messages: Vec<String>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: vec![],
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.len(), 1);
    }
}
Listing 15-21: Sebuah usaha buat mengimplementasikan sebuah MockMessenger yang mana tidak diizinkan oleh borrow checker

Kode pengujian ini mendefinisikan sebuah struct MockMessenger yang punya field sent_messages berisi Vec dari nilai-nilai String buat melacak pesan-pesan apa aja yang dia disuruh buat kirim. Kita juga mendefinisikan fungsi associated (terkait) new buat bikin nyaman pas membikin nilai MockMessenger baru yang dimulai dengan list pesan yang kosong. Kita kemudian mengimplementasikan trait Messenger untuk MockMessenger supaya kita bisa ngasih sebuah MockMessenger ke sebuah LimitTracker. Di dalam definisi dari method send, kita mengambil pesan yang diberikan sebagai parameter lalu menyimpannya di list sent_messages milik MockMessenger.

Di dalam pengujiannya, kita menguji apa yang terjadi saat LimitTracker disuruh buat nge-set value ke sesuatu yang jumlahnya lebih dari 75 persen dari nilai max. Pertama kita bikin sebuah MockMessenger baru, yang mana bakal dimulai dengan list pesan yang kosong. Terus kita bikin sebuah LimitTracker baru dan ngasih dia sebuah referensi ke MockMessenger yang baru itu dan nilai max sebesar 100. Kita memanggil method set_value di LimitTracker dengan nilai 80, yang mana itu lebih dari 75 persen dari 100. Lalu kita menegaskan kalau list pesan yang dilacak sama MockMessenger itu seharusnya sekarang punya satu pesan di dalamnya.

Namun, ada satu masalah dengan pengujian ini, seperti yang ditunjukkan di sini:

$ cargo test
   Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
error[E0596]: cannot borrow `self.sent_messages` as mutable, as it is behind a `&` reference
  --> src/lib.rs:58:13
   |
58 |             self.sent_messages.push(String::from(message));
   |             ^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be borrowed as mutable
   |
help: consider changing this to be a mutable reference in the `impl` method and the `trait` definition
   |
 2 ~     fn send(&mut self, msg: &str);
 3 | }
...
56 |     impl Messenger for MockMessenger {
57 ~         fn send(&mut self, message: &str) {
   |

For more information about this error, try `rustc --explain E0596`.
error: could not compile `limit-tracker` (lib test) due to 1 previous error

Kita tidak bisa memodifikasi MockMessenger buat melacak pesan-pesannya karena method send mengambil sebuah referensi immutable ke self. Kita juga tidak bisa mengikuti saran dari teks error-nya buat memakai &mut self di method impl dan juga di definisi trait-nya. Kita tidak mau mengubah trait Messenger cuma demi bisa melakukan pengujian. Sebaliknya, kita harus cari cara buat bikin kode pengujian kita bekerja dengan benar dengan desain kita yang udah ada.

Ini adalah sebuah situasi di mana interior mutability bisa membantu! Kita bakal menyimpan sent_messages di dalam sebuah RefCell<T>, dan kemudian method send bakal bisa memodifikasi sent_messages buat menyimpan pesan-pesan yang udah kita lihat. Listing 15-22 menunjukkan seperti apa rupa dari perubahan tersebut.

Filename: src/lib.rs
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: RefCell::new(vec![]),
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.borrow_mut().push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        // --snip--
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}
Listing 15-22: Memakai RefCell<T> buat memutasi nilai internal (inner value) sembari nilai luarnya (outer value) dianggap immutable

Field sent_messages sekarang bertipe RefCell<Vec<String>> bukannya Vec<String>. Di dalam fungsi new, kita membikin sebuah instance RefCell<Vec<String>> baru di sekeliling vector kosong.

Untuk implementasi method send-nya, parameter pertamanya tetap berupa pinjaman (borrow) immutable dari self, yang mana cocok sama definisi trait-nya. Kita memanggil borrow_mut pada RefCell<Vec<String>> di dalam self.sent_messages buat dapet sebuah referensi mutable ke nilai yang ada di dalam RefCell<Vec<String>>, yaitu vector-nya. Terus kita bisa manggil push pada referensi mutable ke vector tersebut buat melacak pesan-pesan yang dikirim selama pengujiannya.

Perubahan terakhir yang harus kita buat adalah di penegasannya (assertion): buat melihat ada berapa banyak item yang ada di vector internalnya, kita memanggil borrow pada RefCell<Vec<String>> buat dapet sebuah referensi immutable ke vector-nya.

Sekarang karena kita sudah melihat gimana cara memakai RefCell<T>, mari kita gali lebih dalam soal gimana cara kerjanya!

Melacak Borrows saat Runtime dengan RefCell<T>

Saat membikin referensi immutable dan mutable, kita masing-masing memakai sintaks & dan &mut. Dengan RefCell<T>, kita memakai method borrow dan borrow_mut, yang merupakan bagian dari API aman yang dimiliki sama RefCell<T>. Method borrow mengembalikan tipe smart pointer Ref<T>, dan borrow_mut mengembalikan tipe smart pointer RefMut<T>. Kedua tipe ini mengimplementasikan Deref, jadi kita bisa memperlakukan mereka kayak referensi biasa.

RefCell<T> melacak berapa banyak smart pointers Ref<T> dan RefMut<T> yang saat ini lagi aktif. Setiap kali kita memanggil borrow, RefCell<T> menaikkan hitungan (count) jumlah borrows immutable yang lagi aktif. Saat sebuah nilai Ref<T> keluar dari scope, hitungan dari borrows immutable itu turun sebanyak 1. Persis sama kayak aturan borrowing di compile-time, RefCell<T> membiarkan kita punya banyak borrows immutable atau satu borrow mutable pada suatu waktu kapan pun.

Kalau kita mencoba melanggar aturan ini, bukannya dapat error compiler kayak yang bakal kita dapat pas pakai referensi, implementasi dari RefCell<T> malah bakal panic di saat runtime. Listing 15-23 menunjukkan sebuah modifikasi dari implementasi send di Listing 15-22. Kita sengaja mencoba membikin dua borrows mutable aktif di scope yang sama buat mengilustrasikan kalau RefCell<T> mencegah kita dari melakukan hal ini saat runtime.

Filename: src/lib.rs
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: RefCell::new(vec![]),
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            let mut one_borrow = self.sent_messages.borrow_mut();
            let mut two_borrow = self.sent_messages.borrow_mut();

            one_borrow.push(String::from(message));
            two_borrow.push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}
Listing 15-23: Membikin dua referensi mutable di dalam scope yang sama buat melihat kalau RefCell<T> bakal mengalami panic

Kita membikin sebuah variabel one_borrow buat smart pointer RefMut<T> yang dikembalikan dari borrow_mut. Terus kita membikin borrow mutable lainnya dengan cara yang sama di dalam variabel two_borrow. Ini membikin dua referensi mutable di dalam scope yang sama, yang mana tidak diizinkan. Saat kita menjalankan pengujian buat library kita, kode di Listing 15-23 bakal berhasil di-compile tanpa error apa pun, tapi pengujiannya bakal gagal:

$ cargo test
   Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/limit_tracker-e599811fa246dbde)

running 1 test
test tests::it_sends_an_over_75_percent_warning_message ... FAILED

failures:

---- tests::it_sends_an_over_75_percent_warning_message stdout ----

thread 'tests::it_sends_an_over_75_percent_warning_message' panicked at src/lib.rs:60:53:
RefCell already borrowed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_sends_an_over_75_percent_warning_message

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Perhatikan kalau kodenya mengalami panic dengan pesan already borrowed: BorrowMutError. Ini adalah gimana RefCell<T> menangani pelanggaran dari aturan borrowing saat runtime.

Memilih buat menangkap error borrowing saat runtime ketimbang saat compile time, seperti yang udah kita lakuin di sini, berarti kita punya kemungkinan buat nemuin kesalahan di kode kita jauh belakangan di proses pengembangan: bahkan mungkin tidak bakal ketemu sampai kode kita sudah di-deploy ke production (produksi). Selain itu, kode kita bakal kena sedikit penalti (hukuman) performa runtime akibat harus melacak borrows-nya pas runtime ketimbang pas compile time. Namun, memakai RefCell<T> memungkinkan kita buat menulis sebuah mock object yang bisa memodifikasi dirinya sendiri buat melacak pesan-pesan yang udah dilihatnya sementara kita memakainya di dalam sebuah konteks di mana cuma nilai immutable yang diizinkan. Kita bisa memakai RefCell<T> meskipun ada trade-offs (kekurangannya) buat dapat fungsionalitas lebih banyak ketimbang yang disediakan oleh referensi biasa.

Mengizinkan Kepemilikan Ganda (Multiple Owners) pada Data yang Mutable dengan Rc<T> dan RefCell<T>

Cara yang umum buat memakai RefCell<T> adalah dengan menggabungkannya bersama Rc<T>. Ingat kembali kalau Rc<T> membiarkan kita punya banyak pemilik dari suatu data, tapi ia cuma ngasih akses immutable ke data tersebut. Kalau kita punya sebuah Rc<T> yang memegang sebuah RefCell<T>, kita bisa dapat sebuah nilai yang bisa punya banyak pemilik dan juga bisa kita mutasi (ubah)!

Sebagai contoh, ingat kembali contoh cons list di Listing 15-18 di mana kita memakai Rc<T> agar beberapa lists bisa berbagi kepemilikan dari sebuah list lain. Karena Rc<T> cuma menampung nilai immutable, kita tidak bisa mengubah satu pun nilai di dalam list-nya setelah kita membuatnya. Mari kita menambahkan RefCell<T> agar kita bisa mendapatkan kemampuan buat mengubah nilai-nilai di dalam lists tersebut. Listing 15-24 menunjukkan kalau dengan memakai sebuah RefCell<T> di dalam definisi Cons, kita bisa memodifikasi nilai yang disimpan di semua lists.

Filename: src/main.rs
#[derive(Debug)]
enum List {
    Cons(Rc<RefCell<i32>>, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

fn main() {
    let value = Rc::new(RefCell::new(5));

    let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));

    let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
    let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));

    *value.borrow_mut() += 10;

    println!("a after = {a:?}");
    println!("b after = {b:?}");
    println!("c after = {c:?}");
}
Listing 15-24: Memakai Rc<RefCell<i32>> buat bikin sebuah List yang bisa kita mutasi

Kita membikin sebuah nilai yang merupakan sebuah instance dari Rc<RefCell<i32>> lalu menyimpannya ke dalam sebuah variabel bernama value supaya kita bisa mengaksesnya secara langsung nanti. Terus kita membikin sebuah List di dalam a dengan sebuah varian Cons yang memegang value. Kita perlu meng-clone value sehingga baik a dan value dua-duanya punya kepemilikan dari nilai internal 5 tersebut ketimbang memindahkan (transferring) kepemilikan dari value ke a atau bikin a meminjam dari value.

Kita membungkus list a di dalam sebuah Rc<T> sehingga saat kita membikin lists b dan c, mereka berdua bisa merujuk ke a, persis kayak yang kita lakuin di Listing 15-18.

Setelah kita membikin lists di a, b, dan c, kita mau menambahkan 10 ke nilai di dalam value. Kita melakukan ini dengan memanggil borrow_mut pada value, yang mana memakai fitur automatic dereferencing yang udah kita bahas di “Ke Mana Perginya Operator ->?” di Bab 5 buat men-dereferensi Rc<T> ke nilai RefCell<T> di dalamnya. Method borrow_mut mengembalikan sebuah smart pointer RefMut<T>, lalu kita memakai operator dereferensi padanya dan mengubah nilai internalnya.

Saat kita mencetak a, b, dan c, kita bisa melihat kalau mereka semua sekarang punya nilai yang udah dimodifikasi menjadi 15 bukannya 5:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.63s
     Running `target/debug/cons-list`
a after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil))

Teknik ini lumayan keren! Dengan memakai RefCell<T>, dari luar kita punya sebuah nilai List yang terlihat immutable. Tapi kita bisa memakai method-method di RefCell<T> yang menyediakan akses ke interior mutability-nya sehingga kita bisa memodifikasi data kita saat kita butuhkan. Pengecekan aturan borrowing saat runtime ngelindungin kita dari data races (balapan data), dan kadang-kadang sepadan rasanya buat menukar sedikit kecepatan (speed) demi fleksibilitas di dalam struktur data kita ini. Perhatikan bahwa RefCell<T> tidak bakal bekerja buat kode yang multithreaded! Mutex<T> adalah versi yang thread-safe (aman dari utas) dari RefCell<T>, dan kita bakal membahas Mutex<T> di Bab 16.

Siklus Referensi Bisa Menyebabkan Kebocoran Memori

Reference Cycles Bisa Membocorkan Memori (Memory Leak)

Jaminan keamanan memori (memory safety guarantees) di Rust membikin hal itu jadi sulit, tapi bukannya mustahil, buat secara tidak sengaja membikin memori yang tidak akan pernah dibersihkan (dikenal sebagai memory leak atau kebocoran memori). Mencegah memory leaks secara total bukanlah salah satu jaminan yang diberikan Rust, yang berarti memory leaks itu sifatnya aman-memori (memory safe) di Rust. Kita bisa melihat kalau Rust mengizinkan memory leaks dengan memakai Rc<T> dan RefCell<T>: sangat mungkin buat membikin referensi di mana item-itemnya merujuk satu sama lain dalam sebuah cycle (siklus). Ini membikin memory leaks karena jumlah referensi (reference count) dari setiap item di dalam cycle tidak akan pernah mencapai 0, dan nilainya tidak akan pernah di-drop.

Membikin Sebuah Reference Cycle

Mari kita lihat gimana sebuah reference cycle bisa terjadi dan gimana cara mencegahnya, dimulai dengan definisi dari enum List dan method tail di Listing 15-25.

Filename: src/main.rs
// ANCHOR: here
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}
// ANCHOR_END: here

fn main() {}
Listing 15-25: Definisi sebuah cons list yang menampung sebuah RefCell<T> supaya kita bisa memodifikasi apa yang ditunjuk oleh varian Cons

Kita memakai variasi lain dari definisi List dari Listing 15-5. Elemen kedua di varian Cons sekarang adalah RefCell<Rc<List>>, yang berarti bahwa ketimbang cuma punya kemampuan buat memodifikasi nilai i32 seperti yang kita lakuin di Listing 15-24, kita sekarang mau memodifikasi nilai List yang ditunjuk oleh sebuah varian Cons. Kita juga menambahkan method tail buat memudahkan kita mengakses item kedua kalau kita punya sebuah varian Cons.

Di Listing 15-26, kita menambahkan fungsi main yang memakai definisi di Listing 15-25. Kode ini membikin sebuah list di a dan sebuah list di b yang menunjuk ke list di a. Terus dia memodifikasi list di a buat menunjuk ke b, sehingga membikin sebuah reference cycle. Ada statements println! di sepanjang jalan buat menunjukkan berapa jumlah referensi (reference counts) di berbagai titik dalam proses ini.

Filename: src/main.rs
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

fn main() {
    let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));

    println!("a initial rc count = {}", Rc::strong_count(&a));
    println!("a next item = {:?}", a.tail());

    let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));

    println!("a rc count after b creation = {}", Rc::strong_count(&a));
    println!("b initial rc count = {}", Rc::strong_count(&b));
    println!("b next item = {:?}", b.tail());

    if let Some(link) = a.tail() {
        *link.borrow_mut() = Rc::clone(&b);
    }

    println!("b rc count after changing a = {}", Rc::strong_count(&b));
    println!("a rc count after changing a = {}", Rc::strong_count(&a));

    // Uncomment the next line to see that we have a cycle;
    // it will overflow the stack.
    // println!("a next item = {:?}", a.tail());
}
Listing 15-26: Membikin sebuah reference cycle dari dua nilai List yang saling menunjuk satu sama lain

Kita membikin instance Rc<List> yang memegang sebuah nilai List di variabel a dengan list awal berupa 5, Nil. Terus kita membikin instance Rc<List> yang memegang nilai List lainnya di variabel b yang berisi nilai 10 dan menunjuk ke list di a.

Kita memodifikasi a agar dia menunjuk ke b ketimbang ke Nil, sehingga membikin sebuah cycle. Kita melakukan itu dengan memakai method tail buat mendapatkan sebuah referensi ke RefCell<Rc<List>> di a, yang kemudian kita taruh di variabel link. Lalu kita memakai method borrow_mut pada RefCell<Rc<List>> tersebut buat mengubah nilai internalnya dari sebuah Rc<List> yang memegang nilai Nil menjadi Rc<List> yang ada di b.

Saat kita menjalankan kode ini, dengan membiarkan println! terakhir tetap dikomentari (commented out) buat saat ini, kita bakal dapat output ini:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.53s
     Running `target/debug/cons-list`
a initial rc count = 1
a next item = Some(RefCell { value: Nil })
a rc count after b creation = 2
b initial rc count = 1
b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b rc count after changing a = 2
a rc count after changing a = 2

Jumlah referensi (reference count) dari instance Rc<List> di a maupun b adalah 2 setelah kita ngubah list di a agar menunjuk ke b. Di akhir dari main, Rust men-drop variabel b, yang mana menurunkan jumlah referensi dari instance Rc<List> si b dari 2 menjadi 1. Memori yang dimiliki Rc<List> di heap tidak bakal di-drop di titik ini karena jumlah referensinya itu 1, bukannya 0. Terus Rust men-drop a, yang mana menurunkan jumlah referensi dari instance Rc<List> si a dari 2 menjadi 1 juga. Memori dari instance ini juga tidak bisa di-drop, karena instance Rc<List> yang satunya lagi masih merujuk ke dia. Memori yang dialokasikan ke list tersebut bakal terus tersisa dan tidak dibersihkan selamanya. Buat memvisualisasikan reference cycle ini, kita sudah membikin diagram di Gambar 15-4.

Sebuah persegi panjang berlabel 'a' yang menunjuk ke sebuah persegi panjang berisi integer 5. Sebuah persegi panjang berlabel 'b' yang menunjuk ke sebuah persegi panjang berisi integer 10. Persegi panjang yang berisi 5 menunjuk ke persegi panjang yang berisi 10, dan persegi panjang yang berisi 10 menunjuk balik ke persegi panjang yang berisi 5, menciptakan sebuah cycle (siklus)

Gambar 15-4: Sebuah reference cycle dari list a dan b yang saling menunjuk satu sama lain

Kalau kita menghilangkan komentar (uncomment) pada println! yang terakhir lalu menjalankan programnya, Rust bakal mencoba mencetak cycle ini dengan a menunjuk ke b menunjuk ke a dan seterusnya sampai dia mengalami stack overflow.

Dibandingkan dengan program di dunia nyata, konsekuensi dari membikin reference cycle di contoh ini tidak terlalu fatal: sesaat setelah kita membikin reference cycle tersebut, programnya berakhir. Namun, kalau sebuah program yang lebih kompleks mengalokasikan banyak memori di dalam sebuah cycle lalu memegangnya untuk waktu yang lama, program tersebut bakal memakai lebih banyak memori daripada yang dia butuhkan dan bisa bikin sistem kewalahan, yang berujung pada kehabisan memori (out of memory).

Membikin reference cycles itu memang tidak mudah dilakukan, tapi itu juga bukanlah hal yang mustahil. Kalau kita punya nilai RefCell<T> yang mengandung nilai Rc<T> atau kombinasi bersarang yang mirip dari tipe-tipe yang punya interior mutability dan reference counting, kita harus memastikan kalau kita tidak membikin cycles; kita tidak bisa ngandelin Rust buat menangkap hal tersebut. Membikin sebuah reference cycle adalah sebuah logic bug (kutu logika) di program kita yang harusnya diminimalisir dengan memakai automated tests (pengujian otomatis), code reviews (tinjauan kode), dan praktik pengembangan software lainnya.

Solusi lain buat menghindari reference cycles adalah dengan mengatur ulang struktur data kita sedemikian rupa sehingga beberapa referensi mengekspresikan kepemilikan (ownership) dan referensi lainnya tidak. Sebagai hasilnya, kita bisa punya cycles yang dibikin dari beberapa hubungan kepemilikan dan beberapa hubungan non-kepemilikan, dan cuma hubungan kepemilikan lah yang memengaruhi apakah suatu nilai bisa di-drop atau tidak. Di Listing 15-25, kita selalu mau varian Cons buat memiliki list mereka, jadi mengatur ulang struktur datanya itu tidak memungkinkan. Mari kita lihat contoh yang memakai graphs (graf) yang dibikin dari parent nodes (simpul induk) dan child nodes (simpul anak) buat melihat kapan hubungan non-kepemilikan menjadi cara yang pas buat mencegah reference cycles.

Mencegah Reference Cycles Memakai Weak<T>

Sejauh ini, kita sudah mendemonstrasikan kalau memanggil Rc::clone bakal menaikkan nilai strong_count dari sebuah instance Rc<T>, dan sebuah instance Rc<T> cuma bakal dibersihkan kalau nilai strong_count-nya 0. Kita juga bisa membikin sebuah weak reference (referensi lemah) ke sebuah nilai yang ada di dalam instance Rc<T> dengan memanggil Rc::downgrade dan meneruskan sebuah referensi ke Rc<T> tersebut. Strong references (referensi kuat) adalah cara gimana kita bisa berbagi kepemilikan dari sebuah instance Rc<T>. Weak references tidak mengekspresikan hubungan kepemilikan, dan jumlah mereka (count) tidak memengaruhi kapan sebuah instance Rc<T> dibersihkan. Mereka tidak bakal bikin sebuah reference cycle karena cycle apa pun yang melibatkan beberapa weak references bakal terputus begitu jumlah strong reference dari nilai-nilai yang terlibat mencapai 0.

Pas kita memanggil Rc::downgrade, kita bakal dapat sebuah smart pointer bertipe Weak<T>. Alih-alih menaikkan strong_count di instance Rc<T> sebanyak 1, memanggil Rc::downgrade menaikkan weak_count sebanyak 1. Tipe Rc<T> memakai weak_count buat melacak berapa banyak referensi Weak<T> yang ada, mirip kayak strong_count. Bedanya adalah weak_count tidak harus 0 supaya instance Rc<T>-nya bisa dibersihkan.

Karena nilai yang ditunjuk sama Weak<T> mungkin sudah di-drop, untuk melakukan apa pun dengan nilai yang ditunjuk sama Weak<T> kita harus memastikan kalau nilainya masih eksis. Lakukan ini dengan memanggil method upgrade pada sebuah instance Weak<T>, yang mana bakal mengembalikan sebuah Option<Rc<T>>. Kita bakal dapat hasil Some kalau nilai Rc<T> tersebut belum di-drop dan hasil None kalau nilai Rc<T> tersebut sudah di-drop. Karena upgrade mengembalikan sebuah Option<Rc<T>>, Rust bakal memastikan kalau kasus Some maupun kasus None udah ditangani, dan tidak bakal ada yang namanya pointer yang tidak valid.

Sebagai contoh, ketimbang memakai sebuah list yang item-itemnya cuma tahu soal item selanjutnya aja, kita bakal membikin sebuah struktur tree (pohon) yang item-itemnya tahu soal children items (item anak) mereka dan parent items (item induk) mereka.

Membikin Struktur Data Tree: Sebuah Node dengan Child Nodes

Sebagai awalan, kita bakal ngebangun sebuah tree yang terdiri dari nodes (simpul) yang tahu soal child nodes mereka. Kita bakal membikin sebuah struct bernama Node yang memegang nilai i32-nya sendiri dan juga referensi ke nilai-nilai Node dari children-nya:

Nama file: src/main.rs

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}

Kita pengen supaya sebuah Node memiliki children-nya, dan kita mau berbagi kepemilikan tersebut dengan berbagai variabel sehingga kita bisa mengakses setiap Node di tree tersebut secara langsung. Buat melakukannya, kita mendefinisikan item-item Vec<T> agar berupa nilai-nilai bertipe Rc<Node>. Kita juga mau mengubah nodes mana saja yang merupakan children dari node lain, jadi kita membungkus Vec<Rc<Node>> tersebut di dalam sebuah RefCell<T> di field children.

Selanjutnya, kita bakal memakai definisi struct kita buat membikin satu instance Node bernama leaf (daun) dengan nilai 3 dan tanpa children, serta instance lain bernama branch (cabang) dengan nilai 5 dan leaf sebagai salah satu children-nya, seperti yang ditunjukkan di Listing 15-27.

Filename: src/main.rs
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}
Listing 15-27: Membikin sebuah leaf node tanpa children dan sebuah branch node yang menjadikan leaf sebagai salah satu children-nya

Kita meng-clone Rc<Node> yang ada di leaf dan menyimpannya di dalam branch, yang artinya Node di leaf sekarang punya dua pemilik: leaf dan branch. Kita bisa pindah dari branch ke leaf melalui branch.children, tapi tidak ada cara buat pindah dari leaf ke branch. Alasannya adalah karena leaf tidak punya referensi ke branch dan tidak tahu kalau mereka itu berhubungan. Kita pengen supaya leaf tahu kalau branch itu adalah parent-nya (induknya). Kita bakal melakukan hal itu selanjutnya.

Menambahkan Referensi dari Child ke Parent-nya

Buat membikin si child node sadar akan parent-nya, kita perlu menambahkan sebuah field parent ke definisi struct Node kita. Masalahnya adalah menentukan apa seharusnya tipe dari parent ini. Kita tahu kalau dia tidak boleh berisi Rc<T>, karena itu bakal membikin sebuah reference cycle dengan leaf.parent yang menunjuk ke branch dan branch.children yang menunjuk ke leaf, yang mana bakal membikin nilai strong_count mereka tidak akan pernah mencapai 0.

Membayangkan hubungan ini dengan cara lain, sebuah parent node seharusnya memiliki children-nya: kalau sebuah parent node di-drop, child nodes-nya seharusnya ikut di-drop juga. Namun, sebuah child tidak seharusnya memiliki parent-nya: kalau kita men-drop sebuah child node, sang parent seharusnya tetap eksis. Ini adalah kasus yang tepat sekali buat memakai weak references!

Jadi ketimbang memakai Rc<T>, kita bakal membikin tipe dari parent tersebut agar memakai Weak<T>, spesifiknya adalah RefCell<Weak<Node>>. Sekarang definisi struct Node kita kelihatan kayak gini:

Nama file: src/main.rs

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}

Sebuah node sekarang bakal bisa merujuk ke parent node-nya tapi dia tidak memiliki parent tersebut. Di Listing 15-28, kita meng-update main buat memakai definisi baru ini sehingga node leaf bakal punya cara buat merujuk ke parent-nya, yaitu branch.

Filename: src/main.rs
use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}
Listing 15-28: Sebuah leaf node dengan referensi lemah (weak reference) ke parent node-nya, branch

Membikin node leaf ini kelihatan mirip sama yang di Listing 15-27 dengan pengecualian di field parent: leaf awalnya tidak punya parent, jadi kita membikin sebuah instance referensi Weak<Node> baru yang kosong.

Pada titik ini, saat kita mencoba buat mendapatkan referensi ke parent dari leaf dengan memakai method upgrade, kita bakal dapat sebuah nilai None. Kita bisa melihat ini di dalam output dari statement println! yang pertama:

leaf parent = None

Saat kita membikin node branch, dia juga bakal punya sebuah referensi Weak<Node> baru di field parent-nya karena branch tidak punya parent node. Kita tetap punya leaf sebagai salah satu dari children si branch. Setelah kita mendapatkan instance Node di dalam branch, kita bisa memodifikasi leaf buat ngasih dia sebuah referensi Weak<Node> ke parent-nya. Kita memakai method borrow_mut pada RefCell<Weak<Node>> di dalam field parent si leaf, dan terus kita pakai fungsi Rc::downgrade buat membikin sebuah referensi Weak<Node> ke branch dari Rc<Node> yang ada di dalam branch.

Pas kita mencetak parent dari leaf lagi, kali ini kita bakal dapat sebuah varian Some yang memegang branch: sekarang leaf bisa mengakses parent-nya! Pas kita mencetak leaf, kita juga terhindar dari cycle yang pada akhirnya berujung pada stack overflow kayak yang terjadi di Listing 15-26; referensi Weak<Node> tersebut cuma dicetak sebagai (Weak):

leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) },
children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) },
children: RefCell { value: [] } }] } })

Absennya (lack of) output yang tidak terhingga ini menandakan bahwa kode ini tidak membikin sebuah reference cycle. Kita juga bisa mengetahuinya dengan melihat ke nilai-nilai yang kita dapat dari memanggil Rc::strong_count dan Rc::weak_count.

Memvisualisasikan Perubahan pada strong_count dan weak_count

Mari kita lihat gimana nilai strong_count dan weak_count dari instance-instance Rc<Node> tersebut berubah dengan membikin sebuah inner scope (scope dalam) baru lalu memindahkan pembuatan branch ke dalam scope tersebut. Dengan melakukan ini, kita bisa melihat apa yang terjadi saat branch dibikin dan kemudian di-drop pas dia keluar dari scope. Modifikasi ini ditunjukkan di Listing 15-29.

Filename: src/main.rs
use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );

    {
        let branch = Rc::new(Node {
            value: 5,
            parent: RefCell::new(Weak::new()),
            children: RefCell::new(vec![Rc::clone(&leaf)]),
        });

        *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

        println!(
            "branch strong = {}, weak = {}",
            Rc::strong_count(&branch),
            Rc::weak_count(&branch),
        );

        println!(
            "leaf strong = {}, weak = {}",
            Rc::strong_count(&leaf),
            Rc::weak_count(&leaf),
        );
    }

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );
}
Listing 15-29: Membikin branch di sebuah inner scope dan memeriksa jumlah (count) strong dan weak reference-nya

Setelah leaf dibikin, Rc<Node> miliknya punya strong count sebesar 1 dan weak count sebesar 0. Di dalam inner scope, kita membikin branch dan mengaitkannya (associate) dengan leaf, di mana pada titik ini kalau kita mencetak jumlahnya, Rc<Node> di dalam branch bakal punya strong count 1 dan weak count 1 (karena leaf.parent menunjuk ke branch dengan sebuah Weak<Node>). Pas kita mencetak jumlah (counts) yang ada di leaf, kita bakal melihat kalau dia punya strong count sebesar 2 karena branch sekarang punya clone dari Rc<Node> milik leaf yang disimpan di branch.children, tapi dia tetap punya weak count sebesar 0.

Saat inner scope-nya berakhir, branch keluar dari scope dan strong count dari Rc<Node>-nya turun menjadi 0, jadi Node-nya bakal di-drop. Weak count sebesar 1 dari leaf.parent sama sekali tidak memengaruhi apakah si Node itu bakal di-drop atau tidak, jadi kita tidak dapat memory leaks!

Kalau kita nyoba mengakses parent dari leaf setelah akhir dari scope itu, kita bakal dapat None lagi. Di akhir dari program, Rc<Node> di dalam leaf punya strong count sebesar 1 dan weak count sebesar 0 karena variabel leaf sekarang menjadi satu-satunya referensi ke Rc<Node> tersebut lagi.

Semua logika yang mengelola jumlah referensi (counts) dan pen-drop-an nilai ini dibangun langsung di dalam Rc<T> dan Weak<T> beserta implementasi mereka pada trait Drop. Dengan secara spesifik menentukan kalau hubungan dari seorang child ke parent-nya haruslah memakai referensi Weak<T> di dalam definisi dari Node, kita bisa membikin parent nodes menunjuk ke child nodes dan begitu juga sebaliknya tanpa membikin sebuah reference cycle dan memory leaks.

Ringkasan

Bab ini membahas gimana cara memakai smart pointers buat membikin berbagai jaminan dan trade-offs (pertukaran) yang berbeda dari apa yang Rust lakukan secara default dengan referensi biasa. Tipe Box<T> punya ukuran yang sudah pasti diketahui dan menunjuk ke data yang dialokasikan di heap. Tipe Rc<T> melacak jumlah referensi ke data yang ada di heap supaya data tersebut bisa punya banyak pemilik. Tipe RefCell<T> dengan kemampuan interior mutability-nya ngasih kita tipe yang bisa kita pakai pas kita butuh tipe yang immutable tapi juga butuh buat ngubah nilai di dalamnya; ia juga menegakkan aturan borrowing saat runtime bukannya saat compile time.

Kita juga udah ngebahas trait Deref dan Drop, yang memungkinkan berjalannya banyak dari fungsionalitas smart pointers ini. Kita mengeksplorasi reference cycles yang bisa menyebabkan memory leaks dan gimana cara mencegah mereka memakai Weak<T>.

Kalau bab ini sudah membangkitkan rasa penasaran kita dan kita pengen mengimplementasikan smart pointers kita sendiri, silakan cek “The Rustonomicon” buat dapetin lebih banyak informasi yang berguna.

Berikutnya, kita bakal ngomongin soal konkurensi (concurrency) di Rust. Kita bahkan bakal mempelajari beberapa smart pointers baru lagi lho!

Konkurensi Tanpa Rasa Takut (Fearless Concurrency)

Menangani pemrograman konkuren secara aman dan efisien adalah salah satu tujuan utama Rust lainnya. Concurrent programming (pemrograman konkuren), di mana berbagai bagian dari sebuah program dieksekusi secara independen, dan parallel programming (pemrograman paralel), di mana berbagai bagian dari sebuah program dieksekusi di saat yang bersamaan, menjadi makin penting seiring makin banyaknya komputer yang memanfaatkan prosesor multi-inti (multiple processors). Secara historis, pemrograman di dalam konteks ini selalu sulit dan rawan error. Rust berharap bisa mengubah hal tersebut.

Awalnya, tim Rust berpikir bahwa memastikan keamanan memori (memory safety) dan mencegah masalah konkurensi adalah dua tantangan terpisah yang harus diselesaikan dengan metode yang berbeda. Seiring berjalannya waktu, tim menemukan bahwa sistem kepemilikan (ownership) dan sistem tipe adalah serangkaian alat yang sangat kuat buat membantu mengelola keamanan memori dan masalah konkurensi! Dengan memanfaatkan ownership dan pengecekan tipe (type checking), banyak error konkurensi di Rust bakal menjadi error compile-time (saat kompilasi) ketimbang error runtime. Oleh karena itu, ketimbang membiarkan kita menghabiskan banyak waktu mencoba mereka ulang kondisi persis di mana sebuah bug konkurensi runtime terjadi, kode yang salah bakal menolak untuk di-compile dan menyajikan error yang menjelaskan masalahnya. Sebagai hasilnya, kita bisa memperbaiki kode kita saat kita sedang mengerjakannya, bukannya nanti setelah kodenya dikirim ke tahap produksi. Kita menjuluki aspek dari Rust ini sebagai fearless concurrency (konkurensi tanpa rasa takut). Fearless concurrency memungkinkan kita buat menulis kode yang bebas dari bugs yang tersembunyi (subtle bugs) dan mudah buat di-refactor tanpa memunculkan bugs baru.

Catatan: Demi kesederhanaan, kita bakal menyebut banyak dari masalah-masalah ini sebagai konkuren ketimbang lebih presisi dengan bilang konkuren dan/atau paralel. Buat bab ini, tolong substitusikan dalam hati konkuren dan/atau paralel kapan pun kita memakai kata konkuren. Di bab selanjutnya, di mana perbedaannya lebih penting, kita bakal lebih spesifik.

Banyak bahasa pemrograman bersifat dogmatis soal solusi yang mereka tawarkan buat menangani masalah konkuren. Misalnya, Erlang punya fungsionalitas yang elegan buat konkurensi message-passing (pengiriman pesan) tapi cuma punya cara yang samar-samar (obscure) buat membagikan state (keadaan) di antara threads. Mendukung hanya sebagian dari solusi yang memungkinkan adalah strategi yang masuk akal buat bahasa tingkat tinggi (higher-level languages) karena bahasa tingkat tinggi menjanjikan manfaat dari mengorbankan sedikit kontrol buat mendapatkan abstraksi. Namun, bahasa tingkat rendah (lower-level languages) diharapkan bisa menyediakan solusi dengan performa terbaik di situasi apa pun dan punya lebih sedikit abstraksi di atas perangkat kerasnya. Oleh karena itu, Rust menawarkan berbagai alat buat memodelkan masalah dengan cara apa pun yang cocok buat situasi dan kebutuhan kita.

Berikut adalah topik-topik yang bakal kita bahas di bab ini:

  • Cara membikin threads (utas) buat menjalankan beberapa potong kode di saat yang bersamaan
  • Konkurensi message-passing, di mana saluran (channels) mengirim pesan antar threads
  • Konkurensi shared-state, di mana banyak threads punya akses ke sekumpulan data
  • Trait Sync dan Send, yang memperluas jaminan konkurensi Rust ke tipe-tipe yang didefinisikan sama pengguna (user-defined types) serta tipe-tipe yang disediakan oleh standard library

Memakai Threads buat Menjalankan Kode Secara Beberangan

Memakai Threads buat Menjalankan Kode Secara Bersamaan

Di sebagian besar sistem operasi saat ini, kode dari sebuah program yang dieksekusi dijalankan di dalam sebuah process (proses), dan sistem operasi bakal mengelola banyak proses sekaligus. Di dalam sebuah program, kita juga bisa punya bagian-bagian independen yang berjalan secara bersamaan (simultaneously). Fitur yang menjalankan bagian-bagian independen ini disebut threads (utas). Misalnya, sebuah web server bisa punya banyak threads sehingga ia bisa merespons lebih dari satu request (permintaan) di saat yang bersamaan.

Memecah komputasi di program kita menjadi banyak threads buat menjalankan banyak tugas di saat yang bersamaan bisa meningkatkan performa, tapi ini juga menambahkan kerumitan (complexity). Karena threads bisa berjalan secara bersamaan, tidak ada jaminan bawaan (inherent guarantee) tentang urutan bagian kode mana di threads yang berbeda yang bakal jalan duluan. Ini bisa berujung pada masalah-masalah, seperti:

  • Race conditions (balapan kondisi), di mana threads mengakses data atau sumber daya (resources) dalam urutan yang tidak konsisten
  • Deadlocks (jalan buntu), di mana dua threads saling menunggu satu sama lain, mencegah kedua threads tersebut buat bisa lanjut
  • Bugs yang cuma terjadi di situasi-situasi tertentu dan susah buat direka ulang (reproduce) dan diperbaiki secara andal

Rust berusaha memitigasi efek-efek negatif dari memakai threads, tapi memprogram di dalam konteks multithreaded tetap butuh pemikiran yang hati-hati dan membutuhkan struktur kode yang berbeda dari program yang berjalan di satu thread saja (single thread).

Bahasa pemrograman mengimplementasikan threads dengan beberapa cara yang berbeda-beda, dan banyak sistem operasi menyediakan sebuah API yang bisa dipanggil oleh bahasa pemrograman tersebut buat membikin threads baru. Standard library Rust memakai model implementasi thread 1:1, di mana sebuah program memakai satu thread sistem operasi untuk satu thread bahasa. Ada crates yang mengimplementasikan model threading lain yang membikin trade-offs (pertukaran) yang beda dari model 1:1. (Sistem async Rust, yang bakal kita lihat di bab selanjutnya, menyediakan pendekatan lain buat konkurensi.)

Membikin Thread Baru dengan spawn

Buat membikin thread baru, kita memanggil fungsi thread::spawn lalu memberikan sebuah closure (kita sudah membahas closures di Bab 13) yang mengandung kode yang mau kita jalankan di thread baru tersebut. Contoh di Listing 16-1 mencetak sedikit teks dari main thread (thread utama) dan teks lainnya dari thread yang baru dibikin.

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}
Listing 16-1: Membikin sebuah thread baru buat mencetak sesuatu sementara main thread mencetak hal lain

Perhatikan bahwa saat main thread dari program Rust selesai, semua threads yang baru dibikin (spawned threads) bakal dimatikan, tidak peduli apakah mereka sudah selesai berjalan atau belum. Output dari program ini mungkin bakal sedikit berbeda setiap kalinya, tapi bakal kelihatan mirip seperti berikut:

hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!

Pemanggilan ke thread::sleep memaksa sebuah thread buat menghentikan eksekusinya untuk durasi yang singkat, memungkinkan thread lain buat berjalan. Threads tersebut kemungkinan bakal berjalan bergantian, tapi itu tidak dijamin: itu bergantung sama gimana sistem operasi kita menjadwalkan (schedules) threads tersebut. Di jalannya (run) kali ini, main thread mencetak duluan, meskipun statement print dari spawned thread muncul duluan di kodenya. Dan walaupun kita menyuruh spawned thread buat mencetak sampai i itu 9, ia cuma sampai ke 5 sebelum main thread dimatikan.

Kalau kita menjalankan kode ini dan cuma melihat output dari main thread, atau tidak melihat tumpang tindih (overlap) apa pun, coba naikkan angka di rentangnya (ranges) buat membikin lebih banyak kesempatan buat sistem operasi beralih di antara threads tersebut.

Kode di Listing 16-1 tidak cuma menghentikan spawned thread sebelum waktunya (prematurely) di sebagian besar waktu karena main thread yang berakhir duluan, tapi karena tidak ada jaminan di urutan mana threads itu berjalan, kita juga tidak bisa menjamin apakah spawned thread itu bakal dapat kesempatan buat berjalan sama sekali!

Kita bisa membereskan masalah spawned thread yang tidak berjalan atau berakhir sebelum waktunya dengan menyimpan nilai kembalian (return value) dari thread::spawn ke dalam sebuah variabel. Tipe kembalian dari thread::spawn adalah JoinHandle<T>. Sebuah JoinHandle<T> adalah nilai yang dimiliki (owned value) yang, saat kita memanggil method join padanya, bakal menunggu sampai thread-nya selesai. Listing 16-2 menunjukkan gimana cara memakai JoinHandle<T> dari thread yang kita bikin di Listing 16-1 dan gimana cara memanggil join buat memastikan spawned thread tersebut selesai sebelum main keluar (exits).

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}
Listing 16-2: Menyimpan sebuah JoinHandle<T> dari thread::spawn buat menjamin thread-nya berjalan sampai selesai

Memanggil join pada handle bakal memblokir (blocks) thread yang saat ini lagi jalan sampai thread yang diwakili oleh handle tersebut berhenti (terminates). Memblokir sebuah thread berarti thread tersebut dicegah buat melakukan pekerjaan atau keluar. Karena kita menaruh pemanggilan join setelah for loop milik main thread, menjalankan Listing 16-2 seharusnya menghasilkan output yang mirip kayak gini:

hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!

Dua threads tersebut lanjut berjalan bergantian, tapi main thread menunggu karena adanya pemanggilan handle.join() dan tidak berakhir sampai spawned thread-nya selesai.

Tapi mari kita lihat apa yang terjadi kalau kita malah memindahkan handle.join() sebelum for loop di main, kayak gini:

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    handle.join().unwrap();

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

Main thread bakal menunggu spawned thread buat selesai baru kemudian dia menjalankan for loop-nya, jadi outputnya tidak bakal tumpang tindih lagi, seperti yang ditunjukkan di sini:

hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!

Detail-detail kecil, kayak di mana join itu dipanggil, bisa memengaruhi apakah threads kita berjalan secara bersamaan atau tidak.

Memakai Closures move bersama Threads

Kita bakal sering memakai keyword move bersama closures yang diteruskan ke thread::spawn karena closure tersebut kemudian bakal mengambil kepemilikan atas nilai-nilai yang dia pakai dari lingkungannya, sehingga mentransfer kepemilikan dari nilai-nilai tersebut dari satu thread ke thread lainnya. Di “Menangkap Referensi atau Memindahkan Kepemilikan” di Bab 13, kita sudah membahas move di dalam konteks closures. Sekarang kita bakal lebih konsentrasi pada interaksi antara move dan thread::spawn.

Perhatikan di Listing 16-1 bahwa closure yang kita teruskan ke thread::spawn tidak menerima argumen apa pun: kita tidak memakai data apa pun dari main thread di dalam kode milik spawned thread. Buat memakai data dari main thread di dalam spawned thread, closure si spawned thread harus menangkap (capture) nilai-nilai yang dia butuhkan. Listing 16-3 menunjukkan usaha buat membikin sebuah vector di main thread dan memakainya di dalam spawned thread. Namun, ini belum bisa jalan, seperti yang bakal kita lihat sebentar lagi.

Filename: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}
Listing 16-3: Usaha buat memakai sebuah vector yang dibikin oleh main thread di dalam thread lainnya

Closure ini memakai v, jadi dia bakal menangkap v dan menjadikannya bagian dari lingkungan closure tersebut. Karena thread::spawn menjalankan closure ini di sebuah thread baru, kita seharusnya bisa mengakses v di dalam thread baru tersebut. Tapi pas kita men-compile contoh ini, kita dapat error berikut:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Here's a vector: {v:?}");
  |                                     - `v` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:18
  |
6 |       let handle = thread::spawn(|| {
  |  __________________^
7 | |         println!("Here's a vector: {v:?}");
8 | |     });
  | |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

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

Rust menebak (infers) gimana cara menangkap v, dan karena println! cuma butuh sebuah referensi ke v, closure tersebut mencoba meminjam (borrow) v. Namun, ada sebuah masalah: Rust tidak bisa memberi tahu berapa lama spawned thread tersebut bakal berjalan, jadi dia tidak tahu apakah referensi ke v itu bakal selalu valid.

Listing 16-4 memberikan skenario yang punya kemungkinan lebih tinggi di mana referensi ke v tidak bakal valid.

Filename: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    drop(v); // oh no!

    handle.join().unwrap();
}
Listing 16-4: Sebuah thread dengan closure yang mencoba menangkap sebuah referensi ke v dari sebuah main thread yang men-drop v

Kalau Rust mengizinkan kita menjalankan kode ini, ada kemungkinan kalau spawned thread tersebut bakal langsung ditaruh di background (latar belakang) tanpa sempat dijalankan sama sekali. Spawned thread itu punya referensi ke v di dalamnya, tapi main thread langsung men-drop (membuang) v, memakai fungsi drop yang sudah kita bahas di Bab 15. Lalu, saat spawned thread mulai dieksekusi, v sudah tidak valid lagi, jadi referensi ke dia juga ikutan tidak valid. Waduh!

Buat membenarkan error compiler di Listing 16-3, kita bisa memakai saran dari pesan error-nya:

help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

Dengan menambahkan keyword move sebelum closure, kita memaksa closure buat mengambil kepemilikan dari nilai-nilai yang dia pakai, ketimbang membiarkan Rust menebak kalau dia seharusnya meminjam (borrow) nilai-nilai tersebut. Modifikasi buat Listing 16-3 yang ditunjukkan di Listing 16-5 bakal bisa di-compile dan berjalan sesuai keinginan kita.

Filename: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}
Listing 16-5: Memakai keyword move buat memaksa sebuah closure untuk mengambil kepemilikan dari nilai-nilai yang dia pakai

Kita mungkin tergiur buat mencoba hal yang sama buat membenarkan kode di Listing 16-4 di mana main thread memanggil drop, dengan memakai closure move. Namun, perbaikan ini tidak bakal bisa karena apa yang coba dilakukan oleh Listing 16-4 itu tidak diizinkan buat alasan yang berbeda. Kalau kita menambahkan move ke closure tersebut, kita bakal memindahkan v ke dalam lingkungan closure-nya, dan kita tidak bisa lagi memanggil drop padanya di main thread. Kita malah bakal dapat error compiler ini:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0382]: use of moved value: `v`
  --> src/main.rs:10:10
   |
 4 |     let v = vec![1, 2, 3];
   |         - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
 5 |
 6 |     let handle = thread::spawn(move || {
   |                                ------- value moved into closure here
 7 |         println!("Here's a vector: {v:?}");
   |                                     - variable moved due to use in closure
...
10 |     drop(v); // oh no!
   |          ^ value used here after move
   |
help: consider cloning the value before moving it into the closure
   |
 6 ~     let value = v.clone();
 7 ~     let handle = thread::spawn(move || {
 8 ~         println!("Here's a vector: {value:?}");
   |

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

Aturan ownership (kepemilikan) Rust sudah menyelamatkan kita lagi! Kita dapat error dari kode di Listing 16-3 karena Rust bertindak konservatif dan cuma meminjam v buat thread tersebut, yang mana berarti main thread secara teoritis bisa membikin referensi si spawned thread jadi tidak valid. Dengan memberi tahu Rust buat memindahkan kepemilikan dari v ke spawned thread, kita menjamin ke Rust kalau main thread tidak bakal memakai v lagi. Kalau kita mengubah Listing 16-4 dengan cara yang sama, kita malah melanggar aturan kepemilikan saat kita mencoba memakai v di main thread. Keyword move menimpa (overrides) aturan default konservatif Rust yang melakukan peminjaman (borrowing); ia tidak mengizinkan kita buat melanggar aturan kepemilikannya.

Sekarang karena kita sudah membahas apa itu threads dan method-method yang disediakan oleh API thread, mari kita lihat beberapa situasi di mana kita bisa memakai threads.

Transfer Data antar Threads Memakai Message Passing

Memakai Message Passing buat Mentransfer Data Antar Threads

Satu pendekatan yang makin populer buat memastikan konkurensi yang aman adalah message passing (pengiriman pesan), di mana threads atau actors berkomunikasi dengan mengirimkan pesan berisi data ke satu sama lain. Ide ini digambarkan dalam sebuah slogan dari dokumentasi bahasa Go: “Jangan berkomunikasi dengan membagikan (sharing) memori; sebaliknya, bagikan memori dengan berkomunikasi.”

Buat mencapai konkurensi pengiriman pesan ini, standard library Rust menyediakan sebuah implementasi dari saluran (channels). Sebuah channel adalah konsep pemrograman umum di mana data dikirim dari satu thread ke thread lainnya.

Kita bisa membayangkan sebuah channel di dalam pemrograman itu kayak saluran air yang mengalir ke satu arah, seperti sungai atau selokan. Kalau kita menaruh sesuatu kayak bebek karet ke dalam sungai, dia bakal mengalir ke hilir (downstream) sampai ke ujung saluran air tersebut.

Sebuah channel punya dua paruh: sebuah transmitter (pemancar/pengirim) dan sebuah receiver (penerima). Paruh transmitter adalah lokasi hulu (upstream) tempat kita menaruh bebek karetnya ke dalam sungai, dan paruh receiver adalah hilir tempat si bebek karet pada akhirnya berlabuh. Satu bagian dari kode kita memanggil method-method di transmitter dengan data yang mau kita kirim, dan bagian lain mengecek ujung penerima (receiving end) buat melihat pesan yang datang. Sebuah channel dikatakan closed (tertutup) kalau entah paruh transmitter atau receiver-nya di-drop (dibuang).

Di sini, kita bakal perlahan ngebangun sebuah program yang punya satu thread buat nge-generate nilai dan mengirimkannya ke dalam sebuah channel, dan satu thread lain yang bakal menerima nilai-nilai tersebut lalu mencetaknya ke layar. Kita bakal mengirim nilai-nilai sederhana antar threads memakai sebuah channel buat mengilustrasikan fitur ini. Begitu kita udah terbiasa sama tekniknya, kita bisa memakai channels buat threads mana aja yang butuh berkomunikasi satu sama lain, kayak sistem chat atau sistem di mana banyak threads melakukan bagian-bagian dari sebuah perhitungan lalu mengirim bagian-bagian tersebut ke satu thread yang mengagregasikan (mengumpulkan) hasilnya.

Pertama, di Listing 16-6, kita bakal membikin sebuah channel tapi tidak melakukan apa-apa dengannya. Perhatikan bahwa ini belum bisa di-compile karena Rust tidak tahu tipe nilai apa yang mau kita kirim lewat channel ini.

Filename: src/main.rs
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
}
Listing 16-6: Membikin sebuah channel dan me-assign kedua paruhnya ke tx dan rx

Kita membikin sebuah channel baru memakai fungsi mpsc::channel; mpsc adalah singkatan dari multiple producer, single consumer (banyak penghasil, satu konsumen). Singkatnya, cara standard library Rust mengimplementasikan channels berarti sebuah channel bisa punya banyak ujung pengirim (sending ends) yang memproduksi nilai tapi cuma bisa punya satu ujung penerima (receiving end) yang mengonsumsi nilai-nilai tersebut. Bayangin ada banyak aliran sungai kecil yang ngalir bersatu jadi satu sungai besar: apa pun yang dikirim ke salah satu sungai kecil itu bakal berakhir di satu sungai besar tersebut di ujungnya. Kita bakal mulai dengan satu produsen (producer) aja buat sekarang, tapi kita bakal nambahin banyak produsen pas contoh ini udah bisa jalan.

Fungsi mpsc::channel mengembalikan sebuah tuple, yang elemen pertamanya adalah ujung pengirim (the sending end)—yaitu si transmitter—dan elemen keduanya adalah ujung penerima (the receiving end)—yaitu si receiver. Singkatan tx dan rx secara tradisional sering dipakai di banyak bidang untuk masing-masing transmitter dan receiver, jadi kita menamai variabel kita dengan nama tersebut buat mengindikasikan setiap ujungnya. Kita memakai statement let dengan sebuah pola (pattern) yang men-destructure tuples tersebut; kita bakal membahas pemakaian pola di dalam statements let dan destructuring di Bab 19. Buat sekarang, ketahui aja kalau memakai statement let dengan cara ini adalah pendekatan yang nyaman buat mengekstrak potongan-potongan dari tuple yang dikembalikan sama mpsc::channel.

Mari kita pindahkan ujung pengirim (transmitting end) ke dalam spawned thread lalu suruh dia mengirim satu string supaya spawned thread tersebut berkomunikasi sama main thread, seperti yang ditunjukkan di Listing 16-7. Ini ibarat naruh bebek karet di bagian hulu sungai atau ngirim pesan chat dari satu thread ke thread lainnya.

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });
}
Listing 16-7: Memindahkan tx ke dalam sebuah spawned thread dan mengirim "hi"

Sekali lagi, kita memakai thread::spawn buat membikin thread baru lalu memakai move buat memindahkan tx ke dalam closure supaya spawned thread tersebut memiliki (owns) tx. Spawned thread perlu memiliki si transmitter supaya bisa mengirim pesan lewat channel.

Transmitter punya sebuah method send yang menerima nilai yang mau kita kirim. Method send mengembalikan sebuah tipe Result<T, E>, jadi kalau receiver-nya ternyata sudah di-drop dan tidak ada tempat lagi buat ngirim nilai, operasi pengirimannya (send) bakal mengembalikan sebuah error. Di contoh ini, kita memanggil unwrap buat panic seandainya terjadi error. Tapi di aplikasi betulan (real application), kita bakal menanganinya dengan benar: silakan kembali ke Bab 9 buat me-review strategi-strategi buat penanganan error yang tepat.

Di Listing 16-8, kita bakal mengambil nilainya dari receiver di dalam main thread. Ini ibarat memungut bebek karet dari air di ujung hilir sungai atau menerima sebuah pesan chat.

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("Got: {received}");
}
Listing 16-8: Menerima nilai "hi" di dalam main thread dan mencetaknya

Receiver punya dua method yang berguna: recv dan try_recv. Kita memakai recv, singkatan dari receive (menerima), yang bakal memblokir eksekusi dari main thread dan menunggu sampai sebuah nilai dikirim lewat channel tersebut. Begitu ada nilai yang dikirim, recv bakal mengembalikannya di dalam sebuah Result<T, E>. Saat transmitter ditutup, recv bakal mengembalikan sebuah error buat memberi sinyal bahwa tidak akan ada lagi nilai yang bakal datang.

Method try_recv tidak melakukan pemblokiran (doesn’t block), tapi ia bakal langsung mengembalikan sebuah Result<T, E>: nilai Ok yang memegang sebuah pesan kalau pesannya lagi tersedia dan nilai Err kalau tidak ada pesan sama sekali saat ini. Memakai try_recv berguna kalau thread ini punya kerjaan lain yang harus dilakuin sambil nunggu pesan: kita bisa nulis sebuah loop yang memanggil try_recv sesekali, menangani pesannya kalau lagi tersedia, dan kalau enggak, ngerjain tugas lain dulu sebentar sampai waktunya ngecek lagi.

Kita memakai recv di contoh ini buat kesederhanaan; kita tidak punya kerjaan lain buat main thread selain menunggu pesan, jadi memblokir main thread adalah pilihan yang tepat.

Pas kita menjalankan kode di Listing 16-8, kita bakal melihat nilai yang dicetak dari main thread:

Got: hi

Sempurna!

Channels dan Transfer Kepemilikan (Ownership Transference)

Aturan-aturan ownership (kepemilikan) memainkan peran vital dalam pengiriman pesan karena mereka ngebantu kita nulis kode konkuren yang aman. Mencegah error di pemrograman konkuren adalah keuntungan (advantage) dari memikirkan tentang ownership di sepanjang program Rust kita. Mari kita lakukan sebuah eksperimen buat nunjukin gimana channels dan ownership bekerja bersama-sama mencegah timbulnya masalah: kita bakal nyoba memakai sebuah nilai val di dalam spawned thread setelah kita ngirim nilai itu lewat channel. Coba compile kode di Listing 16-9 buat melihat kenapa kode ini tidak diizinkan.

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
        println!("val is {val}");
    });

    let received = rx.recv().unwrap();
    println!("Got: {received}");
}
Listing 16-9: Mencoba memakai val setelah kita mengirimnya lewat channel

Di sini, kita mencoba buat mencetak val setelah kita mengirimnya lewat channel via tx.send. Mengizinkan ini adalah ide yang buruk: begitu nilainya sudah dikirim ke thread lain, thread tersebut bisa aja memodifikasi atau men-drop-nya sebelum kita nyoba memakai nilai itu lagi. Secara potensial, modifikasi yang dilakukan sama thread lain bisa menyebabkan error atau hasil yang tidak disangka-sangka akibat adanya data yang tidak konsisten atau sudah tidak eksis lagi. Namun, Rust ngasih kita error kalau kita mencoba men-compile kode di Listing 16-9:

$ cargo run
   Compiling message-passing v0.1.0 (file:///projects/message-passing)
error[E0382]: borrow of moved value: `val`
  --> src/main.rs:10:27
   |
 8 |         let val = String::from("hi");
   |             --- move occurs because `val` has type `String`, which does not implement the `Copy` trait
 9 |         tx.send(val).unwrap();
   |                 --- value moved here
10 |         println!("val is {val}");
   |                           ^^^ value borrowed here after move
   |
   = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

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

Kesalahan konkurensi (concurrency mistake) kita ini sudah membikin sebuah error compile-time. Fungsi send mengambil kepemilikan atas parameternya, dan saat nilainya dipindahkan (moved), receiver bakal mengambil kepemilikannya. Hal ini menghentikan kita dari memakai nilai itu secara tidak sengaja lagi setelah mengirimnya; sistem ownership memastikan kalau semuanya aman terkendali.

Mengirim Banyak Nilai dan Melihat Receiver Menunggu

Kode di Listing 16-8 berhasil di-compile dan jalan, tapi dia tidak secara jelas menunjukkan ke kita kalau ada dua threads terpisah yang lagi ngobrol satu sama lain lewat channel.

Di Listing 16-10 kita sudah membuat beberapa modifikasi yang bakal membuktikan kalau kode di Listing 16-8 itu berjalan secara konkuren: spawned thread sekarang bakal mengirim banyak pesan dan ngasih jeda sebentar (pause) satu detik di antara setiap pesannya.

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {received}");
    }
}
Listing 16-10: Mengirim banyak pesan dan ngasih jeda (pause) di antara setiap pesannya

Kali ini, spawned thread punya sebuah vector berisi string yang mau kita kirim ke main thread. Kita iterasi ngelewatin mereka, ngirim setiap string-nya satu-satu, dan ngasih jeda di antara setiap pengiriman dengan memanggil fungsi thread::sleep beserta sebuah nilai Duration satu detik.

Di dalam main thread, kita tidak memanggil fungsi recv secara eksplisit lagi: sebaliknya, kita memperlakukan rx sebagai sebuah iterator. Buat setiap nilai yang diterima, kita bakal mencetaknya. Saat channel-nya ditutup, iterasinya bakal berakhir.

Pas kita menjalankan kode di Listing 16-10, kita seharusnya melihat output berikut dengan jeda satu detik di antara setiap barisnya:

Got: hi
Got: from
Got: the
Got: thread

Karena kita tidak punya kode yang melakukan jeda atau penundaan (delays) di dalam for loop di main thread, kita bisa tahu kalau main thread tersebut lagi nunggu buat nerima nilai-nilai dari spawned thread.

Membikin Banyak Producers dengan Meng-clone si Transmitter

Tadi kita sempat menyebutkan kalau mpsc adalah singkatan dari multiple producer, single consumer (banyak penghasil, satu konsumen). Mari kita manfaatin mpsc ini lalu ekspansi kode di Listing 16-10 buat membikin beberapa threads yang semuanya mengirim nilai ke satu receiver yang sama. Kita bisa melakukan itu dengan meng-clone transmitter-nya, seperti yang ditunjukkan di Listing 16-11.

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    // --snip--

    let (tx, rx) = mpsc::channel();

    let tx1 = tx.clone();
    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx1.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    thread::spawn(move || {
        let vals = vec![
            String::from("more"),
            String::from("messages"),
            String::from("for"),
            String::from("you"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {received}");
    }

    // --snip--
}
Listing 16-11: Mengirim banyak pesan dari banyak producers

Kali ini, sebelum kita membikin spawned thread yang pertama, kita memanggil clone pada transmitter-nya. Ini bakal ngasih kita sebuah transmitter baru yang bisa kita teruskan ke spawned thread yang pertama. Kita meneruskan transmitter aslinya ke sebuah spawned thread yang kedua. Hal ini ngasih kita dua threads, di mana masing-masing mengirim pesan yang berbeda ke satu receiver yang sama.

Pas kita menjalankan kodenya, output kita harusnya bakal kelihatan kurang lebih kayak gini:

Got: hi
Got: more
Got: from
Got: messages
Got: for
Got: the
Got: thread
Got: you

kita mungkin bakal melihat nilai-nilainya dalam urutan yang berbeda, tergantung dari sistem yang kita pakai. Inilah yang membikin konkurensi jadi hal yang menarik sekaligus sulit. Kalau kita eksperimen sama thread::sleep, ngasih dia nilai yang beda-beda di berbagai threads tersebut, masing-masing jalan (run) bakal jadi makin tidak deterministik dan menghasilkan output yang berbeda-beda setiap kalinya.

Sekarang setelah kita melihat gimana channels itu bekerja, mari kita lihat metode konkurensi yang berbeda.

Konkurensi dengan Shared-State (Status Bersama)

Konkurensi Shared-State (Keadaan Berbagi)

Pengiriman pesan (message passing) adalah cara yang bagus buat menangani konkurensi, tapi itu bukan satu-satunya cara. Metode lain adalah dengan membiarkan banyak threads buat mengakses data bersama (shared data) yang sama. Ingat kembali slogan dari dokumentasi bahasa Go ini: “Jangan berkomunikasi dengan membagikan (sharing) memori.”

Kira-kira gimana sih bentuknya berkomunikasi dengan membagikan memori itu? Selain itu, kenapa para penggemar message-passing mewanti-wanti buat tidak memakai berbagi memori (memory sharing)?

Di satu sisi, channels (saluran) di bahasa pemrograman mana pun itu mirip dengan kepemilikan tunggal (single ownership) karena begitu kita mentransfer sebuah nilai lewat channel, kita tidak seharusnya memakai nilai itu lagi. Konkurensi memori bersama (shared-memory concurrency) itu kayak kepemilikan ganda (multiple ownership): banyak threads bisa mengakses lokasi memori yang sama di saat yang bersamaan. Seperti yang udah kita lihat di Bab 15, di mana smart pointers memungkinkan kepemilikan ganda, kepemilikan ganda bisa nambahin kerumitan (complexity) karena pemilik-pemilik yang berbeda ini butuh dikelola (managed). Sistem tipe (type system) dan aturan ownership Rust ngebantu sekali buat bikin pengelolaan ini jadi benar. Sebagai contoh, mari kita lihat mutexes, salah satu dari struktur data konkurensi (concurrency primitives) yang paling umum dipakai buat memori bersama.

Memakai Mutexes buat Mengizinkan Akses ke Data dari Satu Thread dalam Satu Waktu

Mutex adalah singkatan dari mutual exclusion (pengecualian timbal balik), yang artinya sebuah mutex cuma mengizinkan satu thread aja buat mengakses beberapa data di satu waktu tertentu. Buat mengakses data di dalam sebuah mutex, sebuah thread pertama-tama harus ngasih sinyal kalau dia mau akses dengan meminta buat ngambil (acquire) lock (kunci) milik mutex tersebut. Lock adalah struktur data yang jadi bagian dari mutex yang melacak siapa yang saat ini punya akses eksklusif ke datanya. Oleh karena itu, mutex digambarkan sebagai penjaga (guarding) data yang dia pegang melalui sistem locking ini.

Mutexes terkenal susah dipakai karena kita harus ingat dua aturan ini:

  1. Kita harus mencoba mengambil (acquire) lock-nya sebelum memakai datanya.
  2. Pas kita kelar sama data yang dijaga sama mutex tersebut, kita harus membuka kunci (unlock) datanya supaya threads lain bisa mengambil lock itu.

Sebagai metafora dunia nyata buat sebuah mutex, bayangin sebuah panel diskusi di sebuah konferensi yang cuma punya satu mikrofon. Sebelum seorang panelis bisa ngomong, dia harus minta atau ngasih sinyal kalau dia mau memakai mikrofonnya. Saat dia dapat mikrofonnya, dia bisa ngomong selama yang dia mau lalu memberikan mikrofonnya ke panelis berikutnya yang meminta buat ngomong. Kalau ada panelis yang lupa ngasih mikrofonnya ke orang lain pas udah selesai, tidak bakal ada orang lain yang bisa ngomong. Kalau pengelolaan mikrofon bersama ini jadi berantakan, panelnya tidak bakal berjalan sesuai rencana!

Pengelolaan mutex bisa jadi susah sekali buat dibikin benar, itulah kenapa banyak orang jadi antusias sekali sama channels. Namun, berkat sistem tipe dan aturan ownership di Rust, kita tidak mungkin salah mengunci dan membuka kunci.

API dari Mutex<T>

Sebagai contoh gimana cara memakai mutex, mari mulai dengan memakai sebuah mutex di dalam konteks satu thread (single-threaded), seperti yang ditunjukkan di Listing 16-12.

Filename: src/main.rs
use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }

    println!("m = {m:?}");
}
Listing 16-12: Mengeksplorasi API dari Mutex<T> di dalam konteks single-threaded buat kesederhanaan

Sama kayak banyak tipe lainnya, kita membikin sebuah Mutex<T> dengan memakai fungsi associated new. Buat mengakses data di dalam mutex-nya, kita memakai method lock buat ngambil lock-nya. Pemanggilan ini bakal memblokir (block) thread yang saat ini lagi jalan sehingga ia tidak bisa melakukan kerjaan apa-apa sampai giliran kita dapat lock-nya.

Pemanggilan ke lock bakal gagal kalau thread lain yang sedang memegang lock tersebut mengalami panic. Di kasus itu, tidak bakal ada yang bisa ngedapetin lock-nya lagi, jadi kita memilih buat unwrap dan membikin thread ini panic kalau kita ada di situasi tersebut.

Setelah kita dapat lock-nya, kita bisa memperlakukan nilai kembaliannya, yang kita namakan num di sini, sebagai referensi mutable ke data internalnya. Sistem tipe memastikan kalau kita dapat lock-nya dulu sebelum memakai nilai di dalam m. Tipe dari m adalah Mutex<i32>, bukannya i32, jadi kita harus memanggil lock supaya bisa memakai nilai i32 tersebut. Kita tidak bisa lupa; sistem tipenya tidak bakal ngebiarin kita mengakses i32 internal itu kalau kita tidak melakukannya.

Pemanggilan ke lock mengembalikan sebuah tipe bernama MutexGuard, yang dibungkus di dalam sebuah LockResult yang tadi kita tangani dengan panggilan ke unwrap. Tipe MutexGuard mengimplementasikan Deref agar dia menunjuk ke data internal kita; tipe ini juga punya implementasi Drop yang melepaskan lock-nya secara otomatis saat sebuah MutexGuard keluar dari scope, yang mana terjadi di akhir dari inner scope (scope dalam). Sebagai hasilnya, kita tidak bakal ambil risiko lupa melepaskan lock-nya dan ngeblokir mutex tersebut dari dipakai sama threads lain, karena pelepasan lock itu terjadi secara otomatis.

Setelah lock-nya di-drop, kita bisa mencetak nilai mutex tersebut dan melihat kalau kita berhasil ngubah i32 internalnya jadi 6.

Berbagi Mutex<T> di Antara Beberapa Threads

Sekarang mari kita coba membagikan sebuah nilai di antara beberapa threads memakai Mutex<T>. Kita bakal membikin (spin up) 10 threads dan menyuruh masing-masing dari mereka buat menaikkan nilai penghitung (counter) sebanyak 1, sehingga penghitungnya berjalan dari 0 sampai 10. Contoh di Listing 16-13 bakal mengalami error compiler, dan kita bakal memakai error itu buat belajar lebih banyak soal memakai Mutex<T> dan gimana Rust ngebantu kita memakainya dengan benar.

Filename: src/main.rs
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];

    for _ in 0..10 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-13: Sepuluh threads, di mana masing-masing menaikkan penghitung yang dijaga sama sebuah Mutex<T>

Kita bikin sebuah variabel counter buat memegang sebuah i32 di dalam sebuah Mutex<T>, kayak yang kita lakuin di Listing 16-12. Selanjutnya, kita bikin 10 threads dengan iterasi melewati serangkaian angka. Kita memakai thread::spawn dan ngasih closure yang sama ke semua threads itu: sebuah closure yang memindahkan (moves) penghitung tersebut ke dalam thread, mengambil lock pada Mutex<T> dengan memanggil method lock, lalu menambahkan 1 ke nilai yang ada di dalam mutex tersebut. Pas sebuah thread kelar ngejalanin closure-nya, num bakal keluar dari scope dan melepaskan lock-nya supaya thread lain bisa mengambil lock itu.

Di main thread, kita mengumpulkan (collect) semua join handles. Terus, sama kayak di Listing 16-2, kita memanggil join pada setiap handle buat memastikan semua threads selesai. Di titik itu, main thread bakal ngambil lock-nya dan mencetak hasil dari program ini.

Kita tadi udah ngebocorin kalau contoh ini tidak bakal bisa di-compile. Sekarang mari kita cari tahu alasannya kenapa!

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0382]: borrow of moved value: `counter`
  --> src/main.rs:21:29
   |
 5 |     let counter = Mutex::new(0);
   |         ------- move occurs because `counter` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait
...
 8 |     for _ in 0..10 {
   |     -------------- inside of this loop
 9 |         let handle = thread::spawn(move || {
   |                                    ------- value moved into closure here, in previous iteration of loop
...
21 |     println!("Result: {}", *counter.lock().unwrap());
   |                             ^^^^^^^ value borrowed here after move
   |
help: consider moving the expression out of the loop so it is only moved once
   |
 8 ~     let mut value = counter.lock();
 9 ~     for _ in 0..10 {
10 |         let handle = thread::spawn(move || {
11 ~             let mut num = value.unwrap();
   |

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

Pesan error-nya bilang kalau nilai counter itu udah dipindahkan (moved) di iterasi loop sebelumnya. Rust ngasih tahu kita kalau kita tidak bisa memindahkan kepemilikan lock counter ke dalam beberapa threads. Mari kita perbaiki error compiler ini dengan metode kepemilikan ganda (multiple- ownership method) yang udah kita bahas di Bab 15.

Multiple Ownership dengan Multiple Threads

Di Bab 15, kita ngasih sebuah nilai ke beberapa pemilik (multiple owners) dengan memakai smart pointer Rc<T> buat membikin nilai yang jumlah referensinya dilacak (reference counted value). Mari kita lakuin hal yang sama di sini dan lihat apa yang terjadi. Kita bakal ngebungkus Mutex<T> ke dalam Rc<T> di Listing 16-14 dan meng-clone Rc<T>-nya sebelum memindahkan kepemilikannya ke dalam thread.

Filename: src/main.rs
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Rc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Rc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-14: Mencoba memakai Rc<T> buat mengizinkan beberapa threads buat memiliki Mutex<T>

Sekali lagi, kita compile dan… dapat error yang beda! Compiler-nya ngajarin kita banyak hal.

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
  --> src/main.rs:11:36
   |
11 |           let handle = thread::spawn(move || {
   |                        ------------- ^------
   |                        |             |
   |  ______________________|_____________within this `{closure@src/main.rs:11:36: 11:43}`
   | |                      |
   | |                      required by a bound introduced by this call
12 | |             let mut num = counter.lock().unwrap();
13 | |
14 | |             *num += 1;
15 | |         });
   | |_________^ `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
   |
   = help: within `{closure@src/main.rs:11:36: 11:43}`, the trait `Send` is not implemented for `Rc<std::sync::Mutex<i32>>`
note: required because it's used within this closure
  --> src/main.rs:11:36
   |
11 |         let handle = thread::spawn(move || {
   |                                    ^^^^^^^
note: required by a bound in `spawn`
  --> /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/std/src/thread/mod.rs:723:1

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

Wow, pesan error-nya panjang sekali ya! Ini bagian penting yang perlu jadi fokus: `Rc<Mutex<i32>>` cannot be sent between threads safely. Compiler-nya juga ngasih tahu kita apa alasannya: the trait `Send` is not implemented for `Rc<Mutex<i32>>`. Kita bakal ngomongin soal Send di bagian selanjutnya: dia adalah salah satu trait yang memastikan tipe-tipe yang kita pakai bersama threads emang ditujukan buat dipakai di situasi yang konkuren.

Sayangnya, Rc<T> tidak aman buat dibagikan antar threads. Saat Rc<T> mengelola reference count, dia nambahin jumlahnya buat setiap panggilan ke clone dan ngurangin jumlahnya saat setiap clone di-drop. Tapi dia tidak memakai concurrency primitives apa pun buat memastikan kalau perubahan pada jumlah itu tidak diinterupsi sama thread lain. Hal ini bisa menyebabkan perhitungan (counts) yang salah—bugs halus (subtle bugs) yang pada akhirnya bisa menyebabkan memory leaks (kebocoran memori) atau sebuah nilai di-drop sebelum kita selesai memakainya. Apa yang kita butuhkan adalah sebuah tipe yang persis kayak Rc<T>, tapi yang ngubah jumlah referensinya pakai cara yang thread-safe (aman di lingkungan utas ganda).

Atomic Reference Counting dengan Arc<T>

Untungnya, Arc<T> adalah tipe yang mirip Rc<T> yang aman buat dipakai di situasi yang konkuren. Huruf A-nya singkatan dari atomic, yang artinya dia adalah tipe atomically reference-counted (penghitungan referensi secara atomik). Atomics adalah jenis primitif konkurensi tambahan yang tidak bakal kita bahas secara mendetail di sini: cek dokumentasi standard library buat std::sync::atomic buat detail lebih lanjut. Di titik ini, kita cuma perlu tahu kalau atomics bekerja kayak tipe primitif biasa tapi aman buat dibagikan antar threads.

Terus kita mungkin penasaran kenapa tidak semua tipe primitif dibikin jadi atomic dan kenapa tipe-tipe standard library tidak diimplementasikan buat memakai Arc<T> secara default. Alasannya adalah keamanan thread (thread safety) itu datang dengan penalti performa (performance penalty) yang mana kita cuma mau membayarnya saat kita bener-bener butuh. Kalau kita cuma ngelakuin operasi pada nilai di dalam satu thread, kode kita bisa berjalan lebih kencang kalau dia tidak perlu memaksakan jaminan yang disediakan sama atomics.

Mari kembali ke contoh kita: Arc<T> dan Rc<T> punya API yang sama, jadi kita memperbaiki program kita dengan mengubah baris use, panggilan ke new, dan panggilan ke clone. Kode di Listing 16-15 akhirnya bakal bisa di-compile dan jalan.

Filename: src/main.rs
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-15: Memakai sebuah Arc<T> buat ngebungkus Mutex<T> supaya bisa membagikan kepemilikan di beberapa threads

Kode ini bakal mencetak output berikut:

Result: 10

Kita berhasil! Kita menghitung dari 0 sampai 10, yang mungkin tidak kelihatan terlalu mengesankan, tapi itu banyak ngajarin kita soal Mutex<T> dan keamanan thread. Kita juga bisa memakai struktur program ini buat melakukan operasi yang lebih rumit ketimbang cuma menaikkan nilai penghitung. Memakai strategi ini, kita bisa membagi perhitungan jadi bagian-bagian independen, memecah bagian- bagian itu ke berbagai threads, lalu memakai Mutex<T> agar masing-masing thread bisa meng-update hasil akhirnya dengan bagian perhitungan mereka.

Perhatikan bahwa kalau kita ngelakuin operasi angka (numerical operations) yang simpel, ada tipe yang lebih sederhana ketimbang tipe Mutex<T> yang disediakan sama modul std::sync::atomic di standard library. Tipe-tipe ini menyediakan akses yang aman, konkuren, dan atomik ke tipe-tipe primitif. Kita memilih buat memakai Mutex<T> bersama sebuah tipe primitif buat contoh ini supaya kita bisa berkonsentrasi pada gimana Mutex<T> itu bekerja.

Kesamaan Antara RefCell<T>/Rc<T> dan Mutex<T>/Arc<T>

kita mungkin nyadar kalau counter itu immutable tapi kita bisa dapat sebuah referensi mutable ke nilai di dalamnya; ini artinya Mutex<T> menyediakan interior mutability (mutabilitas interior), sama kayak keluarga Cell. Dengan cara yang sama seperti kita memakai RefCell<T> di Bab 15 buat memungkinkan kita memutasi isi di dalam sebuah Rc<T>, kita memakai Mutex<T> buat memutasi isi di dalam sebuah Arc<T>.

Detail lain yang perlu diperhatikan adalah Rust tidak bisa melindungi kita dari semua jenis logic errors (kutu logika) pas kita memakai Mutex<T>. Ingat kembali dari Bab 15 kalau memakai Rc<T> punya risiko membikin reference cycles (siklus referensi), di mana dua nilai Rc<T> merujuk ke satu sama lain, yang menyebabkan memory leaks. Serupa dengan hal itu, Mutex<T> punya risiko membikin deadlocks (jalan buntu). Hal ini terjadi pas suatu operasi butuh mengambil dua locks dan dua threads masing-masing udah ngambil salah satu dari locks tersebut, yang menyebabkan mereka saling menunggu satu sama lain selamanya. Kalau kita tertarik sama deadlocks, coba bikin program Rust yang punya sebuah deadlock; terus cari tahu soal strategi mitigasi deadlock buat mutexes di bahasa pemrograman apa pun lalu cobalah mengimplementasikan strategi itu di Rust. Dokumentasi API standard library buat Mutex<T> dan MutexGuard nawarin informasi yang berguna.

Kita bakal menuntaskan bab ini dengan ngomongin soal trait Send dan Sync dan gimana cara kita bisa memakainya bersama custom types (tipe kustom).

Konkurensi yang Bisa Diperluas Memakai Send dan Sync

Konkurensi yang Bisa Diperluas (Extensible) dengan Trait Send dan Sync

Yang menarik, hampir semua fitur konkurensi yang udah kita bahas sejauh ini di bab ini adalah bagian dari standard library, bukan bagian dari bahasa Rust itu sendiri. Pilihan kita buat menangani konkurensi tidak cuma terbatas pada apa yang disediakan oleh bahasa atau standard library; kita bisa nulis fitur konkurensi kita sendiri atau memakai yang udah ditulis sama orang lain.

Namun, di antara konsep-konsep konkurensi utama yang tertanam (embedded) di dalam bahasanya ketimbang di standard library adalah marker traits (trait penanda) std::marker yaitu Send dan Sync.

Mengizinkan Transfer Kepemilikan Antar Threads dengan Send

Marker trait Send mengindikasikan bahwa kepemilikan (ownership) dari nilai dengan tipe yang mengimplementasikan Send itu bisa ditransfer antar threads. Hampir setiap tipe di Rust mengimplementasikan Send, tapi ada beberapa pengecualian, termasuk Rc<T>: tipe ini tidak bisa mengimplementasikan Send karena kalau kita meng-clone sebuah nilai Rc<T> lalu mencoba buat mentransfer kepemilikan dari clone tersebut ke thread lain, kedua threads bisa aja meng-update reference count di waktu yang bersamaan. Atas alasan inilah, Rc<T> diimplementasikan buat dipakai di situasi single-threaded di mana kita tidak mau membayar penalti performa (performance penalty) demi keamanan thread.

Oleh karena itu, sistem tipe dan trait bounds Rust memastikan kalau kita tidak bakal bisa secara tidak sengaja mengirim nilai Rc<T> melintasi threads dengan cara yang tidak aman. Pas kita mencoba melakukan ini di Listing 16-14, kita dapat error the trait `Send` is not implemented for `Rc<Mutex<i32>>`. Pas kita ganti jadi pakai Arc<T>, yang mana emang mengimplementasikan Send, kodenya berhasil di-compile.

Tipe apa pun yang secara utuh disusun (composed entirely) dari tipe-tipe yang mengimplementasikan Send bakal secara otomatis ditandai sebagai Send juga. Hampir semua tipe primitif itu Send, kecuali untuk raw pointers (pointer mentah), yang bakal kita bahas di Bab 20.

Mengizinkan Akses dari Banyak Threads dengan Sync

Marker trait Sync mengindikasikan kalau aman buat sebuah tipe yang mengimplementasikan Sync untuk dirujuk (referenced) dari banyak threads. Dengan kata lain, tipe T apa pun mengimplementasikan Sync kalau &T (referensi immutable ke T) mengimplementasikan Send, yang berarti referensi tersebut bisa dikirim dengan aman ke thread lain. Sama kayak Send, semua tipe primitif mengimplementasikan Sync, dan tipe-tipe yang secara utuh disusun dari tipe-tipe yang mengimplementasikan Sync juga bakal mengimplementasikan Sync.

Smart pointer Rc<T> juga tidak mengimplementasikan Sync dengan alasan yang sama kayak kenapa dia tidak mengimplementasikan Send. Tipe RefCell<T> (yang kita bahas di Bab 15) dan keluarga dari tipe Cell<T> yang terkait juga tidak mengimplementasikan Sync. Implementasi dari borrow checking yang dilakukan RefCell<T> saat runtime itu tidak thread-safe (tidak aman di lingkungan banyak utas). Smart pointer Mutex<T> mengimplementasikan Sync dan bisa dipakai buat berbagi akses dengan banyak threads, seperti yang kita lihat di “Berbagi Mutex<T> di Antara Beberapa Threads”.

Mengimplementasikan Send dan Sync secara Manual Itu Unsafe (Tidak Aman)

Karena tipe yang secara utuh disusun dari tipe-tipe lain yang mengimplementasikan trait Send dan Sync itu juga otomatis mengimplementasikan Send dan Sync, kita tidak perlu mengimplementasikan trait-trait tersebut secara manual. Sebagai marker traits, mereka bahkan tidak punya method apa pun buat diimplementasikan. Mereka cuma berguna buat memaksakan (enforcing) aturan baku (invariants) yang berkaitan dengan konkurensi.

Mengimplementasikan trait-trait ini secara manual melibatkan penulisan kode Rust yang unsafe. Kita bakal ngomongin soal memakai kode Rust yang unsafe di Bab 20; buat sekarang, informasi pentingnya adalah bahwa membangun tipe konkuren baru yang tidak disusun dari bagian-bagian yang Send dan Sync membutuhkan pemikiran yang ekstra hati-hati buat mempertahankan jaminan keamanannya (safety guarantees). “The Rustonomicon” punya lebih banyak informasi soal jaminan-jaminan ini dan gimana cara mempertahankannya.

Ringkasan

Ini bukan terakhir kalinya kita bakal melihat konkurensi di buku ini: bab berikutnya berfokus pada pemrograman async, dan project di Bab 21 bakal memakai konsep-konsep di bab ini di dalam situasi yang lebih realistis ketimbang contoh-contoh kecil yang dibahas di sini.

Seperti yang disebutkan sebelumnya, karena cuma sebagian kecil dari cara Rust menangani konkurensi itu yang menjadi bagian dari bahasanya, banyak solusi konkurensi diimplementasikan dalam bentuk crates. Crate-crate ini berevolusi lebih cepat daripada standard library, jadi pastikan kita mencari secara online buat crates yang paling mutakhir (state-of-the-art) buat dipakai di situasi- situasi multithreaded.

Standard library Rust menyediakan channels buat pengiriman pesan (message passing) dan tipe-tipe smart pointer, seperti Mutex<T> dan Arc<T>, yang aman buat dipakai di konteks konkuren. Sistem tipe dan borrow checker memastikan kalau kode yang memakai solusi-solusi ini tidak bakal berujung pada data races atau referensi yang tidak valid. Begitu kita berhasil membikin kode kita bisa di-compile, kita bisa bernapas lega karena dia bakal jalan dengan bahagia di atas banyak threads tanpa jenis bugs yang susah dilacak kayak yang biasa terjadi di bahasa pemrograman lain. Pemrograman konkuren bukan lagi konsep yang perlu ditakutkan: maju terus dan bikin program kita konkuren tanpa rasa takut!

Dasar-dasar Pemrograman Asinkron: Async, Await, Futures, dan Streams

Banyak operasi yang kita suruh komputer buat lakukan bisa memakan waktu agak lama buat selesai. Bakal menyenangkan sekali kalau kita bisa melakukan hal lain selagi kita nungguin proses-proses yang panjang itu buat kelar. Komputer modern menawarkan dua teknik buat mengerjakan lebih dari satu operasi pada satu waktu: paralelisme (parallelism) dan konkurensi (concurrency). Namun, logika program kita biasanya ditulis dengan gaya yang linear. Kita pengen bisa menentukan operasi apa aja yang harus dilakukan program dan titik-titik di mana sebuah fungsi bisa berhenti sejenak (pause) dan bagian lain dari program bisa berjalan sebagai gantinya, tanpa perlu menentukan di awal secara persis urutan dan cara tiap potongan kode itu berjalan. Pemrograman asinkron (asynchronous programming) adalah sebuah abstraksi yang memungkinkan kita mengekspresikan kode kita dalam bentuk titik-titik jeda potensial dan hasil akhirnya, yang mana bakal menangani detail koordinasinya buat kita.

Bab ini dibangun di atas penggunaan threads buat paralelisme dan konkurensi di Bab 16 dengan memperkenalkan pendekatan alternatif buat nulis kode: futures, streams, dan sintaks async dan await di Rust yang memungkinkan kita buat mengekspresikan gimana operasi-operasi tersebut bisa bersifat asinkron, serta crates pihak ketiga yang mengimplementasikan asynchronous runtimes: kode yang mengelola dan mengoordinasi eksekusi dari operasi-operasi asinkron tersebut.

Mari kita pertimbangkan sebuah contoh. Katakanlah kita lagi ngekspor video perayaan keluarga yang sudah kita bikin, sebuah operasi yang bisa memakan waktu mulai dari beberapa menit sampai berjam-jam. Ekspor video bakal memakai sebanyak mungkin tenaga CPU dan GPU yang bisa dia dapatkan. Kalau kita cuma punya satu core (inti) CPU dan sistem operasi kita tidak nge-pause ekspor itu sampai ia selesai—yaitu, kalau ia mengeksekusi ekspornya secara sinkron (synchronously)—kita tidak bakal bisa ngelakuin hal lain di komputer kita saat tugas itu berjalan. Itu bakal jadi pengalaman yang cukup bikin frustrasi. Untungnya, sistem operasi di komputer kita bisa, dan emang ngelakuin itu, menginterupsi proses ekspor secara kasatmata cukup sering biar kita bisa mengerjakan tugas lain di saat yang bersamaan.

Sekarang katakanlah kita lagi men-download video yang di-share sama orang lain, yang juga bisa memakan waktu lumayan lama tapi tidak terlalu menyita banyak waktu CPU. Di kasus ini, CPU harus menunggu data dari jaringan buat tiba. Meskipun kita bisa mulai membaca data tersebut begitu dia mulai berdatangan, itu bisa memakan waktu beberapa saat sampai semuanya muncul. Bahkan setelah semua datanya ada, kalau videonya sangat besar, itu bisa memakan waktu setidaknya satu atau dua detik buat nge-load semuanya. Itu mungkin tidak kedengeran terlalu lama, tapi itu adalah waktu yang sangat lama buat prosesor modern, yang mana bisa melakukan miliaran operasi setiap detik. Sekali lagi, sistem operasi kita bakal menginterupsi program kita secara kasatmata buat membiarkan CPU mengerjakan tugas lain sembari nunggu pemanggilan jaringan selesai.

Ekspor video adalah contoh dari operasi CPU-bound atau compute-bound (bergantung pada prosesor). Dia dibatasi oleh potensi kecepatan pemrosesan data komputer di dalam CPU atau GPU, dan seberapa besar dari kecepatan itu yang bisa didedikasikan buat operasi tersebut. Download video adalah contoh dari operasi I/O-bound (bergantung pada input-output), karena dia dibatasi oleh kecepatan input dan output komputer; dia cuma bisa berjalan secepat data yang bisa dikirim lewat jaringan.

Di dua contoh ini, interupsi kasatmata dari sistem operasi ngasih suatu bentuk konkurensi. Namun, konkurensi itu cuma terjadi di tingkat keseluruhan program: sistem operasi menginterupsi satu program buat membiarkan program lain mengerjakan tugasnya. Di banyak kasus, karena kita paham program kita di tingkat yang jauh lebih spesifik (granular) ketimbang sistem operasi, kita bisa menemukan peluang-peluang buat konkurensi yang tidak bisa dilihat sama sistem operasi.

Misalnya, kalau kita lagi bikin tool buat mengelola download file, kita seharusnya bisa menulis program kita sedemikian rupa sehingga pas mulai nge-download satu file UI-nya tidak bakal macet (lock up), dan user seharusnya bisa memulai banyak download di saat yang bersamaan. Namun, banyak API sistem operasi buat berinteraksi dengan jaringan itu sifatnya blocking (memblokir); yakni, mereka memblokir progress program sampai data yang mereka proses itu benar-benar siap seutuhnya.

Catatan: Ini adalah cara kerja kebanyakan pemanggilan fungsi, kalau dipikir-pikir. Namun, istilah blocking biasanya dikhususkan buat pemanggilan fungsi yang berinteraksi dengan file, jaringan, atau sumber daya lain di komputer, karena itu adalah kasus-kasus di mana suatu program individu bakal dapat keuntungan kalau operasi tersebut non-blocking.

Kita bisa menghindari memblokir main thread kita dengan membikin sebuah thread khusus buat men-download tiap file. Namun, beban (overhead) dari sumber daya sistem yang dipakai sama threads itu pada akhirnya bakal jadi masalah. Bakal lebih oke kalau pemanggilan fungsinya dari awal emang tidak memblokir, dan sebaliknya kita bisa menentukan sejumlah tugas yang pengen diselesaikan program kita lalu membiarkan runtime memilih urutan dan cara terbaik buat menjalankannya.

Nah, itulah persisnya yang dikasih sama abstraksi asinkron (singkatan dari async) di Rust buat kita. Di bab ini, kita bakal belajar semua hal tentang asinkron selagi kita membahas topik-topik berikut:

  • Gimana cara memakai sintaks async dan await di Rust dan mengeksekusi fungsi-fungsi asinkron dengan sebuah runtime
  • Gimana cara memakai model asinkron buat nyelesein beberapa tantangan yang sama seperti yang udah kita lihat di Bab 16
  • Gimana multithreading dan asinkron menyediakan solusi yang saling melengkapi, yang bisa kita gabungkan di banyak kasus

Tapi, sebelum kita lihat gimana cara kerja asinkron di praktiknya, kita perlu sedikit belok sebentar buat ngebahas perbedaan antara paralelisme dan konkurensi.

Paralelisme dan Konkurensi

Sejauh ini kita memperlakukan paralelisme dan konkurensi seolah-olah maknanya bisa ditukar-tukar. Sekarang kita harus bisa membedakannya dengan lebih presisi, karena perbedaannya bakal kerasa pas kita udah mulai bekerja.

Bayangin aja cara-cara beda yang bisa dipakai sebuah tim buat ngebagi kerjaan di suatu proyek software. Kita bisa menugaskan satu orang beberapa tugas, menugaskan tiap anggota satu tugas, atau memakai campuran dari kedua pendekatan tersebut.

Saat satu orang individu mengerjakan beberapa tugas yang berbeda sebelum satupun dari tugas-tugas itu selesai, ini dinamakan konkurensi. Salah satu cara buat mengimplementasikan konkurensi adalah mirip kayak punya dua project berbeda yang lagi dibuka di komputer kita, dan pas kita bosan atau mentok di satu project, kita beralih ke project yang satu lagi. Kita cuma satu orang, jadi kita tidak bisa bikin progress di kedua tugas pada waktu yang sama persis, tapi kita bisa multitask, bikin progress pada satu tugas secara bergantian dengan beralih di antara mereka (lihat Gambar 17-1).

Sebuah diagram dengan kotak-kotak bertumpuk berlabel Tugas A dan Tugas B, dengan belah ketupat di dalamnya yang melambangkan subtugas. Ada panah yang menunjuk dari A1 ke B1, B1 ke A2, A2 ke B2, B2 ke A3, A3 ke A4, dan A4 ke B3. Panah di antara subtugas-subtugas ini menyilang di antara kotak untuk Tugas A dan Tugas B.
Gambar 17-1: Sebuah alur kerja konkuren, beralih di antara Tugas A dan Tugas B

Saat sebuah tim membagi sekelompok tugas dengan nyuruh setiap anggota mengambil satu tugas lalu mengerjakannya sendirian, ini dinamakan paralelisme. Setiap orang di tim tersebut bisa membikin progress pada waktu yang sama persis (lihat Gambar 17-2).

Sebuah diagram dengan kotak-kotak bertumpuk berlabel Tugas A dan Tugas B, dengan belah ketupat di dalamnya yang melambangkan subtugas. Ada panah yang menunjuk dari A1 ke A2, A2 ke A3, A3 ke A4, B1 ke B2, dan B2 ke B3. Tidak ada panah yang menyilang antara kotak untuk Tugas A dan Tugas B.
Gambar 17-2: Sebuah alur kerja paralel, di mana pekerjaan terjadi pada Tugas A dan Tugas B secara independen

Di kedua alur kerja (workflows) ini, kita mungkin harus mengoordinasikan antara tugas-tugas yang berbeda. Mungkin kita pikir tugas yang diberikan ke satu orang itu benar-benar independen dari pekerjaan orang lain, tapi ternyata dia butuh orang lain di tim itu buat nyelesein tugas mereka lebih dulu. Beberapa pekerjaan bisa dilakukan secara paralel, tapi sebagian darinya itu sebenarnya serial: dia cuma bisa terjadi dalam satu urutan, satu tugas setelah tugas lainnya, kayak di Gambar 17-3.

Sebuah diagram dengan kotak-kotak bertumpuk berlabel Tugas A dan Tugas B, dengan belah ketupat di dalamnya yang melambangkan subtugas. Di Tugas A, panah menunjuk dari A1 ke A2, dari A2 ke sepasang garis vertikal tebal seperti simbol “pause”, dan dari simbol itu ke A3. Di Tugas B, panah menunjuk dari B1 ke B2, B2 ke B3, B3 ke A3, dan B3 ke B4.
Gambar 17-3: Sebuah alur kerja yang sebagiannya paralel, di mana pekerjaan terjadi pada Tugas A dan Tugas B secara independen sampai Tugas A3 terhambat (blocked) menunggu hasil dari Tugas B3.

Sama halnya, kita mungkin sadar kalau salah satu tugas kita sendiri itu bergantung sama tugas kita yang lainnya. Sekarang pekerjaan konkuren kita juga udah jadi serial.

Paralelisme dan konkurensi juga bisa bersinggungan satu sama lain. Kalau kita tahu kalau rekan kerja kita lagi mentok sampai kita nyelesein salah satu tugas kita, kita mungkin bakal memusatkan seluruh usaha kita pada tugas itu buat “membuka jalan” (unblock) rekan kerja kita tersebut. Kita dan rekan kerja kita tidak lagi bisa bekerja secara paralel, dan kita juga tidak lagi bisa bekerja secara konkuren di tugas kita masing-masing.

Dinamika dasar yang sama mulai berlaku pada perangkat lunak dan perangkat keras. Di mesin yang punya satu core CPU, CPU cuma bisa melakukan satu operasi pada satu waktu, tapi dia tetap bisa bekerja secara konkuren. Memakai alat seperti threads, proses, dan asinkron, komputer bisa mem-pause satu aktivitas lalu beralih ke aktivitas lain sebelum akhirnya berputar kembali ke aktivitas pertama tadi. Di mesin dengan banyak core CPU, dia juga bisa mengerjakan tugas secara paralel. Satu core bisa ngerjain satu tugas sementara core lain ngerjain tugas yang sama sekali tidak ada hubungannya, dan operasi-operasi itu benar-benar terjadi pada waktu yang bersamaan.

Menjalankan kode asinkron di Rust biasanya terjadi secara konkuren. Tergantung pada perangkat keras, sistem operasi, dan async runtime yang lagi kita pakai (async runtimes bakal dibahas sebentar lagi), konkurensi itu mungkin juga memakai paralelisme di balik layar.

Sekarang, mari kita selami gimana cara kerja pemrograman asinkron di Rust sebenarnya.

Futures dan Sintaks Async

Futures dan Sintaks Async

Elemen kunci dari pemrograman asinkron di Rust adalah futures dan keyword async dan await milik Rust.

Sebuah future adalah nilai yang mungkin belum siap sekarang tapi bakal jadi siap di suatu waktu di masa depan. (Konsep yang sama ini juga muncul di banyak bahasa lain, kadang-kadang pakai nama lain kayak task atau promise.) Rust menyediakan trait Future sebagai blok penyusun supaya berbagai operasi asinkron bisa diimplementasikan pakai struktur data yang berbeda-beda tapi dengan antarmuka (interface) yang sama. Di Rust, futures adalah tipe-tipe yang mengimplementasikan trait Future. Tiap future menyimpan informasinya sendiri soal sejauh mana kemajuan yang sudah dibuat dan apa makna dari “siap” (“ready”).

Kita bisa menerapkan keyword async ke blok dan fungsi buat menentukan kalau mereka bisa diinterupsi dan dilanjutkan lagi. Di dalam sebuah blok asinkron atau fungsi asinkron, kita bisa memakai keyword await buat menunggu sebuah future (yaitu, nunggu dia sampai jadi siap). Titik mana pun di mana kita me-await sebuah future di dalam sebuah blok atau fungsi asinkron adalah titik potensial buat blok atau fungsi asinkron itu buat berhenti sejenak (pause) dan dilanjutkan lagi (resume). Proses pengecekan ke sebuah future buat melihat apakah nilainya sudah tersedia atau belum ini disebut polling.

Beberapa bahasa pemrograman lain, kayak C# dan JavaScript, juga memakai keyword async dan await buat pemrograman asinkron. Kalau kita sudah familier sama bahasa-bahasa itu, kita mungkin bakal sadar ada beberapa perbedaan signifikan dari cara Rust melakukan hal tersebut, termasuk cara nanganin sintaksnya. Itu ada alasan bagusnya lho, kayak yang bakal kita lihat nanti!

Pas lagi nulis Rust asinkron, kita bakal memakai keyword async dan await sebagian besar waktunya. Rust mengompilasi mereka jadi kode yang ekuivalen menggunakan trait Future, mirip kayak gimana dia mengompilasi for loops jadi kode yang ekuivalen memakai trait Iterator. Tapi karena Rust menyediakan trait Future, kita juga bisa mengimplementasikannya buat tipe data kita sendiri kalau kita butuh. Banyak fungsi yang bakal kita lihat di sepanjang bab ini mengembalikan tipe yang punya implementasi Future-nya masing-masing. Kita bakal balik lagi ke definisi trait-nya di akhir bab ini dan gali lebih dalam soal gimana cara kerjanya, tapi detail segini sudah cukup buat kita lanjut jalan dulu.

Ini semua mungkin terasa agak abstrak, jadi mari kita tulis program asinkron pertama kita: sebuah web scraper mini. Kita bakal memasukkan dua URL dari command line, mengambil (fetch) kedua URL itu secara konkuren, terus mengembalikan hasil dari URL mana pun yang selesai duluan. Contoh ini bakal punya lumayan banyak sintaks baru, tapi jangan khawatir—kita bakal jelasin semua yang perlu kita tahu seiring kita berjalan.

Program Asinkron Pertama Kita

Biar fokus bab ini tetap pada belajar asinkron ketimbang sibuk ngerjain bagian- bagian ekosistem, kita sudah ngebikin crate trpl (trpl itu singkatan dari “The Rust Programming Language”). Crate ini mengekspor ulang (re-exports) semua tipe, trait, dan fungsi yang bakal kita butuhkan, utamanya dari crate futures dan tokio. Crate futures adalah tempat resmi buat bereksperimen dengan kode asinkron di Rust, dan sebenarnya di sanalah trait Future asal-muasalnya didesain. Tokio adalah async runtime yang paling banyak dipakai di Rust saat ini, apalagi buat aplikasi web. Ada banyak runtimes keren lainnya di luar sana, dan mereka mungkin lebih cocok buat kebutuhan kita. Kita memakai crate tokio di balik layarnya trpl karena dia sudah teruji dengan baik dan banyak dipakai.

Di beberapa kasus, trpl juga mengganti nama atau membungkus (wraps) API aslinya biar kita tetap fokus sama detail-detail yang relevan buat bab ini. Kalau kita mau paham apa yang dilakukan sama crate ini, kita menyarankan kita buat cek source code-nya. Kita bakal bisa lihat dari crate mana asal dari tiap fitur yang di-re-export, dan kita sudah ninggalin komentar yang ekstensif yang menjelaskan apa aja yang dilakukan sama crate tersebut.

Bikin sebuah project binary baru bernama hello-async dan tambahkan crate trpl sebagai dependensi:

$ cargo new hello-async
$ cd hello-async
$ cargo add trpl

Sekarang kita bisa pakai berbagai potongan yang disediakan sama trpl buat nulis program asinkron pertama kita. Kita bakal bikin alat command line mini yang mengambil dua halaman web, menarik elemen <title> dari masing-masing halaman, terus mencetak judul dari halaman mana pun yang menyelesaikan seluruh proses tersebut paling duluan.

Mendefinisikan Fungsi page_title

Mari mulai dengan nulis fungsi yang menerima satu URL halaman sebagai parameternya, melakukan request ke sana, dan mengembalikan teks dari elemen judulnya (lihat Listing 17-1).

Filename: src/main.rs
extern crate trpl; // required for mdbook test

fn main() {
    // TODO: we'll add this next!
}

use trpl::Html;

async fn page_title(url: &str) -> Option<String> {
    let response = trpl::get(url).await;
    let response_text = response.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html())
}
Listing 17-1: Mendefinisikan fungsi asinkron buat dapat elemen judul dari halaman HTML

Pertama, kita mendefinisikan sebuah fungsi bernama page_title dan menandainya pakai keyword async. Terus kita pakai fungsi trpl::get buat mengambil URL apa pun yang dimasukkan dan menambahkan keyword await buat menunggu responsnya. Buat dapat teks dari respons tersebut, kita memanggil method text- nya, dan sekali lagi menunggunya dengan keyword await. Kedua langkah ini sifatnya asinkron. Buat fungsi get, kita harus nunggu server buat mengirim balik bagian pertama dari responsnya, yang mana bakal berisi HTTP headers, cookies, dan lain-lain, dan bisa saja dikirim secara terpisah dari body (isi utama) responsnya. Apalagi kalau body-nya itu besar sekali, itu bisa memakan waktu yang lumayan lama buat semuanya sampai. Karena kita harus nunggu buat keseluruhan responsnya sampai, method text itu juga asinkron.

Kita harus secara eksplisit menunggu kedua futures ini, karena futures di Rust itu lazy (malas): mereka tidak bakal melakukan apa-apa sampai kita menyuruh mereka dengan keyword await. (Faktanya, Rust bakal mengeluarkan peringatan compiler kalau kita tidak menunggu sebuah future.) Ini mungkin mengingatkan kita soal pembahasan iterator di Bab 13 di bagian “Memproses Serangkaian Item dengan Iterator”. Iterator tidak bakal melakukan apa-apa kecuali kalau kita memanggil method next-nya—baik itu secara langsung atau dengan memakai for loops atau method-method kayak map yang memakai next di balik layarnya. Demikian juga, futures tidak bakal melakukan apa-apa kecuali kita menyuruh mereka secara eksplisit. Sifat malas ini membolehkan Rust menghindari menjalankan kode asinkron sampai dia bener-bener dibutuhkan.

Catatan: Ini berbeda dari perilaku yang kita lihat di Bab 16 saat memakai thread::spawn di “Membikin Thread Baru dengan spawn”<!–

ignore –>, di mana closure yang kita kasih ke thread lain itu langsung mulai berjalan. Ini juga beda dari cara banyak bahasa lain melakukan pendekatan ke asinkron. Tapi penting buat Rust buat bisa ngasih jaminan performanya, sama halnya dengan iterator.

Begitu kita dapet response_text, kita bisa menguraikan (parse) nilainya jadi sebuah instance dari tipe Html menggunakan Html::parse. Daripada pakai string mentah, sekarang kita punya sebuah tipe data yang bisa kita pakai buat mengolah HTML tersebut sebagai struktur data yang lebih kaya. Secara spesifik, kita bisa pakai method select_first buat menemukan instance pertama dari CSS selector yang kita kasih. Dengan memasukkan string "title", kita bakal dapet elemen <title> pertama di dokumen tersebut, kalau memang ada. Karena mungkin saja nggak ada elemen yang cocok, select_first mengembalikan Option<ElementRef>. Terakhir, kita memakai method Option::map, yang membolehkan kita beroperasi pada item di dalam Option kalau itemnya ada, dan tidak melakukan apa-apa kalau itemnya nggak ada. (Kita juga bisa saja pakai ekspresi match di sini, tapi map itu lebih idiomatik.) Di dalam isi fungsi yang kita berikan ke map, kita memanggil inner_html pada title buat mendapatkan kontennya, yang mana adalah sebuah String. Pada akhirnya, kita punya sebuah Option<String>.

Perhatikan bahwa keyword await di Rust ditaruh setelah ekspresi yang lagi kita tungguin, bukan di sebelumnya. Yakni, ia adalah sebuah keyword postfix (akhiran). Ini mungkin beda dari apa yang biasa kita jumpai kalau kita pernah memakai async di bahasa lain, tapi di Rust ini bikin chaining (rentetan) pemanggilan method jadi jauh lebih enak buat dibuat. Hasilnya, kita bisa mengubah isi dari page_title buat menyambung (chain) pemanggilan fungsi trpl::get dan text sekaligus pakai await di antara mereka, kayak yang ditunjukkan di Listing 17-2.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::Html;

fn main() {
    // TODO: we'll add this next!
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html())
}
Listing 17-2: Chaining (menyambung) dengan keyword await

Selesai deh, kita sudah berhasil menulis fungsi asinkron pertama kita! Sebelum kita nambahin beberapa kode di main buat manggil dia, mari kita ngomongin lebih lanjut soal apa yang sudah kita tulis ini dan apa maknanya.

Pas Rust melihat ada blok yang ditandai pakai keyword async, dia mengompilasinya jadi tipe data anonim unik yang mengimplementasikan trait Future. Pas Rust melihat fungsi ditandai pakai async, dia mengompilasinya jadi fungsi non-asinkron yang isinya merupakan sebuah blok asinkron. Tipe kembalian fungsi asinkron adalah tipe dari tipe data anonim yang dibuat sama compiler buat blok asinkron tersebut.

Jadi, menulis async fn itu ekuivalen (sama aja) kayak menulis fungsi yang mengembalikan sebuah future dari tipe kembaliannya. Bagi compiler, definisi fungsi kayak async fn page_title di Listing 17-1 itu kira-kira ekuivalen sama fungsi non-asinkron yang didefinisikan kayak gini:

#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test
use std::future::Future;
use trpl::Html;

fn page_title(url: &str) -> impl Future<Output = Option<String>> {
    async move {
        let text = trpl::get(url).await.text().await;
        Html::parse(&text)
            .select_first("title")
            .map(|title| title.inner_html())
    }
}
}

Mari kita telusuri bagian demi bagian dari versi yang sudah diubah ini:

  • Dia memakai sintaks impl Trait yang sudah kita bahas dulu di Bab 10 di bagian “Traits sebagai Parameter”.
  • Nilai yang dikembalikan mengimplementasikan trait Future dengan associated type Output. Perhatikan bahwa tipe Output-nya adalah Option<String>, yang mana sama dengan tipe kembalian asli dari versi async fn si page_title.
  • Semua kode yang dipanggil di dalam isi dari fungsi aslinya dibungkus di dalam sebuah blok async move. Ingat kembali kalau blok itu adalah ekspresi. Keseluruhan blok ini adalah ekspresi yang dikembalikan dari fungsinya.
  • Blok asinkron ini menghasilkan nilai bertipe Option<String>, seperti yang baru saja dijelaskan. Nilai tersebut cocok sama tipe Output di tipe kembaliannya. Ini sama persis kayak blok-blok lain yang pernah kita lihat.
  • Isi fungsi baru tersebut adalah sebuah blok async move gara-gara gimana dia memakai parameter url. (Kita bakal ngebahas lebih banyak lagi soal async versus async move nanti di bab ini.)

Sekarang kita bisa memanggil page_title di main.

Mengeksekusi Fungsi Asinkron dengan sebuah Runtime

Sebagai awalan, kita cuma bakal mengambil judul buat satu halaman saja, yang ditunjukkan di Listing 17-3. Sayangnya, kode ini belum bisa di-compile.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::Html;

async fn main() {
    let args: Vec<String> = std::env::args().collect();
    let url = &args[1];
    match page_title(url).await {
        Some(title) => println!("The title for {url} was {title}"),
        None => println!("{url} had no title"),
    }
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html())
}
Listing 17-3: Memanggil fungsi page_title dari main memakai argumen yang dikasih sama user

Kita mengikuti pola yang sama yang kita pakai buat dapat argumen command line di Bab 12 di bagian “Menerima Argumen Command Line”. Terus kita mengoper URL pertamanya ke page_title dan menunggu hasilnya. Karena nilai yang dihasilkan oleh future tersebut adalah sebuah Option<String>, kita memakai ekspresi match buat mencetak pesan yang beda- beda dengan memperhitungkan apakah halamannya punya <title> atau tidak.

Satu-satunya tempat di mana kita bisa memakai keyword await adalah di dalam fungsi atau blok asinkron, dan Rust tidak bakal membolehkan kita menandai fungsi spesial main sebagai async.

error[E0752]: `main` function is not allowed to be `async`
 --> src/main.rs:6:1
  |
6 | async fn main() {
  | ^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`

Alasan main tidak bisa ditandai async adalah karena kode asinkron itu butuh sebuah runtime: sebuah crate Rust yang mengelola detail eksekusi kode asinkron. Fungsi main di sebuah program bisa menginisialisasi (initialize) sebuah runtime, tapi fungsi main itu bukanlah sebuah runtime itu sendiri. (Kita bakal lihat lebih lanjut soal kenapa ini terjadi sebentar lagi.) Setiap program Rust yang mengeksekusi kode asinkron punya minimal satu tempat di mana dia menyiapkan sebuah runtime yang mengeksekusi futures-nya.

Kebanyakan bahasa yang mendukung asinkron sudah membundel sebuah runtime bawaan, tapi Rust tidak begitu. Sebaliknya, ada banyak async runtimes berbeda yang tersedia, di mana masing-masing membikin tradeoffs (pertukaran) yang cocok buat kasus penggunaan yang jadi targetnya. Misalnya, web server yang high-throughput (kemampuan transmisi besar) dengan banyak core CPU dan RAM dalam jumlah besar punya kebutuhan yang sangat berbeda dari mikrokontroler dengan single core, jumlah RAM yang kecil, dan tidak punya kemampuan alokasi heap sama sekali. Crate yang menyediakan runtimes ini juga sering kali memberikan versi asinkron dari fungsionalitas umum kayak I/O file atau jaringan.

Di sini, dan di sepanjang sisa bab ini, kita bakal memakai fungsi block_on dari crate trpl, yang mana menerima sebuah future sebagai argumen dan memblokir thread saat ini sampai future tersebut berjalan hingga selesai. Di balik layar, memanggil block_on menyiapkan sebuah runtime menggunakan crate tokio yang dipakai buat menjalankan future yang diberikan (perilaku block_on dari crate trpl ini mirip dengan fungsi block_on milik crate runtime lainnya). Setelah future tersebut selesai, block_on bakal mengembalikan nilai apa pun yang dihasilkan oleh future itu.

Kita bisa saja meneruskan future yang dikembalikan sama page_title langsung ke block_on dan, begitu selesai, kita bisa melakukan match pada Option<String> hasilnya, kayak yang sudah kita coba lakukan di Listing 17-3. Tapi, buat mayoritas contoh di bab ini (dan mayoritas kode asinkron di dunia nyata), kita bakal melakukan lebih dari sekadar satu pemanggilan fungsi asinkron saja, jadi alih-alih begitu kita bakal meneruskan sebuah blok async lalu secara eksplisit menunggu hasil dari panggilan page_title, seperti di Listing 17-4.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::Html;

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::block_on(async {
        let url = &args[1];
        match page_title(url).await {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
    })
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html())
}
Listing 17-4: Menunggu sebuah blok asinkron dengan trpl::block_on

Pas kita jalankan kode ini, kita dapet perilaku kayak yang kita harapkan di awal:

$ cargo run -- "https://www.rust-lang.org"
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.05s
     Running `target/debug/async_await 'https://www.rust-lang.org'`
The title for https://www.rust-lang.org was
            Rust Programming Language

Fiuh—akhirnya kita punya kode asinkron yang jalan! Tapi sebelum kita nambahin kode buat mengadu (race) kedua situs web itu satu sama lain, mari kita alihkan sejenak perhatian kita kembali ke gimana futures itu bekerja.

Setiap await point (titik penantian)—yakni, setiap tempat di mana kodenya memakai keyword await—merepresentasikan sebuah tempat di mana kontrol dikembalikan ke runtime. Biar itu bisa terjadi, Rust perlu melacak (keep track of) state (keadaan) yang terlibat di dalam blok asinkron tersebut sehingga runtime bisa memulai beberapa pekerjaan lain lalu balik lagi nanti kalau dia sudah siap buat mencoba melanjutkan pekerjaan pertama tadi. Ini adalah sebuah state machine (mesin keadaan) kasatmata, seolah-olah kita menulis sebuah enum kayak gini buat menyimpan state saat ini di tiap titik await:

#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test

enum PageTitleFuture<'a> {
    Initial { url: &'a str },
    GetAwaitPoint { url: &'a str },
    TextAwaitPoint { response: trpl::Response },
}
}

Menulis kode buat bertransisi di antara setiap state ini secara manual bakal melelahkan dan gampang rawan error, apalagi kalau nanti kita harus menambahkan fungsionalitas dan lebih banyak states lagi ke kode tersebut. Untungnya, compiler Rust otomatis membikin dan mengelola struktur data state machine buat kode asinkron. Aturan-aturan borrowing dan ownership normal seputar struktur data itu tetap berlaku semua, dan syukurnya, compiler juga menangani pengecekan itu buat kita dan menyediakan pesan error yang berguna. Kita bakal membedah beberapa kasus kayak gitu nanti di bab ini.

Pada akhirnya, sesuatu harus mengeksekusi state machine ini, dan “sesuatu” itu adalah sebuah runtime. (Inilah kenapa kita mungkin pernah ketemu istilah executors (pengeksekusi) pas lagi nyari tahu soal runtimes: sebuah executor adalah bagian dari sebuah runtime yang bertugas mengeksekusi kode asinkron tersebut.)

Sekarang kita bisa tahu kenapa compiler melarang kita membikin main itu sendiri jadi fungsi asinkron balik di Listing 17-3 tadi. Kalau main itu fungsi asinkron, sesuatu yang lain bakal harus mengelola state machine buat future apa pun yang dikembalikan sama main, tapi padahal main adalah titik awal buat programnya! Sebaliknya, kita memanggil fungsi trpl::block_on di main buat menyiapkan sebuah runtime dan menjalankan future yang dikembalikan sama blok async sampai dia selesai.

Catatan: Beberapa runtimes menyediakan macros sehingga kita bisa menulis fungsi main yang asinkron. Macros itu menulis ulang async fn main() { ... } jadi fn main normal, yang melakukan persis hal yang sama kayak yang kita lakukan secara manual di Listing 17-4: memanggil fungsi yang mengeksekusi sebuah future sampai selesai kayak yang dilakukan sama trpl::block_on.

Sekarang mari kita gabungkan bagian-bagian ini dan lihat gimana kita bisa menulis kode konkuren.

Menandingkan (Racing) Dua URL Secara Konkuren

Di Listing 17-5, kita memanggil page_title dengan dua URL berbeda yang dimasukkan dari command line lalu mengadu (race) mereka berdua dengan memilih future mana pun yang selesai duluan.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::{Either, Html};

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::block_on(async {
        let title_fut_1 = page_title(&args[1]);
        let title_fut_2 = page_title(&args[2]);

        let (url, maybe_title) =
            match trpl::select(title_fut_1, title_fut_2).await {
                Either::Left(left) => left,
                Either::Right(right) => right,
            };

        println!("{url} returned first");
        match maybe_title {
            Some(title) => println!("Its page title was: '{title}'"),
            None => println!("It had no title."),
        }
    })
}

async fn page_title(url: &str) -> (&str, Option<String>) {
    let response_text = trpl::get(url).await.text().await;
    let title = Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html());
    (url, title)
}
Listing 17-5: Memanggil page_title buat dua URL untuk melihat mana yang kembali duluan

Kita mulai dengan memanggil page_title buat tiap URL yang dikasih sama user. Kita simpan futures hasilnya sebagai title_fut_1 dan title_fut_2. Ingat, mereka ini belum melakukan apa-apa, karena futures itu sifatnya malas dan kita belum menunggunya. Terus kita mengoper futures tersebut ke trpl::select, yang mengembalikan sebuah nilai buat mengindikasikan future mana yang selesai duluan di antara yang dioper kepadanya.

Catatan: Di balik layar, trpl::select dibangun di atas fungsi select yang lebih umum yang didefinisikan di crate futures. Fungsi select milik crate futures bisa melakukan banyak hal yang fungsi trpl::select tidak bisa, tapi dia juga punya kerumitan ekstra yang bisa kita lewati dulu buat sekarang.

Masing-masing future bisa saja “menang,” jadi tidak masuk akal kalau kita mengembalikan Result. Alih-alih begitu, trpl::select mengembalikan sebuah tipe yang belum pernah kita lihat sebelumnya, yaitu trpl::Either. Tipe Either itu agak mirip sama Result dalam hal dia punya dua kasus. Bedanya sama Result, tidak ada konsep “sukses” atau “gagal” yang tertanam di dalam Either. Alih-alih begitu, dia memakai Left (kiri) dan Right (kanan) buat mengindikasikan “yang satu atau yang lainnya”:

#![allow(unused)]
fn main() {
enum Either<A, B> {
    Left(A),
    Right(B),
}
}

Fungsi select mengembalikan Left yang berisi output dari future tersebut kalau argumen pertama menang, dan Right yang berisi output future kedua kalau yang itu yang menang. Ini cocok dengan urutan munculnya argumen-argumen tersebut saat memanggil fungsinya: argumen pertama ada di kiri argumen kedua.

Kita juga memperbarui page_title buat mengembalikan URL yang sama dengan yang dimasukkan. Dengan begitu, kalau halaman yang kembali duluan tidak punya <title> yang bisa kita uraikan, kita masih bisa mencetak pesan yang bermakna. Dengan informasi yang sudah tersedia itu, kita selesaikan ini semua dengan mengubah output println! kita buat mengindikasikan baik URL mana yang selesai duluan, dan apa, kalau memang ada, <title> buat halaman web di URL tersebut.

Kita sudah ngebikin web scraper mini yang bisa jalan sekarang! Silakan pilih beberapa URL lalu jalankan alat command line kita. Kita mungkin mendapati kalau beberapa situs secara konsisten memang lebih kencang dibanding yang lain, sementara di kasus lain situs yang kencang itu berubah-ubah di tiap jalan. Yang lebih penting, kita sudah belajar dasar-dasar dari bekerja dengan futures, jadi sekarang kita bisa gali lebih dalam soal apa yang bisa kita lakukan dengan asinkron.

Menerapkan Konkurensi dengan Async

Menerapkan Konkurensi dengan Async

Di bagian ini, kita bakal menerapkan asinkron ke beberapa tantangan konkurensi yang sama dengan yang sudah kita tangani memakai threads di Bab 16. Karena kita sudah banyak ngomongin ide-ide kuncinya di sana, di bagian ini kita bakal fokus sama apa aja yang berbeda antara threads dan futures.

Di banyak kasus, API buat bekerja bareng konkurensi memakai asinkron itu mirip sekali sama yang dipakai buat threads. Di kasus lain, mereka jadinya lumayan berbeda. Bahkan kalau API-nya kelihatan mirip antara threads dan asinkron, mereka sering kali punya perilaku yang berbeda—dan mereka hampir selalu punya karakteristik performa yang berbeda.

Membikin Task (Tugas) Baru dengan spawn_task

Operasi pertama yang kita tangani di bagian “Membikin Thread Baru dengan spawn di Bab 16 adalah menghitung angka di dua threads terpisah. Mari kita lakukan hal yang sama memakai asinkron. Crate trpl menyediakan fungsi spawn_task yang kelihatannya mirip sekali sama API thread::spawn, dan fungsi sleep yang merupakan versi asinkron dari API thread::sleep. Kita bisa memakai keduanya bersama-sama buat mengimplementasikan contoh penghitungan angka tersebut, kayak yang ditunjukkan di Listing 17-6.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        trpl::spawn_task(async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        });

        for i in 1..5 {
            println!("hi number {i} from the second task!");
            trpl::sleep(Duration::from_millis(500)).await;
        }
    });
}
Listing 17-6: Membikin task baru buat mencetak satu hal sementara task utama mencetak hal lainnya

Sebagai titik awal, kita menyiapkan fungsi main kita pakai trpl::block_on supaya fungsi tingkat teratas kita bisa bersifat asinkron.

Catatan: Mulai dari titik ini di bab ini, setiap contoh bakal menyertakan kode pembungkus yang sama persis pakai trpl::block_on di main, jadi kita bakal sering mengabaikannya (skip) sama seperti kita mengabaikan main. Jangan lupa buat menyertakannya di dalam kode kita ya!

Terus kita menulis dua loops di dalam blok tersebut, masing-masing mengandung pemanggilan trpl::sleep, yang bakal nunggu selama setengah detik (500 milidetik) sebelum mengirim pesan berikutnya. Kita menaruh satu loop di dalam isi dari trpl::spawn_task dan satu lagi di dalam loop for tingkat teratas. Kita juga nambahin await setelah pemanggilan sleep.

Kode ini berperilaku mirip sama implementasi berbasis thread—termasuk fakta bahwa kita mungkin bakal melihat pesan-pesannya muncul dalam urutan yang berbeda di terminal kita pas kita menjalankannya:

hi number 1 from the second task!
hi number 1 from the first task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!

Versi ini bakal berhenti begitu loop for di dalam isi blok asinkron utamanya selesai, karena task yang ditelurkan sama spawn_task bakal dimatikan pas fungsi main berakhir. Kalau kita pengen task tersebut jalan sampai kelar, kita perlu memakai join handle buat nungguin task pertamanya selesai. Pas pakai threads, kita memakai method join buat “memblokir” sampai thread-nya selesai jalan. Di Listing 17-7, kita bisa memakai await buat ngelakuin hal yang sama, karena task handle itu sendiri adalah sebuah future. Tipe Output-nya adalah sebuah Result, jadi kita juga meng-unwrap-nya setelah me-await-nya.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let handle = trpl::spawn_task(async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        });

        for i in 1..5 {
            println!("hi number {i} from the second task!");
            trpl::sleep(Duration::from_millis(500)).await;
        }

        handle.await.unwrap();
    });
}
Listing 17-7: Memakai await bersama join handle buat menjalankan task sampai selesai

Versi yang sudah di-update ini bakal jalan sampai kedua loops selesai:

hi number 1 from the second task!
hi number 1 from the first task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!
hi number 6 from the first task!
hi number 7 from the first task!
hi number 8 from the first task!
hi number 9 from the first task!

Sejauh ini, kelihatannya asinkron dan threads ngasih kita hasil yang mirip, cuma beda di sintaksnya aja: memakai await ketimbang memanggil join pada join handle, dan me-await pemanggilan sleep.

Perbedaan besarnya adalah kita tidak perlu menelurkan (spawn) thread sistem operasi lainnya buat melakukan hal ini. Faktanya, kita bahkan tidak perlu menelurkan sebuah task di sini. Karena blok asinkron dikompilasi jadi futures anonim, kita bisa menaruh tiap loop di dalam blok asinkron lalu menyuruh runtime menjalankan keduanya sampai selesai memakai fungsi trpl::join.

Di bagian “Menunggu Semua Thread sampai Selesai” di Bab 16, kita menunjukkan gimana cara memakai method join pada tipe JoinHandle yang dikembalikan pas kita memanggil std::thread::spawn. Fungsi trpl::join itu mirip, tapi buat futures. Pas kita ngasih dia dua futures, dia bakal memproduksi satu future baru yang output-nya adalah sebuah tuple berisi output dari tiap future yang kita masukkan tadi begitu keduanya selesai. Jadi, di Listing 17-8, kita memakai trpl::join buat nungguin baik fut1 maupun fut2 sampai selesai. Kita tidak me-await fut1 dan fut2 melainkan me-await future baru yang dihasilkan sama trpl::join. Kita ngabaiin output-nya, karena dia cuma sebuah tuple berisi dua nilai unit.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let fut1 = async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let fut2 = async {
            for i in 1..5 {
                println!("hi number {i} from the second task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        trpl::join(fut1, fut2).await;
    });
}
Listing 17-8: Memakai trpl::join buat menunggu dua futures anonim

Pas kita jalankan ini, kita melihat kedua futures jalan sampai selesai:

hi number 1 from the first task!
hi number 1 from the second task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!
hi number 6 from the first task!
hi number 7 from the first task!
hi number 8 from the first task!
hi number 9 from the first task!

Nah, kita bakal melihat urutan yang sama persis di tiap jalannya, yang mana berbeda sekali sama apa yang kita lihat di threads dan di trpl::spawn_task di Listing 17-7. Itu gara-gara fungsi trpl::join ini sifatnya adil (fair), yang artinya dia mengecek tiap future dengan frekuensi yang sama, ganti- gantian di antara mereka, dan tidak pernah membiarkan satu pun balapan (race) mendahului yang lain kalau yang lainnya juga sudah siap. Kalau pakai threads, sistem operasilah yang menentukan thread mana yang mau dicek dan seberapa lama dia dibiarkan jalan. Kalau di Rust asinkron, runtime-lah yang menentukan task mana yang mau dicek. (Di praktiknya, detail-detailnya jadi rumit karena sebuah async runtime mungkin memakai threads sistem operasi di balik layar sebagai bagian dari caranya mengelola konkurensi, jadi menjamin keadilan bisa jadi kerjaan ekstra buat sebuah runtime—tapi tetap mungkin kok!) Runtimes tidak diwajibkan buat menjamin keadilan buat sembarang operasi, dan mereka sering kali menawarkan API yang berbeda-beda biar kita bisa milih mau yang adil atau nggak.

Cobain deh beberapa variasi me-await futures ini dan lihat apa yang mereka lakukan:

  • Hapus blok asinkron dari salah satu atau kedua loops tersebut.
  • Await tiap blok asinkron seketika setelah mendefinisikannya.
  • Bungkus cuma loop pertama saja di dalam blok asinkron, dan await future hasilnya setelah isi dari loop kedua.

Buat tantangan ekstra, coba deh tebak bakal kayak apa output-nya di tiap kasus sebelum kita menjalankan kodenya!

Mengirim Data di Antara Dua Task Memakai Message Passing

Berbagi data antar futures juga bakal terasa familier: kita bakal memakai message passing lagi, tapi kali ini pakai versi asinkron dari tipe-tipe dan fungsi-fungsinya. Kita bakal ngambil jalan yang agak beda dibanding waktu di bagian “Transfer Data antar Threads Memakai Message Passing” di Bab 16 buat mengilustrasikan beberapa perbedaan kunci antara konkurensi berbasis thread dan berbasis futures. Di Listing 17-9, kita bakal mulai dengan cuma satu blok asinkron saja—tidak menelurkan sebuah task terpisah sebagaimana dulu kita menelurkan sebuah thread terpisah.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

fn main() {
    trpl::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let val = String::from("hi");
        tx.send(val).unwrap();

        let received = rx.recv().await.unwrap();
        println!("received '{received}'");
    });
}
Listing 17-9: Membikin sebuah async channel dan memberikan kedua bagiannya ke tx dan rx

Di sini, kita memakai trpl::channel, sebuah versi asinkron dari API multiple- producer, single-consumer channel yang kita pakai bareng threads dulu di Bab 16. Versi asinkron dari API-nya cuma beda sedikit dari versi berbasis thread: ia memakai receiver rx yang bersifat mutable bukannya immutable, dan method recv-nya menghasilkan sebuah future yang perlu kita await bukannya menghasilkan nilainya secara langsung. Sekarang kita bisa mengirim pesan dari sisi pengirim ke sisi penerima. Perhatikan bahwa kita tidak harus menelurkan sebuah thread terpisah atau bahkan sebuah task; kita cuma butuh me-await pemanggilan rx.recv.

Method Receiver::recv yang sinkron di std::mpsc::channel memblokir sampai ia menerima pesan. Method trpl::Receiver::recv tidak memblokir, karena ia sifatnya asinkron. Alih-alih memblokir, ia menyerahkan kontrol kembali ke runtime sampai entah ada pesan yang diterima atau sisi pengirim (send side) dari channel tersebut ditutup. Sebaliknya, kita tidak me-await pemanggilan send, karena ia tidak memblokir. Dia tidak perlu memblokir karena channel yang kita pakai buat mengirim ini sifatnya unbounded (tidak dibatasi).

Catatan: Karena semua kode asinkron ini jalan di dalam blok asinkron di dalam pemanggilan trpl::block_on, semua hal di dalamnya bisa menghindari memblokir. Namun, kode di luar blok tersebut bakal memblokir pada saat fungsi block_on dikembalikan. Itulah poin utama dari fungsi trpl::block_on: ia membiarkan kita memilih di mana harus memblokir pada sekumpulan kode inkron, dan dengan begitu bisa bertransisi antara kode sinkron dan asinkron.

Perhatikan dua hal soal contoh ini. Pertama, pesannya bakal tiba seketika itu juga. Kedua, meskipun kita memakai sebuah future di sini, belum ada konkurensi sama sekali. Semua hal di dalam listing tersebut terjadi secara berurutan (sequence), persis kayak yang bakal terjadi kalau seandainya tidak ada futures yang dilibatkan.

Mari kita beresin bagian pertamanya dengan mengirim serangkaian pesan dan tidur di antara mereka, kayak yang ditunjukkan di Listing 17-10.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("future"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            trpl::sleep(Duration::from_millis(500)).await;
        }

        while let Some(value) = rx.recv().await {
            println!("received '{value}'");
        }
    });
}
Listing 17-10: Mengirim dan menerima banyak pesan melewati async channel dan tidur dengan sebuah await di antara tiap pesan

Selain mengirim pesannya, kita juga perlu menerimanya. Di kasus ini, karena kita tahu ada berapa banyak pesan yang masuk, kita bisa melakukan itu secara manual dengan memanggil rx.recv().await sebanyak empat kali. Tapi di dunia nyata, kita umumnya bakal nungguin pesan yang jumlahnya tidak diketahui, jadi kita butuh terus menunggu sampai kita yakin sudah tidak ada pesan lagi.

Di Listing 16-10, kita memakai loop for buat memproses semua item yang diterima dari sebuah channel yang sinkron. Namun, Rust belum punya cara buat memakai loop for dengan serangkaian item yang diproduksi secara asinkron, jadi kita perlu memakai jenis loop yang belum pernah kita lihat sebelumnya: yaitu perulangan bersyarat while let. Ini adalah versi loop dari konstruk if let yang sudah kita lihat di Bab 6 di bagian “Control Flow Singkat Memakai if let dan let...else. Loop ini bakal terus dijalankan selama pattern yang ditentukannya terus cocok sama nilainya.

Pemanggilan rx.recv menghasilkan sebuah future, yang kemudian kita await. Runtime bakal me-pause future tersebut sampai dia siap. Begitu sebuah pesan tiba, future tersebut bakal selesai (resolve) jadi Some(message) sebanyak jumlah pesan yang tiba. Saat channel-nya ditutup, terlepas dari apakah ada pesan yang tiba atau nggak, future tersebut bakal selesai jadi None buat mengindikasikan kalau sudah tidak ada lagi nilainya dan oleh karena itu kita harus berhenti melakukan polling—yakni, berhenti me-await.

Loop while let menggabungkan ini semua. Kalau hasil dari pemanggilan rx.recv().await adalah Some(message), kita dapet akses ke pesannya dan kita bisa memakainya di dalam isi loop, persis kayak pas pakai if let. Kalau hasilnya None, loop-nya berakhir. Setiap kali loop-nya selesai, dia kena await point lagi, jadi runtime me-pause-nya lagi sampai ada pesan lain yang tiba.

Kodenya sekarang berhasil mengirim dan menerima semua pesan tersebut. Sayangnya, masih ada beberapa masalah. Salah satunya, pesan-pesannya tidak tiba dalam interval setengah detik. Mereka tiba semuanya sekaligus, 2 detik (2.000 milidetik) setelah kita menyalakan programnya. Terus, program ini juga tidak pernah berakhir! Sebaliknya, ia menunggu selamanya buat pesan baru. Kita bakal perlu mematikannya memakai ctrl-C.

Kode di Dalam Satu Blok Asinkron Dieksekusi secara Linear

Mari kita mulai dengan menyelidiki kenapa pesan-pesannya datang sekaligus setelah penundaan penuh (full delay), bukannya datang dengan jeda di tiap pesannya. Di dalam sebuah blok asinkron tertentu, urutan munculnya keyword await di dalam kode juga merupakan urutan saat mereka dieksekusi pas programnya jalan.

Cuma ada satu blok asinkron di Listing 17-10, jadi semua hal di dalamnya jalan secara linear. Masih belum ada konkurensi. Semua pemanggilan tx.send terjadi, diselingi sama semua pemanggilan trpl::sleep dan await points terkaitnya. Baru setelah itu loop while let dapat giliran buat melewati titik await apa pun pada pemanggilan recv.

Buat mendapatkan perilaku yang kita mau, di mana jeda tidurnya (sleep delay) terjadi di antara tiap pesan, kita perlu menaruh operasi tx dan rx di dalam blok asinkronnya masing-masing, kayak yang ditunjukkan di Listing 17-11. Terus si runtime bisa mengeksekusi masing-masing blok secara terpisah memakai trpl::join, persis kayak di Listing 17-8. Sekali lagi, kita me-await hasil pemanggilan trpl::join, bukannya me-await futures individunya. Kalau kita me-await futures individunya secara berurutan, kita ujung-ujungnya cuma bakal balik lagi ke alur sekuensial—persis kayak apa yang mau kita hindari.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let tx_fut = async {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        trpl::join(tx_fut, rx_fut).await;
    });
}
Listing 17-11: Memisahkan send dan recv ke dalam blok asinkronnya masing-masing dan menunggu futures dari blok-blok tersebut

Dengan kode yang sudah di-update di Listing 17-11, pesan-pesannya dicetak dalam interval 500 milidetik, bukannya buru-buru sekaligus setelah 2 detik.

Memindahkan Kepemilikan (Ownership) ke dalam Blok Asinkron

Meskipun begitu, programnya tetap tidak pernah berakhir, karena cara loop while let berinteraksi sama trpl::join:

  • Future yang dikembalikan dari trpl::join cuma selesai begitu kedua futures yang diberikan ke dia sudah selesai.
  • Future tx_fut selesai begitu dia kelar tidur setelah mengirim pesan terakhir di dalam vals.
  • Future rx_fut tidak bakal selesai sampai loop while let berakhir.
  • Loop while let tidak bakal berakhir sampai me-await rx.recv menghasilkan None.
  • Me-await rx.recv bakal mengembalikan None cuma kalau sisi lain dari channel-nya sudah ditutup.
  • Channel-nya bakal tutup cuma kalau kita memanggil rx.close atau pas sisi pengirimnya, tx, di-drop.
  • Kita tidak memanggil rx.close di mana pun, dan tx tidak bakal di-drop sampai blok asinkron terluar yang diberikan ke trpl::block_on berakhir.
  • Blok tersebut tidak bisa berakhir karena dia terhambat (blocked) menunggu trpl::join selesai, yang mana membawa kita balik lagi ke urutan paling atas dari daftar ini.

Saat ini, blok asinkron tempat kita mengirim pesannya cuma meminjam tx karena mengirim pesan tidak mewajibkan adanya kepemilikan, tapi kalau seandainya kita bisa memindahkan (move) tx ke dalam blok asinkron tersebut, dia bakal di-drop begitu blok itu berakhir. Di Bab 13 di bagian “Menangkap Referensi atau Memindahkan Kepemilikan”, kita sudah mempelajari cara memakai keyword move bersama closures, dan, seperti yang dibahas di bagian “Memakai move Closures bersama Threads” di Bab 16, kita sering kali perlu memindahkan data ke dalam closures saat bekerja dengan threads. Dinamika dasar yang sama ini juga berlaku buat blok asinkron, jadi keyword move juga bekerja di blok asinkron sama seperti di closures.

Di Listing 17-12, kita mengubah blok yang dipakai buat mengirim pesan dari async jadi async move.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let tx_fut = async move {
            // --snip--
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        trpl::join(tx_fut, rx_fut).await;
    });
}
Listing 17-12: Revisi dari kode di Listing 17-11 yang mematikan program secara benar begitu selesai

Pas kita menjalankan versi kode yang ini, dia bakal berhenti secara anggun begitu pesan terakhir sudah dikirim dan diterima. Berikutnya, mari kita lihat apa yang butuh diubah buat mengirim data dari lebih dari satu future.

Menggabungkan (Joining) Sejumlah Futures dengan Macro join!

Async channel ini juga merupakan multiple-producer channel, jadi kita bisa memanggil clone pada tx kalau kita mau mengirim pesan dari banyak futures, kayak yang ditunjukkan di Listing 17-13.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(1500)).await;
            }
        };

        trpl::join!(tx1_fut, tx_fut, rx_fut);
    });
}
Listing 17-13: Memakai multiple producers bersama blok asinkron

Pertama, kita meng-clone tx, membikin tx1 di luar blok asinkron pertama. Kita memindahkan tx1 ke dalam blok tersebut sama kayak yang kita lakukan sebelumnya sama tx. Terus, belakangan, kita memindahkan tx asli ke dalam blok asinkron baru, di mana kita mengirim lebih banyak pesan dengan jeda yang sedikit lebih lambat. Kebetulan kita menaruh blok asinkron baru ini setelah blok asinkron buat menerima pesan, tapi kita juga bisa kok menaruhnya sebelumnya. Kuncinya adalah urutan pas futures-nya di-await, bukan urutan pas mereka dibikin.

Kedua blok asinkron buat mengirim pesannya wajib berupa blok async move supaya baik tx maupun tx1 di-drop pas blok-blok tersebut selesai. Kalau tidak, kita bakal balik lagi ke perulangan tiada henti yang sama kayak di awal tadi.

Terakhir, kita beralih dari trpl::join ke trpl::join! buat menangani future tambahannya: macro join! me-await jumlah futures yang sembarang asalkan kita sudah tahu jumlah futures-nya pas masa kompilasi. Kita bakal ngebahas soal menunggu sekumpulan futures yang jumlahnya tidak diketahui nanti di bab ini.

Sekarang kita bisa melihat semua pesan dari kedua futures pengirimnya, dan karena futures pengirimnya memakai jeda yang sedikit berbeda setelah mengirim, pesan-pesannya juga diterima dalam interval yang berbeda tersebut:

received 'hi'
received 'more'
received 'from'
received 'the'
received 'messages'
received 'future'
received 'for'
received 'you'

Kita sudah mengeksplorasi cara memakai message passing buat mengirim data antar futures, gimana kode di dalam blok asinkron jalan secara sekuensial, gimana cara memindahkan kepemilikan ke dalam blok asinkron, dan gimana cara menggabungkan banyak futures. Berikutnya, mari kita bahas gimana dan kenapa harus kasih tahu runtime kalau dia bisa beralih ke tugas lain.

Bekerja dengan Jumlah Futures yang Sembarang

Menyerahkan Kontrol (Yielding) ke Runtime

Ingat kembali dari bagian “Program Asinkron Pertama Kita” kalau di tiap titik await, Rust ngasih kesempatan ke sebuah runtime buat me-pause task dan beralih ke task lain kalau future yang lagi di-await ternyata belum siap. Kebalikannya juga benar: Rust hanya me-pause blok asinkron dan menyerahkan kontrol kembali ke runtime pada saat titik await. Semua hal di antara titik-titik await itu sifatnya sinkron.

Itu artinya kalau kita melakukan banyak pekerjaan di dalam sebuah blok asinkron tanpa adanya titik await, future tersebut bakal memblokir futures lainnya supaya tidak bisa bikin progress. Kita mungkin kadang-kadang mendengar hal ini disebut sebagai satu future yang me-starving (membuat lapar/menghambat) futures lainnya. Di beberapa kasus, itu mungkin bukan masalah besar. Tapi, kalau kita lagi melakukan semacam setup yang berat atau pekerjaan yang makan waktu lama, atau kalau kita punya sebuah future yang bakal terus-menerus melakukan suatu tugas tertentu selamanya, kita perlu memikirkan kapan dan di mana harus menyerahkan kontrol kembali ke runtime.

Mari kita simulasikan sebuah operasi yang memakan waktu lama buat mengilustrasikan masalah starvation ini, lalu mengeksplorasi gimana cara menyelesaikannya. Listing 17-14 memperkenalkan sebuah fungsi slow.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(async {
        // We will call `slow` here later
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-14: Memakai thread::sleep buat menyimulasikan operasi yang lambat

Kode ini memakai std::thread::sleep bukannya trpl::sleep supaya pemanggilan slow bakal memblokir thread saat ini selama beberapa milidetik. Kita bisa memakai slow sebagai pengganti buat operasi di dunia nyata yang sifatnya makan waktu lama sekaligus memblokir.

Di Listing 17-15, kita memakai slow buat meniru pengerjaan tugas semacam CPU-bound ini di dalam sepasang futures.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(async {
        let a = async {
            println!("'a' started.");
            slow("a", 30);
            slow("a", 10);
            slow("a", 20);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            slow("b", 10);
            slow("b", 15);
            slow("b", 350);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'b' finished.");
        };

        trpl::select(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-15: Memanggil fungsi slow buat menyimulasikan operasi yang lambat

Tiap future menyerahkan kontrol kembali ke runtime cuma setelah melaksanakan serangkaian operasi lambat. Kalau kita menjalankan kode ini, kita bakal melihat output ini:

'a' started.
'a' ran for 30ms
'a' ran for 10ms
'a' ran for 20ms
'b' started.
'b' ran for 75ms
'b' ran for 10ms
'b' ran for 15ms
'b' ran for 350ms
'a' finished.

Sama kayak di Listing 17-5 di mana kita memakai trpl::select buat mengadu futures yang mengambil dua URL, select tetap selesai begitu a beres. Tapi nggak ada proses selang-seling (interleaving) di antara pemanggilan ke slow di kedua futures tersebut. Future a melakukan semua pekerjaannya sampai pemanggilan trpl::sleep di-await, baru setelah itu future b melakukan semua pekerjaannya sampai pemanggilan trpl::sleep-nya sendiri di-await, dan akhirnya future a selesai. Buat membolehkan kedua futures membikin progress di sela-sela tugas lambat mereka, kita butuh titik await supaya kita bisa menyerahkan kontrol kembali ke runtime. Itu artinya kita butuh sesuatu yang bisa kita await!

Kita sudah bisa melihat penyerahan kontrol semacam ini terjadi di Listing 17-15: kalau seandainya kita menghapus trpl::sleep di akhir future a, dia bakal selesai tanpa future b sempat berjalan sama sekali. Mari kita coba memakai fungsi trpl::sleep sebagai titik awal buat membiarkan operasi-operasinya bergantian membikin progress, kayak yang ditunjukkan di Listing 17-16.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(async {
        let one_ms = Duration::from_millis(1);

        let a = async {
            println!("'a' started.");
            slow("a", 30);
            trpl::sleep(one_ms).await;
            slow("a", 10);
            trpl::sleep(one_ms).await;
            slow("a", 20);
            trpl::sleep(one_ms).await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            trpl::sleep(one_ms).await;
            slow("b", 10);
            trpl::sleep(one_ms).await;
            slow("b", 15);
            trpl::sleep(one_ms).await;
            slow("b", 350);
            trpl::sleep(one_ms).await;
            println!("'b' finished.");
        };

        trpl::select(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-16: Memakai trpl::sleep buat membiarkan operasi-operasi bergantian membuat progress

Kita sudah nambahin pemanggilan trpl::sleep dengan titik-titik await di tiap sela pemanggilan ke slow. Sekarang pekerjaan kedua futures tersebut sudah diselang-seling:

'a' started.
'a' ran for 30ms
'b' started.
'b' ran for 75ms
'a' ran for 10ms
'b' ran for 10ms
'a' ran for 20ms
'b' ran for 15ms
'a' finished.

Future a tetap jalan sebentar sebelum menyerahkan kontrol ke b, karena dia memanggil slow sebelum pernah memanggil trpl::sleep, tapi setelah itu futures-nya bertukar peran bolak-balik setiap kali salah satu dari mereka mencapai titik await. Di kasus ini, kita sudah melakukan itu setelah tiap pemanggilan ke slow, tapi kita bisa membagi-bagi pekerjaannya pakai cara apa pun yang paling masuk akal buat kita.

Tapi sebenarnya kita nggak mau bener-bener “tidur” (sleep) di sini: kita mau bikin progress secepat yang kita bisa. Kita cuma perlu menyerahkan kembali kontrol ke runtime. Kita bisa melakukan itu secara langsung, menggunakan fungsi trpl::yield_now. Di Listing 17-17, kita mengganti semua pemanggilan trpl::sleep tadi dengan trpl::yield_now.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(async {
        let a = async {
            println!("'a' started.");
            slow("a", 30);
            trpl::yield_now().await;
            slow("a", 10);
            trpl::yield_now().await;
            slow("a", 20);
            trpl::yield_now().await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            trpl::yield_now().await;
            slow("b", 10);
            trpl::yield_now().await;
            slow("b", 15);
            trpl::yield_now().await;
            slow("b", 350);
            trpl::yield_now().await;
            println!("'b' finished.");
        };

        trpl::select(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-17: Memakai yield_now buat membiarkan operasi bergantian membuat progress

Kode ini terasa lebih jelas soal niat aslinya dan bisa jadi jauh lebih kencang ketimbang memakai sleep, karena timers kayak yang dipakai sama sleep sering kali punya batas seberapa spesifik (granular) mereka bisa bekerja. Versi sleep yang kita pakai, misalnya, bakal selalu tidur setidaknya selama satu milidetik, biarpun kita memberinya Duration sebesar satu nanodetik. Sekali lagi, komputer modern itu cepat: mereka bisa melakukan banyak hal dalam satu milidetik!

Ini artinya asinkron bisa berguna bahkan buat tugas-tugas compute-bound, tergantung dari apa lagi yang lagi dilakukan sama program kita, karena dia menyediakan alat yang berguna buat menstrukturkan hubungan antara berbagai bagian program yang berbeda (tapi dengan biaya beban dari state machine asinkron tersebut). Ini adalah suatu bentuk cooperative multitasking, di mana tiap future punya kuasa buat menentukan kapan dia menyerahkan kontrol lewat titik-titik await. Oleh karena itu, tiap future juga punya tanggung jawab buat menghindari memblokir terlalu lama. Di beberapa sistem operasi embedded berbasis Rust, ini adalah satu-satunya jenis multitasking yang ada!

Di kode dunia nyata, tentu saja kita tidak bakal biasanya menyelingi pemanggilan fungsi dengan titik-titik await di tiap baris kodenya. Meskipun menyerahkan kontrol pakai cara ini terhitung murah biayanya, tetap saja ia tidak gratis. Di banyak kasus, mencoba memecah-mecah tugas compute-bound bisa jadi malah bikin dia jauh lebih lambat, jadi kadang-kadang lebih baik buat performa keseluruhan kalau kita membiarkan sebuah operasi memblokir sebentar. Selalu lakukan pengukuran (measure) buat melihat di mana sebenarnya letak bottlenecks performa kode kita. Dinamika dasarnya tetap penting buat diingat, terutama kalau kita melihat banyak pekerjaan yang terjadi secara serial padahal kita mengharapnya terjadi secara konkuren!

Membikin Abstraksi Asinkron Kita Sendiri

Kita juga bisa menggabungkan (compose) futures bersama-sama buat membikin pola baru. Misalnya, kita bisa membangun fungsi timeout dengan blok penyusun asinkron yang sudah kita punya. Pas kita sudah selesai, hasilnya bakal jadi blok penyusun lainnya yang bisa kita pakai buat membikin abstraksi asinkron yang lebih banyak lagi.

Listing 17-18 menunjukkan gimana ekspektasi kita soal cara kerja timeout ini bersama sebuah future yang lambat.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_secs(2)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}
Listing 17-18: Memakai andalan timeout kita buat menjalankan operasi lambat dengan batas waktu

Mari kita implementasikan ini! Sebagai permulaan, mari pikirkan soal API buat timeout:

  • Ia sendiri harus berupa fungsi asinkron supaya kita bisa me-await-nya.
  • Parameter pertamanya haruslah berupa sebuah future buat dijalankan. Kita bisa membikinnya generik biar dia bisa bekerja buat future apa saja.
  • Parameter keduanya adalah waktu maksimal buat menunggu. Kalau kita memakai Duration, itu bakal gampang diteruskan ke trpl::sleep.
  • Ia harus mengembalikan sebuah Result. Kalau future-nya sukses selesai, Result-nya bakal berupa Ok dengan nilai yang dihasilkan sama future tersebut. Kalau batas waktunya keburu habis duluan, Result-nya bakal berupa Err dengan durasi yang sudah dilewati sama si timeout.

Listing 17-19 menunjukkan deklarasi ini.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_secs(2)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

async fn timeout<F: Future>(
    future_to_try: F,
    max_time: Duration,
) -> Result<F::Output, Duration> {
    // Here is where our implementation will go!
}
Listing 17-19: Mendefinisikan signature dari timeout

Itu sudah memenuhi tujuan kita buat tipe-tipenya. Sekarang mari kita pikirkan soal perilaku yang kita butuhkan: kita mau mengadu (race) future yang dimasukkan tadi melawan durasinya. Kita bisa memakai trpl::sleep buat membikin timer future dari durasinya, lalu memakai trpl::select buat menjalankan timer tersebut barengan sama future yang diberikan sama si pemanggil.

Di Listing 17-20, kita mengimplementasikan timeout dengan melakukan match pada hasil dari me-await trpl::select.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

use trpl::Either;

// --snip--

fn main() {
    trpl::block_on(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_secs(2)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

async fn timeout<F: Future>(
    future_to_try: F,
    max_time: Duration,
) -> Result<F::Output, Duration> {
    match trpl::select(future_to_try, trpl::sleep(max_time)).await {
        Either::Left(output) => Ok(output),
        Either::Right(_) => Err(max_time),
    }
}
Listing 17-20: Mendefinisikan timeout dengan select dan sleep

Implementasi dari trpl::select itu tidak adil (not fair): ia selalu melakukan polling pada argumen-argumennya sesuai urutan saat mereka dioper (implementasi select lainnya biasanya bakal memilih argumen mana yang mau di-poll duluan secara acak). Maka dari itu, kita mengoper future_to_try ke select duluan biar dia dapet kesempatan buat selesai biarpun max_time itu durasinya sangat singkat. Kalau future_to_try beres duluan, select bakal mengembalikan Left yang isinya output dari future_to_try. Kalau timer beres duluan, select bakal mengembalikan Right berisi output dari timernya yaitu ().

Kalau future_to_try sukses dan kita dapet Left(output), kita mengembalikan Ok(output). Kalau sebaliknya si sleep timer yang habis waktunya dan kita dapet Right(()), kita mengabaikan () tersebut pakai _ lalu mengembalikan Err(max_time) sebagai gantinya.

Selesai deh, kita sudah punya fungsi timeout yang bisa jalan yang dibangun dari dua buah pembantu asinkron lainnya. Kalau kita jalankan kodenya, dia bakal mencetak mode kegagalan setelah timeout terjadi:

Failed after 2 seconds

Karena futures bisa digabung-gabungin bareng futures lain, kita bisa membangun alat-alat yang sangat kuat memakai blok penyusun asinkron yang kecil- kecil. Misalnya, kita bisa memakai pendekatan yang sama ini buat menggabungkan timeouts dengan retries, dan sebaliknya memakai itu semua bersama operasi lain kayak pemanggilan jaringan (seperti contoh yang ada di Listing 17-5).

Di praktiknya, biasanya kita bakal bekerja secara langsung dengan async dan await, dan secara sekunder memakai fungsi-fungsi kayak select dan macros kayak macro join! buat mengontrol gimana futures paling luarnya dieksekusi.

Kita sekarang sudah melihat sejumlah cara buat bekerja bareng banyak futures di saat yang bersamaan. Selanjutnya, kita bakal melihat gimana cara kita bekerja dengan banyak futures di dalam sebuah urutan seiring berjalannya waktu menggunakan streams.

Streams: Serangkaian Futures Berurutan

Streams: Futures dalam Urutan

Ingat kembali gimana cara kita memakai receiver buat asinkron channel kita di awal bab ini di bagian “Message Passing”. Method asinkron recv memproduksi serangkaian item seiring berjalannya waktu. Ini adalah salah satu bentuk dari pola yang jauh lebih umum yang dikenal sebagai stream. Banyak konsep yang secara alami bisa direpresentasikan sebagai streams: item-item yang mulai tersedia di dalam antrean (queue), potongan data yang ditarik secara bertahap dari sistem file pas set datanya terlalu besar buat memori komputer, atau data yang tiba melalui jaringan seiring waktu. Karena streams adalah futures, kita bisa memakainya bareng jenis future lainnya dan menggabungkannya dengan cara-cara yang menarik. Misalnya, kita bisa mengumpulkan (batch up) kejadian-kejadian (events) buat menghindari terlalu banyak pemanggilan jaringan, mengatur batas waktu (timeouts) pada urutan operasi yang berjalan lama, atau membatasi (throttle) kejadian-kejadian UI buat menghindari melakukan pekerjaan yang sia-sia.

Kita sudah pernah melihat serangkaian item di Bab 13, pas kita melihat trait Iterator di bagian “Trait Iterator dan Method next, tapi ada dua perbedaan antara iterator dan asinkron channel receiver. Perbedaan pertama adalah waktu: iterator itu sinkron, sedangkan channel receiver itu asinkron. Perbedaan kedua adalah API-nya. Pas bekerja langsung dengan Iterator, kita memanggil method next-nya yang sinkron. Khusus buat stream trpl::Receiver, kita memanggil method recv yang asinkron sebagai gantinya. Di luar itu, API-API ini terasa sangat mirip, dan kemiripan itu bukan kebetulan kok. Sebuah stream itu kayak bentuk asinkron dari iterasi. Meskipun trpl::Receiver secara spesifik menunggu buat menerima pesan, tapi API stream yang buat tujuan umum itu jauh lebih luas: dia menyediakan item berikutnya sama kayak yang dilakukan Iterator, tapi secara asinkron.

Kemiripan antara iterator dan streams di Rust artinya kita sebenarnya bisa membikin sebuah stream dari iterator apa saja. Sama kayak iterator, kita bisa bekerja bareng sebuah stream dengan memanggil method next-nya lalu me-await output-nya, kayak di Listing 17-21, yang mana kode ini belum bisa di-compile.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

fn main() {
    trpl::block_on(async {
        let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        let iter = values.iter().map(|n| n * 2);
        let mut stream = trpl::stream_from_iter(iter);

        while let Some(value) = stream.next().await {
            println!("The value was: {value}");
        }
    });
}
Listing 17-21: Membikin sebuah stream dari sebuah iterator dan mencetak nilai-nilainya

Kita mulai dengan sebuah array angka, yang kemudian kita ubah jadi sebuah iterator lalu memanggil map buat melipatgandakan semua nilainya. Terus kita mengubah iterator tersebut jadi sebuah stream menggunakan fungsi trpl::stream_from_iter. Berikutnya, kita melakukan loop melewati item-item di dalam stream tersebut pas mereka tiba memakai loop while let.

Sayangnya, pas kita coba menjalankan kodenya, ia tidak bisa di-compile melainkan malah melaporkan kalau tidak ada method next yang tersedia:

error[E0599]: no method named `next` found for struct `tokio_stream::iter::Iter` in the current scope
  --> src/main.rs:10:40
   |
10 |         while let Some(value) = stream.next().await {
   |                                        ^^^^
   |
   = help: items from traits can only be used if the trait is in scope
help: the following traits which provide `next` are implemented but not in scope; perhaps you want to import one of them
   |
1  + use crate::trpl::StreamExt;
   |
1  + use futures_util::stream::stream::StreamExt;
   |
1  + use std::iter::Iterator;
   |
1  + use std::str::pattern::Searcher;
   |
help: there is a method `try_next` with a similar name
   |
10 |         while let Some(value) = stream.try_next().await {
   |                                        ~~~~~~~~

Sesuai penjelasan output ini, alasan error compiler-nya adalah karena kita butuh trait yang tepat berada di dalam scope supaya bisa memakai method next. Mengingat pembahasan kita sejauh ini, kita mungkin secara wajar berekspektasi kalau trait tersebut adalah Stream, tapi sebenarnya ia adalah StreamExt. Singkatan dari extension (ekstensi), Ext adalah pola yang umum di komunitas Rust buat memperluas satu trait dengan trait lainnya.

Trait Stream mendefinisikan sebuah low-level interface yang secara efektif menggabungkan trait Iterator dan Future. StreamExt menyuplai sekumpulan API tingkat lebih tinggi di atas Stream, termasuk method next sekaligus method pembantu lainnya yang mirip sama yang disediakan oleh trait Iterator. Stream dan StreamExt belum menjadi bagian dari standard library milik Rust, tapi mayoritas crates di ekosistem memakai definisi yang serupa.

Solusi buat error compiler tadi adalah dengan menambahkan statement use buat trpl::StreamExt, kayak yang ditunjukkan di Listing 17-22.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::StreamExt;

fn main() {
    trpl::block_on(async {
        let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        // --snip--
        let iter = values.iter().map(|n| n * 2);
        let mut stream = trpl::stream_from_iter(iter);

        while let Some(value) = stream.next().await {
            println!("The value was: {value}");
        }
    });
}
Listing 17-22: Berhasil memakai sebuah iterator sebagai dasar buat sebuah stream

Dengan semua bagian itu disatukan, kode ini berjalan sesuai yang kita mau! Bahkan, sekarang karena kita punya StreamExt di dalam scope, kita bisa memakai semua method pembantunya, persis kayak iterator.

Ngelihat Lebih Dekat pada Traits buat Async

Ngelihat Lebih Dekat pada Traits buat Async

Di sepanjang bab ini, kita sudah memakai trait Future, Stream, dan StreamExt dengan berbagai cara. Sejauh ini, kita memang sengaja menghindari membahas terlalu dalam soal gimana detail cara kerja mereka atau gimana mereka saling berhubungan, yang mana sebenarnya sah-sah saja buat pekerjaan Rust kita sehari-hari. Tapi kadang-kadang, kita bakal menjumpai situasi di mana kita butuh memahami lebih banyak detail soal trait-trait ini, bersamaan dengan tipe Pin dan trait Unpin. Di bagian ini, kita bakal menggalinya secukupnya saja buat membantu kita di skenario-skenario tersebut, sembari tetap membiarkan pembahasan yang bener-bener mendalam buat dokumentasi lainnya.

Trait Future

Mari kita mulai dengan melihat lebih dekat gimana cara kerja trait Future. Beginilah cara Rust mendefinisikannya:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

Definisi trait tersebut menyertakan sejumlah tipe baru dan juga beberapa sintaks yang belum pernah kita lihat sebelumnya, jadi mari kita bedah definisinya satu per satu.

Pertama, associated type Output milik Future menyatakan hasil akhir yang dihasilkan sama future tersebut. Ini serupa dengan associated type Item pada trait Iterator. Kedua, Future punya method poll, yang menerima sebuah referensi Pin spesial buat parameter self-nya dan sebuah referensi mutable ke tipe Context, serta mengembalikan sebuah Poll<Self::Output>. Kita bakal ngomongin soal Pin dan Context sebentar lagi. Buat sekarang, mari fokus sama apa yang dikembalikan sama method-nya, yaitu tipe Poll:

#![allow(unused)]
fn main() {
pub enum Poll<T> {
    Ready(T),
    Pending,
}
}

Tipe Poll ini mirip sama Option. Ia punya satu varian yang punya nilai, Ready(T), dan satu lagi yang nggak punya, Pending. Tapi makna Poll itu jauh beda lho sama Option! Varian Pending mengindikasikan kalau future-nya masih punya pekerjaan buat dilakukan, jadi si pemanggil perlu mengeceknya lagi nanti. Varian Ready mengindikasikan kalau Future-nya sudah selesai mengerjakan tugasnya dan nilai T sudah tersedia.

Catatan: Jarang sekali ada kebutuhan buat memanggil poll secara langsung, tapi kalau kita memang butuh melakukannya, ingatlah kalau pada kebanyakan futures, si pemanggil tidak seharusnya memanggil poll lagi setelah future-nya mengembalikan Ready. Banyak futures yang bakal panic kalau di-poll lagi setelah mereka jadi siap. Futures yang aman buat di-poll lagi bakal menyatakannya secara eksplisit di dalam dokumentasinya. Perilaku ini mirip sama gimana Iterator::next bekerja.

Pas kita melihat kode yang memakai await, Rust sebenarnya mengompilasi kode tersebut di balik layar menjadi kode yang memanggil poll. Kalau kita melihat balik ke Listing 17-4, di mana kita mencetak judul halaman buat satu URL begitu dia selesai, Rust mengompilasinya jadi sesuatu yang kira-kira (biarpun nggak persis sama) kayak gini:

match page_title(url).poll() {
    Ready(page_title) => match page_title {
        Some(title) => println!("The title for {url} was {title}"),
        None => println!("{url} had no title"),
    },
    Pending => {
        // Terus apa yang ditaruh di sini?
    }
}

Apa yang harus kita lakukan pas future-nya masih Pending? Kita butuh suatu cara buat mencoba lagi, lagi, dan lagi, sampai future-nya akhirnya siap. Dengan kata lain, kita butuh sebuah loop:

let mut page_title_fut = page_title(url);
loop {
    match page_title_fut.poll() {
        Ready(value) => match page_title {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
        Pending => {
            // lanjut
        }
    }
}

Tapi kalau seandainya Rust benar-benar mengompilasi kode yang persis kayak gitu, tiap await jadinya bakal memblokir—persis kebalikan dari apa yang mau kita capai! Sebaliknya, Rust memastikan kalau perulangannya bisa menyerahkan kontrol ke sesuatu yang bisa me-pause pekerjaan pada future ini buat mengerjakan futures lainnya dan baru kemudian mengecek yang satu ini lagi nanti. Kayak yang sudah kita lihat, “sesuatu” itu adalah sebuah async runtime, dan pekerjaan penjadwalan dan koordinasi ini adalah salah satu tugas utamanya.

Di bagian “Mengirim Data di Antara Dua Task Memakai Message Passing”, kita mendeskripsikan proses menunggu di rx.recv. Pemanggilan recv mengembalikan sebuah future, dan me-await future tersebut bakal me- poll-nya. Kita sudah mencatat kalau sebuah runtime bakal me-pause future-nya sampai dia siap dengan entah Some(message) atau None pas channel-nya tutup. Dengan pemahaman kita yang lebih dalam soal trait Future, dan secara spesifik Future::poll, kita bisa melihat gimana cara kerjanya. Runtime tahu kalau future-nya belum siap pas dia mengembalikan Poll::Pending. Kebalikannya, runtime tahu kalau future-nya sudah siap dan melanjutkannya pas poll mengembalikan Poll::Ready(Some(message)) atau Poll::Ready(None).

Detail persis soal gimana cara runtime melakukan hal tersebut ada di luar cakupan buku ini, tapi kuncinya adalah melihat mekanisme dasar dari futures: sebuah runtime me-poll tiap future yang jadi tanggung jawabnya, lalu menidurkan kembali future tersebut pas dia belum siap.

Tipe Pin dan Trait Unpin

Dulu di Listing 17-13, kita memakai macro trpl::join! buat menunggu tiga buah futures. Tapi, sudah jadi hal yang umum kalau kita punya sebuah koleksi seperti vector yang berisi sejumlah futures yang jumlahnya baru bakal diketahui pas runtime. Mari kita ubah Listing 17-13 jadi kode di Listing 17- 23 yang menaruh ketiga futures tersebut ke dalam sebuah vector lalu memanggil fungsi trpl::join_all sebagai gantinya, yang mana kode ini belum bisa di- compile.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            // --snip--
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures: Vec<Box<dyn Future<Output = ()>>> =
            vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];

        trpl::join_all(futures).await;
    });
}
Listing 17-23: Menunggu futures di dalam sebuah koleksi

Kita menaruh tiap future di dalam sebuah Box buat menjadikannya sebagai trait objects, persis kayak yang kita lakukan di bagian “Mengembalikan Error dari run” di Bab 12. (Kita bakal membahas trait objects secara detail di Bab 18.) Memakai trait objects membiarkan kita memperlakukan tiap futures anonim yang dihasilkan sama tipe-tipe ini sebagai tipe yang sama, karena mereka semua mengimplementasikan trait Future.

Ini mungkin mengejutkan. Lagian, tidak ada satu pun dari blok asinkron tersebut yang mengembalikan apa-apa, jadi masing-masing memproduksi sebuah Future<Output = ()>. Tapi ingat kalau Future itu adalah sebuah trait, dan compiler membikin sebuah enum yang unik buat tiap blok asinkron, biarpun tipe output mereka identik. Sama halnya kayak kita tidak bisa menaruh dua buah struct tulisan tangan yang berbeda ke dalam sebuah Vec, kita juga tidak bisa mencampur aduk enum-enum buatan compiler tersebut.

Terus kita mengoper koleksi futures tersebut ke fungsi trpl::join_all lalu me-await hasilnya. Tapi, kode ini tidak bisa di-compile; ini dia bagian yang relevan dari pesan errornya.

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
  --> src/main.rs:48:33
   |
48 |         trpl::join_all(futures).await;
   |                                 ^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

Catatan di pesan error ini ngasih tahu kita kalau kita seharusnya memakai macro pin! buat me-pin (mematok) nilai-nilainya, yang artinya menaruh mereka di dalam tipe Pin yang menjamin kalau nilai-nilainya tidak bakal dipindah-pindah di dalam memori. Pesan error-nya bilang kalau proses pinning itu diwajibkan karena dyn Future<Output = ()> perlu mengimplementasikan trait Unpin dan untuk sekarang dia belum melakukannya.

Fungsi trpl::join_all mengembalikan sebuah struct bernama JoinAll. Struct tersebut sifatnya generik terhadap tipe F, yang dibatasi (constrained) buat mengimplementasikan trait Future. Menunggu sebuah future secara langsung dengan await bakal me-pin future-nya secara implisit. Itulah alasan kenapa kita tidak perlu memakai pin! di semua tempat di mana kita mau me- await futures.

Tapi, kita ini sedang tidak menunggu sebuah future secara langsung di sini. Sebaliknya, kita sedang membangun sebuah future baru, JoinAll, dengan cara meneruskan sekumpulan futures ke fungsi join_all. Signature buat join_all mewajibkan tipe-tipe dari item di dalam koleksinya buat semuanya mengimplementasikan trait Future, dan Box<T> mengimplementasikan Future cuma kalau T yang ia bungkus itu adalah sebuah future yang mengimplementasikan trait Unpin.

Duh, sangat banyak yang harus diserap ya! Biar benar-benar paham, mari kita gali sedikit lebih jauh soal gimana cara trait Future sebenarnya bekerja, terutama seputar urusan pinning. Mari kita lihat lagi definisi dari trait Future:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    // Method yang wajib ada
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

Parameter cx dan tipe Context-nya adalah kunci gimana sebuah runtime bisa benar-benar tahu kapan harus mengecek sembarang future yang diberikan sambil tetap bersifat malas. Sekali lagi, detail gimana itu bekerja ada di luar cakupan bab ini, dan kita umumnya cuma perlu memikirkan hal ini pas lagi menulis implementasi Future kustom. Kita bakal fokus saja ke tipe buat self, karena ini adalah pertama kalinya kita melihat sebuah method di mana self punya anotasi tipe. Sebuah anotasi tipe buat self bekerja kayak anotasi tipe buat parameter fungsi lainnya tapi dengan dua perbedaan kunci:

  • Ia memberi tahu Rust tipe self apa yang harus dimiliki supaya method-nya bisa dipanggil.
  • Ia nggak boleh sembarang tipe. Ia dibatasi cuma buat tipe tempat method-nya diimplementasikan, sebuah referensi atau smart pointer ke tipe tersebut, atau sebuah Pin yang membungkus referensi ke tipe tersebut.

Kita bakal melihat lebih banyak lagi soal sintaks ini di Bab 18. Buat sekarang, cukup tahu saja kalau kita mau me-poll sebuah future buat mengecek apakah dia itu Pending atau Ready(Output), kita butuh referensi mutable yang dibungkus Pin ke tipe tersebut.

Pin adalah sebuah pembungkus (wrapper) buat tipe-tipe yang mirip pointer kayak &, &mut, Box, dan Rc. (Secara teknis, Pin bekerja dengan tipe-tipe yang mengimplementasikan trait Deref atau DerefMut, tapi ini secara efektif ekuivalen dengan bekerja cuma bareng referensi dan smart pointers.) Pin bukanlah sebuah pointer itu sendiri dan ia tidak punya perilaku miliknya sendiri kayak Rc dan Arc yang punya fitur reference counting; ia murni hanyalah alat yang bisa dipakai compiler buat menegakkan batasan (constraints) pada penggunaan pointer.

Mengingat kembali kalau await itu diimplementasikan lewat pemanggilan- pemanggilan ke poll mulai bisa menjelaskan pesan error yang kita lihat tadi, tapi kan tadi itu dalam konteks Unpin, bukan Pin. Jadi apa sebenarnya hubungan Pin dengan Unpin, dan kenapa Future butuh self buat berada di dalam tipe Pin supaya bisa memanggil poll?

Ingat dari bagian awal bab ini kalau serangkaian titik await di dalam sebuah future bakal dikompilasi jadi sebuah state machine, dan compiler mastiin kalau state machine tersebut mengikuti semua aturan normal Rust seputar keamanan, termasuk borrowing dan ownership. Biar itu bisa jalan, Rust melihat data apa saja yang dibutuhkan di antara satu titik await dengan titik await berikutnya atau sampai akhir dari blok asinkronnya. Terus dia membikin varian yang korespondensi di dalam state machine hasil kompilasinya. Tiap varian dapet akses yang dibutuhkannya ke data yang bakal dipakai di bagian kode sumber tersebut, entah dengan mengambil kepemilikan dari data tersebut atau dengan mendapatkan referensi mutable atau immutable kepadanya.

Sejauh ini aman: kalau kita bikin kesalahan soal ownership atau referensi di sebuah blok asinkron tertentu, si borrow checker bakal kasih tahu kita. Tapi pas kita mau memindah-mindahkan future yang berkaitan sama blok tersebut—kayak memindahkannya ke dalam sebuah Vec buat dioper ke join_all—urusannya jadi makin rumit.

Pas kita memindahkan sebuah future—entah itu dengan memasukkannya ke struktur data buat dipakai sebagai iterator bersama join_all atau dengan mengembalikannya dari sebuah fungsi—itu sebenarnya bermakna memindahkan state machine yang Rust bikin buat kita. Dan beda sama mayoritas tipe lain di Rust, futures yang Rust bikin buat blok asinkron bisa berakhir punya referensi ke dirinya sendiri di dalam field dari varian yang manapun, kayak yang ditunjukkan di ilustrasi yang disederhanakan di Gambar 17-4.

Sebuah tabel dengan satu kolom dan tiga baris yang merepresentasikan sebuah future, fut1, yang punya nilai data 0 dan 1 di dua baris pertamanya dan sebuah panah yang menunjuk dari baris ketiga balik ke baris kedua, melambangkan sebuah referensi internal di dalam future tersebut.
Gambar 17-4: Sebuah tipe data yang punya referensi ke dirinya sendiri (self-referential)

Tapi secara bawaan, objek apa saja yang punya referensi ke dirinya sendiri itu sifatnya tidak aman buat dipindahkan, karena referensi itu selalu menunjuk ke alamat memori asli dari apa pun yang dirujuknya (lihat Gambar 17-5). Kalau kita memindahkan struktur datanya itu sendiri, referensi internal tadi bakal ditinggalkan dalam posisi menunjuk ke lokasi yang lama. Padahal, lokasi memori tersebut sekarang sudah tidak valid. Di satu sisi, nilainya tidak bakal di- update pas kita melakukan perubahan ke struktur datanya. Di sisi lain—yang lebih penting—komputernya sekarang bebas buat memakai ulang memori tersebut buat keperluan lain! Kita bisa berakhir membaca data yang sama sekali tidak ada hubungannya nanti.

Dua tabel, menggambarkan dua futures, fut1 dan fut2, yang masing-masing punya satu kolom dan tiga baris, merepresentasikan hasil dari memindahkan sebuah future keluar dari fut1 ke fut2. Yang pertama, fut1, digelapkan warnanya, dengan tanda tanya di tiap indeksnya, melambangkan memori yang tidak diketahui. Yang kedua, fut2, punya 0 dan 1 di baris pertama dan kedua dan sebuah panah yang menunjuk dari baris ketiganya balik ke baris kedua milik fut1, melambangkan sebuah pointer yang sedang merujuk ke lokasi lama di memori milik future tersebut sebelum ia dipindahkan.
Gambar 17-5: Hasil yang tidak aman dari memindahkan tipe data yang bersifat self-referential

Secara teori, compiler Rust bisa saja mencoba buat meng-update tiap referensi ke suatu objek setiap kali objek tersebut dipindahkan, tapi itu bisa menambah banyak beban performa, apalagi kalau ada jaring-jaring referensi utuh yang perlu di-update. Kalau kita sebaliknya bisa memastikan struktur data yang dimaksud itu tidak pindah-pindah di memori, kita tidak perlu repot-repot meng- update referensi apa pun. Nah, itulah gunanya borrow checker milik Rust: di dalam kode yang aman, ia mencegah kita dari memindahkan item apa saja yang punya referensi aktif yang menunjuk kepadanya.

Pin dibangun di atas hal tersebut buat memberikan jaminan persis yang kita butuhkan. Pas kita me-pin sebuah nilai dengan membungkus sebuah pointer ke nilai tersebut di dalam Pin, dia sudah tidak bisa pindah lagi. Jadi, kalau kita punya Pin<Box<SuatuTipe>>, kita sebenarnya sedang me-pin nilai SuatuTipe tersebut, bukan pointer Box-nya. Gambar 17-6 mengilustrasikan proses ini.

Tiga kotak diletakkan berdampingan. Yang pertama dilabeli “Pin”, yang kedua “b1”, dan yang ketiga “pinned”. Di dalam “pinned” ada tabel dilabeli “fut”, dengan satu kolom; ia merepresentasikan sebuah future dengan sel-sel buat tiap bagian dari struktur datanya. Sel pertamanya punya nilai “0”, sel keduanya punya panah yang keluar darinya dan menunjuk ke sel keempat dan terakhir, yang punya nilai “1” di dalamnya, dan sel ketiga punya garis putus-putus dan elipsis buat menandakan mungkin ada bagian lain dari struktur datanya. Secara keseluruhan, tabel “fut” merepresentasikan sebuah future yang bersifat self-referential. Sebuah panah keluar dari kotak berlabel “Pin”, melewati kotak berlabel “b1” dan berakhir di dalam kotak “pinned” di tabel “fut”.
Gambar 17-6: Me-pin sebuah `Box` yang menunjuk ke tipe future yang bersifat self-referential

Kenyataannya, pointer Box-nya tetap bisa pindah-pindah dengan bebas. Ingat: kita peduli buat memastikan kalau data yang ujung-ujungnya sedang direferensikan itu tetap diam di tempat. Kalau sebuah pointer pindah-pindah, tapi data yang ditunjuknya tetap ada di tempat yang sama, kayak di Gambar 17-7, nggak bakal ada masalah potensial. (Sebagai latihan mandiri, coba lihat dokumentasi buat tipe-tipe tersebut sekaligus modul std::pin dan coba cari tahu gimana cara kita melakukan ini pakai Pin yang membungkus sebuah Box.) Kuncinya adalah tipe self-referential-nya itu sendiri nggak bisa pindah, karena ia tetap di- pin.

Empat kotak diletakkan di tiga kolom kasar, identik dengan diagram sebelumnya dengan perubahan di kolom kedua. Sekarang ada dua kotak di kolom kedua, berlabel “b1” dan “b2”, “b1” warnanya digelapkan, dan panah dari “Pin” melewati “b2” bukannya “b1”, menandakan kalau pointernya sudah pindah dari “b1” ke “b2”, tapi data di dalam “pinned” belum pindah.
Gambar 17-7: Memindahkan sebuah `Box` yang menunjuk ke tipe future yang bersifat self-referential

Meskipun begitu, mayoritas tipe itu benar-benar aman buat dipindah-pindahkan, biarpun mereka kebetulan ada di balik sebuah pointer Pin. Kita cuma perlu mikirin soal pinning pas item-itemnya punya referensi internal. Nilai-nilai primitif kayak angka dan Boolean itu aman karena mereka jelas nggak punya referensi internal apa-apa. Begitu juga sama mayoritas tipe yang biasa kita pakai di Rust. Kita bisa memindah-mindahkan sebuah Vec, misalnya, tanpa perlu khawatir. Mengingat apa yang sudah kita lihat sejauh ini, kalau kita punya Pin<Vec<String>>, kita bakal dipaksa melakukan segalanya lewat API milik Pin yang aman tapi membatasi, padahal sebuah Vec<String> itu selalu aman buat dipindahkan kalau tidak ada referensi lain kepadanya. Kita butuh sebuah cara buat memberi tahu compiler kalau tidak apa-apa buat memindah-mindahkan item di kasus-kasus kayak gini—dan di situlah Unpin beraksi.

Unpin adalah sebuah marker trait, mirip sama trait Send dan Sync yang kita lihat di Bab 16, dan oleh karenanya tidak punya fungsionalitas miliknya sendiri. Marker traits eksis cuma buat memberi tahu compiler kalau tipe yang mengimplementasikan trait tersebut aman buat dipakai di suatu konteks tertentu. Unpin menginformasikan ke compiler kalau suatu tipe tertentu tidak perlu menjunjung tinggi jaminan apa pun soal apakah nilai yang dimaksud bisa dipindahkan dengan aman atau tidak.

Sama kayak Send dan Sync, compiler mengimplementasikan Unpin secara otomatis buat semua tipe di mana dia bisa membuktikan keamanannya. Kasus spesialnya, sekali lagi mirip kayak Send dan Sync, adalah pas Unpin tidak diimplementasikan buat suatu tipe. Notasi buat hal ini adalah impl !Unpin for SuatuTipe, di mana SuatuTipe adalah nama dari tipe yang memang perlu menjunjung tinggi jaminan-jaminan tersebut supaya aman kapan pun sebuah pointer ke tipe tersebut dipakai di dalam sebuah Pin.

Dengan kata lain, ada dua hal yang harus diingat soal hubungan antara Pin dan Unpin. Pertama, Unpin adalah kasus yang “normal”, dan !Unpin adalah kasus yang spesial. Kedua, apakah suatu tipe mengimplementasikan Unpin atau !Unpin itu hanya berpengaruh pas kita lagi memakai pointer yang di-pin ke tipe tersebut kayak Pin<&mut SuatuTipe>.

Buat menjadikannya konkret, coba pikirkan soal sebuah String: ia punya sebuah panjang (length) dan karakter-karakter Unicode yang menyusunnya. Kita bisa membungkus sebuah String di dalam Pin, kayak yang terlihat di Gambar 17-8. Tapi, String secara otomatis mengimplementasikan Unpin, sama halnya kayak mayoritas tipe lain di Rust.

Sebuah kotak berlabel “Pin” di sebelah kiri dengan sebuah panah yang mengarah darinya ke sebuah kotak berlabel “String” di sebelah kanan. Kotak “String” berisi data 5usize, melambangkan panjang dari string-nya, dan huruf-huruf “h”, “e”, “l”, “l”, dan “o” melambangkan karakter dari string “hello” yang disimpan di dalam instance String ini. Sebuah persegi panjang putus-putus mengelilingi kotak “String” dan labelnya, tapi tidak dengan kotak “Pin”.
Gambar 17-8: Me-pin sebuah `String`; garis putus-putusnya menandakan kalau `String` tersebut mengimplementasikan trait `Unpin` dan oleh karena itu tidak benar-benar ter-pin

Hasilnya, kita bisa melakukan hal-hal yang tadinya ilegal kalau seandainya String mengimplementasikan !Unpin, kayak misalnya mengganti satu string dengan yang lain di lokasi memori yang sama persis kayak di Gambar 17-9. Ini tidak melanggar kontrak dari Pin, karena String nggak punya referensi internal yang bikin dia jadi nggak aman buat dipindah-pindahkan. Itulah persisnya kenapa ia mengimplementasikan Unpin bukannya !Unpin.

Data string “hello” yang sama dari contoh sebelumnya, sekarang dilabeli “s1” dan warnanya digelapkan. Kotak “Pin” dari contoh sebelumnya sekarang menunjuk ke instance String yang berbeda, yang dilabeli “s2”, bersifat valid, punya panjang 7usize, dan berisi karakter-karakter dari string “goodbye”. s2 juga dikelilingi sama persegi panjang putus-putus karena ia juga mengimplementasikan trait Unpin.
Gambar 17-9: Mengganti si `String` dengan `String` yang benar-benar berbeda di memori

Nah sekarang kita sudah tahu cukup banyak buat memahami error-error yang dilaporkan buat pemanggilan join_all tadi balik di Listing 17-23. Kita awalnya mencoba memindahkan futures hasil produksi blok asinkron ke dalam sebuah Vec<Box<dyn Future<Output = ()>>>, tapi kayak yang sudah kita lihat, futures tersebut mungkin saja punya referensi internal, jadi mereka tidak secara otomatis mengimplementasikan Unpin. Begitu kita me-pin mereka, kita bisa meneruskan tipe Pin hasilnya ke dalam Vec, dengan rasa percaya diri kalau data yang mendasari futures tersebut tidak bakal dipindahkan. Listing 17-24 menunjukkan cara membetulkan kodenya dengan memanggil macro pin! di tiap tempat di mana ketiga futures tadi didefinisikan dan menyesuaikan tipe dari trait object-nya.

extern crate trpl; // required for mdbook test

use std::pin::{Pin, pin};

// --snip--

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = pin!(async move {
            // --snip--
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        });

        let rx_fut = pin!(async {
            // --snip--
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        });

        let tx_fut = pin!(async move {
            // --snip--
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        });

        let futures: Vec<Pin<&mut dyn Future<Output = ()>>> =
            vec![tx1_fut, rx_fut, tx_fut];

        trpl::join_all(futures).await;
    });
}
Listing 17-24: Me-pin futures buat memungkinkan mereka dipindahkan ke dalam vector

Contoh ini sekarang sudah bisa di-compile dan dijalankan, dan kita bisa menambah atau menghapus futures dari vector-nya pas runtime lalu menggabungkan mereka semua.

Pin dan Unpin itu paling banyak kepake pas lagi membangun lower-level libraries, atau pas kita lagi membangun sebuah runtime itu sendiri, bukannya buat kode Rust sehari-hari. Tapi pas kita melihat trait-trait ini muncul di dalam pesan error, setidaknya sekarang kita punya gambaran yang lebih oke soal gimana cara membetulkan kode kita!

Catatan: Kombinasi antara Pin dan Unpin ini memungkinkan implementasi aman dari keseluruhan kelas tipe-tipe kompleks di Rust yang tadinya bakal terbukti menantang gara-gara mereka bersifat self-referential. Tipe-tipe yang mewajibkan Pin paling sering muncul di Rust asinkron saat ini, tapi sekali-sekali, kita mungkin juga bakal menjumpai mereka di konteks lain.

Detail spesifik soal gimana Pin dan Unpin bekerja, dan aturan apa saja yang wajib mereka junjung tinggi, sudah dibahas secara ekstensif di dalam dokumentasi API buat std::pin, jadi kalau kita tertarik buat belajar lebih lanjut, itu adalah tempat yang bagus buat memulai.

Kalau kita mau memahami gimana cara kerja di balik layarnya secara lebih detail lagi, silakan baca Bab 2 dan 4 dari buku Asynchronous Programming in Rust.

Trait Stream

Sekarang setelah kita punya pemahaman yang lebih dalam soal trait Future, Pin, dan Unpin, kita bisa mengalihkan perhatian kita ke trait Stream. Kayak yang sudah kita pelajari sebelumnya di bab ini, streams itu mirip kayak iterator asinkron. Tapi beda sama Iterator dan Future, Stream itu tidak punya definisi di dalam standard library pada saat tulisan ini dibuat, tapi emang ada definisi yang sangat umum dari crate futures yang dipakai di seluruh ekosistem.

Mari kita ulas balik definisi dari trait Iterator dan Future sebelum melihat gimana sebuah trait Stream mungkin bakal menggabungkan keduanya. Dari Iterator, kita dapet ide soal urutan (sequence): method next-nya menyediakan sebuah Option<Self::Item>. Dari Future, kita dapet ide soal kesiapan seiring berjalannya waktu: method poll-nya menyediakan sebuah Poll<Self::Output>. Buat merepresentasikan serangkaian item yang mulai siap seiring waktu, kita mendefinisikan sebuah trait Stream yang menggabungkan fitur-fitur tersebut:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;

    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>
    ) -> Poll<Option<Self::Item>>;
}
}

Trait Stream mendefinisikan sebuah associated type bernama Item buat tipe dari item-item yang diproduksi sama stream-nya. Ini mirip sama Iterator, di mana itemnya bisa berjumlah nol sampai banyak, dan beda sama Future, yang mana output-nya selalu satu, biarpun itu cuma tipe unit ().

Stream juga mendefinisikan sebuah method buat mendapatkan item-item tersebut. Kita menamainya poll_next, buat memperjelas kalau dia me-poll pakai cara yang sama kayak yang dilakukan Future::poll dan memproduksi serangkaian item pakai cara yang sama kayak yang dilakukan Iterator::next. Tipe kembaliannya menggabungkan Poll dengan Option. Tipe luarnya adalah Poll, karena dia wajib dicek kesiapannya, persis kayak sebuah future. Tipe dalamnya adalah Option, karena dia butuh memberi tanda apakah masih ada pesan lagi, persis kayak sebuah iterator.

Sesuatu yang sangat mirip dengan definisi ini kemungkinan besar bakal berakhir menjadi bagian dari standard library milik Rust. Sembari menunggu, ia adalah bagian dari peralatan milik mayoritas runtimes, jadi kita bisa mengandalkannya, dan segala hal yang kita bahas selanjutnya secara umum bakal tetap berlaku!

Tapi di contoh-contoh yang kita lihat di bagian “Streams: Futures dalam Urutan” tadi, kita tidak memakai poll_next maupun Stream, melainkan malah memakai next dan StreamExt. Tentu saja kita bisa bekerja secara langsung mengikuti API poll_next dengan cara menulis tangan state machine Stream kita sendiri, persis kayak kita juga bisa bekerja bareng futures secara langsung lewat method poll-nya. Tapi memakai await itu jauh lebih enak, dan trait StreamExt menyuplai method next supaya kita bisa melakukan hal itu:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;
    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Option<Self::Item>>;
}

trait StreamExt: Stream {
    async fn next(&mut self) -> Option<Self::Item>
    where
        Self: Unpin;

    // other methods...
}
}

Catatan: Definisi asli yang kita pakai sebelumnya di bab ini kelihatan sedikit berbeda dari ini, karena ia mendukung versi Rust yang dulu belum mendukung penggunaan fungsi asinkron di dalam traits. Hasilnya, ia kelihatannya kayak gini:

fn next(&mut self) -> Next<'_, Self> where Self: Unpin;

Tipe Next tersebut adalah sebuah struct yang mengimplementasikan Future dan mengizinkan kita buat menamai lifetime dari referensi ke self dengan Next<'_, Self>, supaya await bisa bekerja bareng method ini.

Trait StreamExt juga merupakan rumah dari semua method menarik yang tersedia buat dipakai bareng streams. StreamExt secara otomatis diimplementasikan buat tiap tipe yang mengimplementasikan Stream, tapi trait-trait ini didefinisikan secara terpisah supaya komunitas bisa beriterasi pada API-API kemudahan tanpa mengganggu trait dasarnya.

Di versi StreamExt yang dipakai di crate trpl, trait-nya tidak cuma mendefinisikan method next tapi juga menyuplai implementasi default dari next yang secara benar menangani detail-detail pemanggilan Stream::poll_next. Ini artinya bahkan pas kita perlu menulis tipe data streaming kita sendiri, kita cuma harus mengimplementasikan Stream, dan nantinya siapa saja yang memakai tipe data kita bisa memakai StreamExt beserta method- methodnya secara otomatis.

Nah, segitu saja pembahasan kita soal detail-detail tingkat rendah pada trait- trait ini. Sebagai penutup, mari kita pertimbangkan gimana futures (termasuk streams), tasks, dan threads semuanya saling melengkapi!

Futures, Tasks, dan Threads

Menyatukan Semuanya: Futures, Tasks, dan Threads

Seperti yang sudah kita lihat di Bab 16, threads menyediakan salah satu pendekatan buat konkurensi. Kita juga sudah melihat pendekatan lainnya di bab ini: memakai asinkron dengan futures dan streams. Kalau kita bertanya-tanya kapan harus memilih salah satu metode di atas metode lainnya, jawabannya adalah: tergantung! Dan di banyak kasus, pilihannya bukan antara threads atau asinkron, melainkan threads dan asinkron.

Banyak sistem operasi sudah menyuplai model konkurensi berbasis threading selama puluhan tahun, dan banyak bahasa pemrograman yang jadi menyokongnya sebagai hasilnya. Namun, model-model ini bukannya tanpa pertukaran (tradeoffs). Di banyak sistem operasi, tiap thread memakan memori dalam jumlah yang cukup besar. Threads juga cuma jadi opsi pas sistem operasi dan perangkat keras kita memang menyokongnya. Beda sama komputer desktop dan ponsel arus utama, beberapa sistem embedded bahkan tidak punya sistem operasi sama sekali, jadinya mereka juga tidak punya threads.

Model asinkron menyediakan kumpulan tradeoffs yang berbeda—dan pada akhirnya saling melengkapi. Di dalam model asinkron, operasi konkuren tidak mewajibkan adanya thread masing-masing. Sebagai gantinya, mereka bisa berjalan di atas tasks (tugas), kayak pas kita memakai trpl::spawn_task buat memulai pekerjaan dari sebuah fungsi sinkron di bagian streams. Sebuah task itu mirip sama thread, tapi bukannya dikelola oleh sistem operasi, ia dikelola sama kode di tingkat library: yaitu runtime.

Ada alasannya kenapa API buat menelurkan threads dan menelurkan tasks itu sangat mirip. Threads bertindak sebagai batas (boundary) buat sekumpulan operasi sinkron; konkurensi dimungkinkan di antara threads. Tasks bertindak sebagai batas buat sekumpulan operasi asinkron; konkurensi dimungkinkan baik di antara maupun di dalam tasks, karena sebuah task bisa berganti-ganti di antara futures di dalam isinya. Terakhir, futures adalah unit konkurensi Rust yang paling spesifik (granular), dan tiap future bisa merepresentasikan sebuah pohon berisi futures lainnya. Runtime—lebih spesifiknya, executor-nya—mengelola tasks, dan tasks mengelola futures. Dalam hal tersebut, tasks itu mirip kayak threads yang ringan dan dikelola sama runtime dengan tambahan kapabilitas yang datang dari fakta bahwa ia dikelola sama runtime bukannya sama sistem operasi.

Ini tidak berarti kalau async tasks itu selalu lebih baik dibanding threads (atau sebaliknya). Konkurensi memakai threads dalam beberapa hal merupakan model pemrograman yang lebih simpel dibanding konkurensi memakai async. Itu bisa jadi kekuatan atau kelemahan. Threads itu semacam “nyalakan dan lupakan” (fire and forget); mereka nggak punya padanan asli terhadap future, jadi mereka sekadar jalan sampai selesai tanpa bisa diinterupsi kecuali oleh sistem operasinya sendiri.

Dan ternyata threads dan tasks itu sering kali bekerja barengan dengan sangat baik, karena tasks (setidaknya di beberapa runtimes) bisa dipindah- pindahkan antar threads. Bahkan, di balik layar, runtime yang sudah kita pakai—termasuk fungsi spawn_blocking dan spawn_task—sifatnya adalah multi- threaded secara bawaan! Banyak runtimes memakai pendekatan bernama work stealing buat memindah-mindahkan tasks antar threads secara transparan, berdasarkan gimana tiap thread tersebut sedang dimanfaatkan saat itu, demi meningkatkan performa keseluruhan sistem. Pendekatan tersebut sebenarnya mewajibkan adanya threads dan tasks, dan oleh karenanya juga futures.

Pas memikirkan metode mana yang mau dipakai kapan, coba pertimbangkan aturan praktis (rules of thumb) berikut:

  • Kalau pekerjaannya sangat bisa diparalelkan (yaitu, CPU-bound), kayak memproses sekumpulan data di mana tiap bagiannya bisa diproses secara terpisah, threads adalah pilihan yang lebih oke.
  • Kalau pekerjaannya sangat konkuren (yaitu, I/O-bound), kayak menangani pesan-pesan dari sekumpulan sumber berbeda yang mungkin datang di interval atau kecepatan yang berbeda-beda, asinkron adalah pilihan yang lebih oke.

Dan kalau kita butuh baik paralelisme maupun konkurensi, kita tidak harus memilih antara threads dan asinkron. Kita bisa memakai keduanya bersamaan dengan bebas, membiarkan masing-masing memainkan peran yang paling dikuasainya. Misalnya, Listing 17-25 menunjukkan contoh yang lumayan umum dari campuran kayak gini di kode Rust dunia nyata.

Filename: src/main.rs
extern crate trpl; // for mdbook test

use std::{thread, time::Duration};

fn main() {
    let (tx, mut rx) = trpl::channel();

    thread::spawn(move || {
        for i in 1..11 {
            tx.send(i).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    trpl::block_on(async {
        while let Some(message) = rx.recv().await {
            println!("{message}");
        }
    });
}
Listing 17-25: Mengirim pesan pakai kode memblokir (blocking) di dalam sebuah thread dan menunggu pesan-pesan tersebut di dalam blok asinkron

Kita mulai dengan membikin asinkron channel, terus menelurkan sebuah thread yang mengambil kepemilikan dari sisi pengirim channel tersebut memakai keyword move. Di dalam thread tersebut, kita mengirim angka 1 sampai 10, sambil tidur selama satu detik di tiap angkanya. Terakhir, kita menjalankan sebuah future yang dibuat pakai blok asinkron yang dioper ke trpl::block_on persis kayak apa yang sudah kita lakukan di sepanjang bab ini. Di dalam future tersebut, kita me-await pesan-pesan tadi, persis kayak di contoh-contoh message-passing lainnya yang sudah kita lihat.

Buat balik lagi ke skenario yang kita buka di awal bab, bayangkan menjalankan sekumpulan tugas video encoding memakai thread khusus (karena video encoding itu sifatnya compute-bound) tapi memberikan notifikasi ke UI kalau operasi tersebut sudah selesai memakai asinkron channel. Ada banyak sekali contoh dari jenis kombinasi kayak gini di kasus penggunaan dunia nyata.

Ringkasan

Ini bukan kali terakhir kita melihat soal konkurensi di buku ini. Proyek di Bab 21 bakal menerapkan konsep-konsep ini di situasi yang lebih realistis dibanding contoh-contoh simpel yang dibahas di sini dan membandingkan penyelesaian masalah memakai threading versus tasks dan futures secara lebih langsung.

Metode mana pun yang kita pilih, Rust memberi kita alat-alat yang kita butuhkan buat menulis kode konkuren yang aman dan kencang—baik itu buat web server yang high-throughput maupun buat sistem operasi embedded.

Berikutnya, kita bakal ngomongin soal cara yang idiomatik buat memodelkan masalah dan menstrukturkan solusi seiring dengan semakin besarnya program Rust kita. Selain itu, kita bakal membahas gimana kaitan antara idiom milik Rust dengan idiom yang mungkin kita sudah familier di pemrograman berorientasi objek (object-oriented programming).

Fitur Pemrograman Berorientasi Objek

Pemrograman berorientasi objek (object-oriented programming, disingkat OOP) adalah sebuah cara buat memodelkan program. Objek (objects) sebagai sebuah konsep terprogram diperkenalkan pada bahasa pemrograman Simula di tahun 1960-an. Objek-objek tersebut memengaruhi arsitektur pemrograman buatan Alan Kay di mana objek-objek bisa saling melempar pesan satu sama lain. Buat mendeskripsikan arsitektur ini, dia mencetuskan istilah object-oriented programming di tahun 1967. Ada banyak definisi yang saling bersaing mengenai apa itu OOP, dan berdasarkan beberapa dari definisi-definisi tersebut Rust bisa dibilang berorientasi objek, tapi berdasarkan definisi lainnya dia bukan. Di bab ini, kita bakal mengeksplorasi ciri-ciri tertentu yang umumnya dianggap berorientasi objek dan gimana ciri-ciri itu diterjemahkan (translated) ke Rust yang idiomatik. Kita lalu bakal menunjukkan ke kita gimana cara mengimplementasikan sebuah desain pola (design pattern) berorientasi objek di Rust dan membahas trade-offs (kelebihan dan kekurangan) dari melakukannya dibandingkan dengan mengimplementasikan solusi yang memanfaatkan kekuatan- kekuatan dari Rust itu sendiri.

Ciri-ciri Bahasa Pemrograman Berorientasi Objek

Ciri-ciri Bahasa Pemrograman Berorientasi Objek

Tidak ada konsensus (kesepakatan umum) di komunitas pemrograman mengenai fitur- fitur apa aja yang wajib dimiliki oleh sebuah bahasa supaya bisa dianggap berorientasi objek. Rust dipengaruhi oleh banyak paradigma pemrograman, termasuk OOP; misalnya, kita sudah mengeksplorasi fitur-fitur yang datang dari pemrograman fungsional (functional programming) di Bab 13. Walaupun bisa diperdebatkan (arguably), bahasa pemrograman OOP biasanya berbagi ciri-ciri umum tertentu, yakni objek, encapsulation (enkapsulasi), dan inheritance (pewarisan). Mari kita lihat apa arti dari masing-masing ciri tersebut dan apakah Rust mendukungnya atau tidak.

Objek Mengandung Data dan Perilaku (Behavior)

Buku Design Patterns: Elements of Reusable Object-Oriented Software karangan Erich Gamma, Richard Helm, Ralph Johnson, dan John Vlissides (Addison-Wesley, 1994), yang secara santai sering disebut sebagai buku The Gang of Four, adalah sebuah katalog berisi desain pola berorientasi objek. Buku itu mendefinisikan OOP dengan cara ini:

Program berorientasi objek dibikin dari objek-objek. Sebuah objek membungkus (packages) baik data maupun prosedur-prosedur yang beroperasi pada data tersebut. Prosedur-prosedur tersebut biasanya disebut methods atau operations.

Memakai definisi ini, Rust itu berorientasi objek: structs dan enums punya data, dan blok impl menyediakan methods pada structs dan enums tersebut. Meskipun structs dan enums yang dilengkapi methods tidak disebut sebagai objek, mereka menyediakan fungsionalitas yang sama, menurut definisi objek dari the Gang of Four.

Encapsulation yang Menyembunyikan Detail Implementasi

Aspek lain yang umumnya dikaitkan dengan OOP adalah ide tentang encapsulation (enkapsulasi), yang berarti kalau detail implementasi dari sebuah objek itu tidak bisa diakses (accessible) oleh kode yang memakai objek tersebut. Oleh karena itu, satu-satunya cara buat berinteraksi dengan sebuah objek adalah melalui API public-nya; kode yang memakai objek itu tidak seharusnya bisa menjangkau bagian internal dari si objek lalu mengubah data atau perilakunya secara langsung. Hal ini memungkinkan programmer untuk mengubah dan me-refactor bagian internal dari sebuah objek tanpa perlu mengubah kode yang memakai objek tersebut.

Kita sudah membahas cara mengontrol encapsulation di Bab 7: kita bisa memakai keyword pub buat menentukan modul, tipe, fungsi, dan method mana saja di kode kita yang seharusnya bersifat public, sementara secara default segala hal lainnya bersifat private. Misalnya, kita bisa mendefinisikan sebuah struct AveragedCollection yang punya field yang berisi vector dari nilai i32. Struct tersebut juga bisa punya sebuah field yang berisi nilai rata-rata (average) dari nilai-nilai di vector tersebut, yang berarti nilai rata-ratanya tidak harus dihitung seketika (on demand) kapan pun seseorang membutuhkannya. Dengan kata lain, AveragedCollection bakal menge-cache nilai rata-rata yang sudah dihitung tersebut buat kita. Listing 18-1 punya definisi dari struct AveragedCollection.

Filename: src/lib.rs
pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}
Listing 18-1: Sebuah struct AveragedCollection yang memelihara daftar (list) dari angka integer dan nilai rata-rata dari item-item di koleksi tersebut

Struct ini ditandai sebagai pub supaya kode lain bisa memakainya, tapi field- field di dalam struct tersebut tetap bersifat private. Hal ini penting di kasus ini karena kita pengen memastikan bahwa kapan pun sebuah nilai ditambahkan atau dihapus dari list, nilai average juga harus di-update. Kita melakukan ini dengan mengimplementasikan method add, remove, dan average pada struct tersebut, seperti yang ditunjukkan di Listing 18-2.

Filename: src/lib.rs
pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

impl AveragedCollection {
    pub fn add(&mut self, value: i32) {
        self.list.push(value);
        self.update_average();
    }

    pub fn remove(&mut self) -> Option<i32> {
        let result = self.list.pop();
        match result {
            Some(value) => {
                self.update_average();
                Some(value)
            }
            None => None,
        }
    }

    pub fn average(&self) -> f64 {
        self.average
    }

    fn update_average(&mut self) {
        let total: i32 = self.list.iter().sum();
        self.average = total as f64 / self.list.len() as f64;
    }
}
Listing 18-2: Implementasi dari public methods add, remove, dan average pada AveragedCollection

Public methods add, remove, dan average adalah satu-satunya cara buat mengakses atau memodifikasi data di dalam sebuah instance AveragedCollection. Saat sebuah item ditambahkan ke list memakai method add atau dihapus memakai method remove, implementasi dari kedua method ini bakal memanggil method private update_average yang juga menangani pembaruan pada field average.

Kita membiarkan field list dan average tetap private sehingga tidak ada cara buat kode eksternal (external code) buat menambahkan atau menghapus item ke atau dari field list secara langsung; karena jika dibiarkan, field average bisa jadi tidak sinkron saat list berubah. Method average mengembalikan nilai yang ada di dalam field average, yang memungkinkan kode eksternal buat membaca nilai average tersebut tapi tidak bisa memodifikasinya.

Karena kita udah mengenkapsulasi (encapsulated) detail implementasi dari struct AveragedCollection, kita bisa gampang mengubah berbagai aspeknya, kayak struktur datanya, di masa depan. Misalnya, kita bisa aja memakai sebuah HashSet<i32> ketimbang Vec<i32> buat field list. Asalkan signature dari public methods add, remove, dan average itu tetap sama, kode yang memakai AveragedCollection tidak perlu berubah. Tapi, kalau seandainya kita membikin list jadi public, hal ini belum tentu benar: HashSet<i32> dan Vec<i32> punya method yang berbeda buat menambahkan dan menghapus item, jadi kode eksternalnya kemungkinan besar juga harus diubah kalau mereka awalnya memodifikasi list secara langsung.

Kalau encapsulation adalah aspek yang wajib dipunyai sama sebuah bahasa agar bisa dianggap berorientasi objek, maka Rust memenuhi syarat tersebut. Pilihan buat memakai pub atau tidak pada berbagai bagian kode memungkinkan adanya encapsulation terhadap detail-detail implementasi.

Inheritance sebagai Sistem Tipe (Type System) dan Pembagian Kode (Code Sharing)

Inheritance (pewarisan) adalah sebuah mekanisme di mana sebuah objek bisa mewarisi (inherit) elemen-elemen dari definisi objek lain, dengan begitu ia mendapatkan data dan perilaku dari objek induk (parent object) tanpa kita harus mendefinisikannya lagi.

Kalau sebuah bahasa wajib punya inheritance buat bisa disebut berorientasi objek, maka Rust tidak termasuk dalam bahasa tersebut. Tidak ada cara buat mendefinisikan sebuah struct yang mewarisi field-field dan implementasi method dari struct induk tanpa memakai macro.

Namun, kalau kita udah biasa (used to) punya inheritance di alat (toolbox) pemrograman kita, kita bisa memakai solusi lain di Rust, tergantung dari alasan kenapa kita mencari inheritance tersebut sejak awal.

Kita biasanya bakal milih buat memakai inheritance karena dua alasan utama. Alasan pertama adalah buat pemakaian ulang kode (reuse of code): kita bisa mengimplementasikan perilaku tertentu untuk satu tipe, dan inheritance memungkinkan kita buat memakai ulang implementasi tersebut buat tipe yang berbeda. Kita bisa melakukan hal ini secara terbatas di kode Rust dengan memakai default trait method implementations (implementasi bawaan pada trait method), yang udah kita lihat di Listing 10-14 pas kita menambahkan implementasi default dari method summarize pada trait Summary. Tipe apa pun yang mengimplementasikan trait Summary bakal langsung punya method summarize yang bisa dipakai padanya tanpa perlu nulis kode tambahan lagi. Ini mirip dengan sebuah parent class (kelas induk) yang punya implementasi dari suatu method dan sebuah inheriting child class (kelas anak yang mewarisi) yang juga ikutan punya implementasi dari method tersebut. Kita juga bisa menimpa (override) implementasi default dari method summarize pas kita mengimplementasikan trait Summary, yang mana mirip kayak saat sebuah child class menimpa implementasi method yang diwarisinya dari sebuah parent class.

Alasan lain memakai inheritance berkaitan dengan sistem tipenya: yakni memungkinkan sebuah tipe child (anak) buat dipakai di tempat-tempat yang sama kayak tipe parent (induk)-nya. Ini juga disebut dengan polymorphism (polimorfisme), yang berarti kita bisa mensubstitusikan (menggantikan) berbagai objek untuk satu sama lain pas runtime asalkan mereka berbagi karakteristik tertentu.

Polimorfisme

Buat banyak orang, polimorfisme itu sinonim dengan inheritance. Tapi dia sebenarnya adalah konsep yang lebih umum yang mengacu ke kode yang bisa bekerja bareng data dari berbagai macam tipe. Buat inheritance, tipe-tipe itu umumnya adalah subclasses (subkelas).

Sebaliknya, Rust memakai generics (generik) buat mengabstraksi bermacam- macam kemungkinan tipe dan trait bounds buat memaksakan batasan-batasan (constraints) pada apa yang wajib disediakan sama tipe-tipe tersebut. Ini kadang-kadang disebut sebagai bounded parametric polymorphism (polimorfisme parametrik terbatas).

Rust memilih serangkaian tradeoffs yang berbeda dengan tidak menawarkan inheritance. Inheritance sering kali punya risiko buat nge-share lebih banyak kode daripada yang diperlukan. Subclasses tidak seharusnya selalu berbagi semua karakteristik dari parent class mereka, tapi mereka bakal melakukan itu kalau pakai inheritance. Hal ini bisa bikin desain dari sebuah program jadi kurang fleksibel. Ini juga memunculkan kemungkinan memanggil method-method pada subclasses yang sebenarnya tidak masuk akal atau menyebabkan error gara-gara method-method itu nyatanya tidak berlaku (apply) buat subclass tersebut. Selain itu, beberapa bahasa cuma mengizinkan single inheritance (berarti satu subclass cuma boleh mewarisi dari satu kelas saja), yang lebih lanjut membatasi fleksibilitas dari desain sebuah program.

Atas alasan-alasan ini, Rust mengambil pendekatan yang berbeda yaitu dengan memakai trait objects (objek trait) sebagai ganti inheritance buat memungkinkan adanya polimorfisme. Mari kita lihat gimana cara trait objects bekerja.

Memakai Trait Objects Buat Mengabstraksi Perilaku Bersama

Memakai Trait Objects Buat Mengabstraksi Perilaku Bersama

Di Bab 8, kita sempat nyebut kalau salah satu batasan dari vectors adalah bahwa mereka cuma bisa nyimpan elemen dari satu tipe aja. Kita membikin sebuah solusi buat ngakalin hal ini (workaround) di Listing 8-9 di mana kita mendefinisikan enum SpreadsheetCell yang punya varian-varian buat menampung integers, floats, dan teks. Ini berarti kita bisa nyimpan tipe data yang berbeda-beda di setiap sel tapi tetap punya sebuah vector yang merepresentasikan satu baris sel. Ini adalah solusi yang sangat bagus kalau item-item yang mau kita tukar-tukar (interchangeable) itu merupakan serangkaian tipe tetap yang udah kita tahu pas kode kita di-compile.

Namun, kadang-kadang kita pengen supaya user library kita bisa memperluas serangkaian tipe yang valid di suatu situasi tertentu. Buat nunjukin gimana kita bisa mencapai hal ini, kita bakal membikin contoh alat graphical user interface (GUI) yang beriterasi ngelewatin sebuah daftar (list) item, lalu memanggil method draw pada masing-masing item tersebut buat menggambarnya ke layar—sebuah teknik yang umum buat alat-alat GUI. Kita bakal membikin sebuah library crate bernama gui yang mengandung struktur dari library GUI tersebut. Crate ini mungkin bakal nyertakan beberapa tipe buat dipakai orang-orang, kayak Button atau TextField. Selain itu, para pengguna gui bakal pengen bikin tipe-tipe mereka sendiri yang bisa digambar: contohnya, satu programmer mungkin bakal nambahin Image dan yang lain mungkin bakal nambahin SelectBox.

Pas lagi nulis library-nya, kita tidak bisa tahu dan tidak bisa mendefinisikan semua tipe yang mungkin pengen dibikin sama programmer lain nantinya. Tapi kita tahu kalau gui itu perlu melacak (keep track of) banyak nilai dari tipe yang berbeda-beda, dan dia perlu manggil method draw pada setiap nilai yang bertipe beda-beda ini. Dia tidak perlu tahu persis apa yang bakal terjadi pas kita manggil method draw tersebut, dia cuma butuh tahu kalau nilai itu punya method tersebut dan tersedia buat kita panggil.

Buat ngelakuin ini di bahasa pemrograman yang punya inheritance (pewarisan), kita mungkin bakal mendefinisikan sebuah class bernama Component yang punya sebuah method bernama draw padanya. Kelas-kelas lainnya, kayak Button, Image, dan SelectBox, bakal mewarisi (inherit) dari Component dan dengan gitu bakal mewarisi juga method draw-nya. Mereka masing-masing bisa menimpa (override) method draw tersebut buat mendefinisikan perilaku kustom mereka sendiri, tapi framework-nya bisa memperlakukan semua tipe-tipe tersebut seolah-olah mereka adalah instance dari Component dan memanggil draw pada mereka. Tapi karena Rust tidak punya inheritance, kita butuh cara lain buat menstrukturkan library gui tersebut supaya user bisa bikin tipe-tipe baru yang kompatibel sama library kita.

Mendefinisikan sebuah Trait untuk Perilaku Umum (Common Behavior)

Buat mengimplementasikan perilaku yang kita pengen ada di gui, kita bakal mendefinisikan sebuah trait bernama Draw yang bakal punya satu method bernama draw. Terus kita bisa mendefinisikan sebuah vector yang menerima sebuah trait object (objek trait). Sebuah trait object menunjuk ke sebuah instance dari suatu tipe yang mengimplementasikan trait yang sudah kita tentukan, dan juga menunjuk ke sebuah tabel yang dipakai buat nyari trait methods pada tipe tersebut saat runtime. Kita membikin trait object dengan menentukan semacam pointer, kayak referensi & atau smart pointer Box<T>, lalu keyword dyn, dan lalu menentukan trait yang relevan. (Kita bakal ngomongin alasan kenapa trait objects harus memakai pointer di “Tipe-tipe yang Berukuran Dinamis dan Trait Sized di Bab 20.) Kita bisa memakai trait objects buat menggantikan tipe generik atau tipe konkret. Di mana pun kita memakai sebuah trait object, sistem tipe Rust bakal memastikan saat compile time bahwa nilai apa pun yang dipakai di konteks tersebut bakal mengimplementasikan trait dari trait object itu. Akibatnya, kita tidak perlu tahu semua tipe yang mungkin ada saat compile time.

Kita udah sempat nyebut kalau di Rust, kita menahan diri buat tidak nyebut structs dan enums sebagai “objek” buat ngebedain mereka dari objek di bahasa pemrograman lain. Di sebuah struct atau enum, data di fields struct-nya dan perilakunya di blok impl itu dipisahkan, sedangkan di bahasa pemrograman lain, data dan perilaku yang digabungkan jadi satu konsep itu yang sering kali dilabeli sebagai sebuah objek. Trait objects berbeda dari objek di bahasa lain dalam hal kita tidak bisa menambahkan data ke sebuah trait object. Trait objects tidak berguna secara umum (generally useful) layaknya objek di bahasa lain: tujuan spesifik mereka adalah memungkinkan abstraksi melewati berbagai perilaku umum (common behavior).

Listing 18-3 menunjukkan gimana cara mendefinisikan sebuah trait bernama Draw dengan satu method bernama draw.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}
Listing 18-3: Definisi dari trait Draw

Sintaks ini seharusnya udah kerasa familier dari pembahasan kita soal gimana cara mendefinisikan traits di Bab 10. Berikutnya datang beberapa sintaks baru: Listing 18-4 mendefinisikan sebuah struct bernama Screen yang memegang sebuah vector bernama components. Vector ini bertipe Box<dyn Draw>, yang mana adalah sebuah trait object; dia jadi pengganti (stand-in) buat tipe apa pun di dalam sebuah Box yang mengimplementasikan trait Draw.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}
Listing 18-4: Definisi dari struct Screen dengan sebuah field components yang memegang sebuah vector berisi trait objects yang mengimplementasikan trait Draw

Pada struct Screen, kita bakal mendefinisikan sebuah method bernama run yang bakal memanggil method draw pada masing-masing komponen (components)-nya, seperti yang ditunjukkan di Listing 18-5.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}
Listing 18-5: Sebuah method run pada Screen yang memanggil method draw pada tiap komponen

Ini bekerja dengan cara yang beda dari pas kita mendefinisikan sebuah struct yang memakai sebuah parameter tipe generik (generic type parameter) dengan trait bounds. Sebuah parameter tipe generik cuma bisa digantikan (substituted) oleh satu tipe konkret aja pada satu waktu, sedangkan trait objects memungkinkan banyak tipe konkret buat ngisi posisi trait object tersebut saat runtime. Misalnya, kita bisa saja mendefinisikan struct Screen memakai tipe generik dan sebuah trait bound, seperti di Listing 18-6.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

impl<T> Screen<T>
where
    T: Draw,
{
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}
Listing 18-6: Sebuah implementasi alternatif buat struct Screen dan method run-nya memakai generik dan trait bounds

Cara ini membatasi kita pada satu instance Screen yang punya daftar komponen yang semuanya bertipe Button atau semuanya bertipe TextField. Kalau kita emang cuma bakal punya koleksi yang homogen (sama semua jenisnya), memakai generik dan trait bounds lebih disarankan karena definisi-definisinya bakal di-monomorphize (diubah ke tipe spesifik) saat compile time buat memakai tipe-tipe konkret tersebut.

Di sisi lain, dengan memakai method yang menggunakan trait objects, satu instance Screen bisa memegang sebuah Vec<T> yang berisi sebuah Box<Button> sekaligus sebuah Box<TextField>. Mari kita lihat gimana cara kerja ini, dan nanti kita bakal ngebahas soal implikasi performa runtime-nya.

Mengimplementasikan Trait-nya

Sekarang kita bakal menambahkan beberapa tipe yang mengimplementasikan trait Draw. Kita bakal menyediakan tipe Button. Sekali lagi, sebenarnya mengimplementasikan library GUI itu ada di luar cakupan buku ini, jadi method draw di sini tidak bakal punya implementasi yang berguna di isi fungsinya. Buat ngebayangin seperti apa rupa dari implementasinya, sebuah struct Button mungkin punya fields buat width, height, dan label, seperti yang ditunjukkan di Listing 18-7.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // code to actually draw a button
    }
}
Listing 18-7: Sebuah struct Button yang mengimplementasikan trait Draw

Fields width, height, dan label pada Button bakal beda dari fields pada komponen-komponen lain; misalnya, tipe TextField mungkin punya fields yang sama ditambah sebuah field placeholder. Masing-masing dari tipe yang mau kita gambar di layar bakal mengimplementasikan trait Draw tapi mereka bakal memakai kode yang berbeda di dalam method draw buat mendefinisikan gimana cara menggambar tipe khusus tersebut, seperti yang dimiliki Button di sini (tanpa kode GUI aslinya, seperti yang disebutkan sebelumnya). Tipe Button, misalnya, mungkin punya blok impl tambahan yang mengandung method-method yang berkaitan dengan apa yang terjadi saat seorang user mengklik tombol (button) itu. Method-method semacam ini tidak bakal berlaku buat tipe kayak TextField.

Kalau seseorang yang memakai library kita memutuskan buat mengimplementasikan sebuah struct SelectBox yang punya fields width, height, dan options, mereka bakal mengimplementasikan trait Draw pada tipe SelectBox tersebut juga, seperti yang ditunjukkan di Listing 18-8.

Filename: src/main.rs
use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

fn main() {}
Listing 18-8: Sebuah crate lain yang memakai gui dan mengimplementasikan trait Draw pada sebuah struct SelectBox

User dari library kita sekarang bisa menulis fungsi main mereka buat bikin sebuah instance Screen. Ke instance Screen ini, mereka bisa menambahkan sebuah SelectBox dan sebuah Button dengan menaruh masing-masing tipe tersebut di dalam sebuah Box<T> buat menjadikannya trait object. Terus mereka bisa manggil method run pada instance Screen tersebut, yang mana bakal manggil draw pada masing-masing komponen. Listing 18-9 menunjukkan implementasi ini.

Filename: src/main.rs
use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

use gui::{Button, Screen};

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No"),
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };

    screen.run();
}
Listing 18-9: Memakai trait objects buat nyimpan nilai-nilai dari tipe yang berbeda yang mengimplementasikan trait yang sama

Pas kita nulis library-nya, kita tidak tahu kalau seseorang mungkin bakal nambahin tipe SelectBox, tapi implementasi Screen kita nyatanya bisa beroperasi pada tipe yang baru tersebut dan menggambarnya karena SelectBox mengimplementasikan trait Draw, yang artinya dia mengimplementasikan method draw.

Konsep ini—yaitu peduli hanya pada pesan apa yang direspon sama sebuah nilai ketimbang peduli sama tipe konkret dari nilai tersebut—itu mirip sama konsep duck typing (tipe bebek) di bahasa pemrograman dengan tipe dinamis (dynamically typed languages): kalau dia jalan kayak bebek dan kwek kayak bebek, maka dia pasti bebek! Di dalam implementasi dari run pada Screen di Listing 18-5, run tidak perlu tahu apa tipe konkret dari masing-masing komponen tersebut. Dia tidak ngecek apakah sebuah komponen itu adalah instance dari sebuah Button atau sebuah SelectBox, dia cuma manggil method draw pada komponen tersebut. Dengan menentukan Box<dyn Draw> sebagai tipe dari nilai-nilai di vector components, kita udah mendefinisikan Screen buat butuh nilai-nilai yang bisa kita panggil method draw-nya.

Keuntungan dari memakai trait objects dan sistem tipe Rust buat nulis kode yang mirip sama kode yang memakai duck typing adalah bahwa kita tidak perlu repot ngecek apakah sebuah nilai mengimplementasikan suatu method tertentu atau enggak saat runtime, atau khawatir dapat error kalau sebuah nilai ternyata tidak mengimplementasikan sebuah method tapi kita tetep memanggilnya. Rust tidak bakal mengompilasi kode kita kalau nilai-nilainya tidak mengimplementasikan traits yang dibutuhkan sama trait objects tersebut.

Misalnya, Listing 18-10 menunjukkan apa yang terjadi kalau kita mencoba bikin sebuah Screen dengan sebuah String sebagai komponennya.

Filename: src/main.rs
use gui::Screen;

fn main() {
    let screen = Screen {
        components: vec![Box::new(String::from("Hi"))],
    };

    screen.run();
}
Listing 18-10: Mencoba memakai tipe yang tidak mengimplementasikan trait dari trait object

Kita bakal dapat error ini karena String tidak mengimplementasikan trait Draw:

$ cargo run
   Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
 --> src/main.rs:5:26
  |
5 |         components: vec![Box::new(String::from("Hi"))],
  |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
  |
  = help: the trait `Draw` is implemented for `Button`
  = note: required for the cast from `Box<String>` to `Box<dyn Draw>`

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

Error ini ngasih tahu kita kalau entah kita itu memberikan sesuatu ke Screen yang sebenarnya tidak kita maksudkan untuk kita berikan dan oleh karena itu kita harusnya memberikan tipe yang berbeda, atau kita harusnya mengimplementasikan Draw pada String supaya Screen bisa memanggil draw pada String tersebut.

Trait Objects Melakukan Dynamic Dispatch

Ingat kembali di “Performa Kode yang Memakai Generik” di Bab 10 pembahasan kita soal proses monomorphization (monomorfisasi) yang dilakukan pada tipe generik oleh compiler: compiler membikin (generates) implementasi fungsi dan method yang non-generik (spesifik) buat setiap tipe konkret yang kita pakai buat gantiin parameter tipe generik tersebut. Kode hasil dari monomorphization ini melakukan static dispatch (pengiriman statis), yaitu ketika compiler sudah tahu method apa yang kita panggil saat compile time. Ini berlawanan dengan dynamic dispatch (pengiriman dinamis), yaitu ketika compiler tidak bisa tahu pasti pas compile time method mana yang kita panggil. Di kasus dynamic dispatch, compiler menghasilkan (emits) kode yang saat runtime baru bakal mencari tahu method mana yang harus dipanggil.

Saat kita memakai trait objects, Rust pasti memakai dynamic dispatch. Compiler tidak tahu semua tipe yang mungkin dipakai bersama kode yang lagi memakai trait objects tersebut, jadi dia tidak tahu method yang mana (yang diimplementasikan pada tipe yang mana) yang harus dipanggil. Alih-alih begitu, saat runtime, Rust memakai pointer yang ada di dalam trait object buat mencari tahu method mana yang harus dipanggil. Pencarian (lookup) ini menimbulkan beban runtime (runtime cost) yang tidak terjadi pada static dispatch. Dynamic dispatch juga mencegah compiler dari memilih buat meng-inline (menyisipkan kode secara langsung) kode dari sebuah method, yang mana berakibat mencegah (prevents) dilakukannya beberapa optimasi lain. Rust juga punya beberapa aturan soal di mana kita boleh dan tidak boleh memakai dynamic dispatch, yang disebut dyn compatibility (kompatibilitas dinamis). Aturan-aturan itu ada di luar cakupan pembahasan ini, tapi kita bisa baca lebih lanjut soal itu di referensinya. Namun, kita dapat kebebasan ekstra (extra flexibility) di dalam kode yang kita tulis di Listing 18-5 dan berhasil mendukung tipe baru di Listing 18-9, jadi ini adalah trade-off (pertukaran) yang patut dipertimbangkan.

Mengimplementasikan Desain Pola Object-Oriented

Mengimplementasikan Desain Pola Object-Oriented

State pattern (pola status) adalah sebuah desain pola object-oriented (berorientasi objek). Inti dari pola ini adalah kita mendefinisikan serangkaian states (status/keadaan) yang bisa dimiliki oleh suatu nilai di dalamnya (internally). States ini direpresentasikan oleh sekumpulan state objects, dan perilaku dari nilai tersebut berubah berdasarkan state yang dia miliki saat itu. Kita bakal mengerjakan contoh berupa struct postingan blog yang punya field buat menampung state-nya, yang mana bakal berupa state object dari serangkaian pilihan: “draft” (draf), “review” (tinjauan), atau “published” (dipublikasikan).

Objek-objek state ini saling berbagi fungsionalitas: di Rust, tentu saja, kita memakai struct dan traits bukannya objek dan pewarisan (inheritance). Setiap state object bertanggung jawab buat perilakunya sendiri dan mengatur kapan dia harus berubah jadi state lain. Nilai yang menampung state object tersebut sama sekali tidak tahu tentang perilaku yang berbeda dari setiap state atau kapan waktu yang tepat buat bertransisi (transition) antar states.

Keuntungan dari memakai state pattern ini adalah, saat ada perubahan persyaratan bisnis (business requirements) di program kita, kita tidak perlu mengubah kode dari nilai yang menampung state-nya atau kode yang memakai nilai tersebut. Kita cuma perlu meng-update kode di dalam salah satu state objects buat ngubah aturan-aturannya atau mungkin nambahin state objects baru.

Pertama-tama kita bakal mengimplementasikan state pattern pakai cara yang lebih tradisional ala object-oriented, terus kita bakal memakai pendekatan yang lebih natural di Rust. Mari kita gali pelan-pelan buat mengimplementasikan workflow (alur kerja) postingan blog pakai state pattern.

Fungsionalitas akhirnya bakal kelihatan kayak gini:

  1. Postingan blog bermula dari draft kosong.
  2. Saat draft-nya beres, sebuah review dari postingan itu diminta (requested).
  3. Saat postingannya disetujui (approved), dia bakal di-publish (dipublikasikan).
  4. Cuma postingan blog yang udah di-publish yang mengembalikan konten buat dicetak, jadi postingan yang belum disetujui tidak bakal bisa tidak sengaja ke-publish.

Perubahan lain yang dicoba dilakukan pada postingan tersebut seharusnya tidak bisa mengubah apa pun. Misalnya, kalau kita mencoba men-approve sebuah postingan draft sebelum kita me-request review, postingan itu seharusnya tetap berupa draft yang belum di-publish.

Percobaan Object-Oriented yang Tradisional

Ada sangat banyak cara buat menata struktur kode buat menyelesaikan masalah yang sama, masing-masing dengan trade-offs (kekurangan/kelebihan) yang beda. Implementasi di bagian ini memakai gaya object-oriented yang lebih tradisional, yang mana bisa ditulis di Rust, tapi tidak memanfaatkan beberapa dari kekuatan unggulan yang dimiliki Rust. Nanti, kita bakal mendemonstrasikan solusi lain yang tetap memakai desain pola object-oriented tapi disusun sedemikian rupa hingga mungkin kelihatan kurang familier buat programmer yang punya pengalaman object-oriented. Kita bakal membandingkan kedua solusi ini buat ngalamin langsung trade-offs dari mendesain kode di Rust dengan cara yang beda daripada nulis kode di bahasa lain.

Listing 18-11 menunjukkan workflow ini dalam bentuk kode: ini adalah contoh pemakaian dari API yang bakal kita implementasikan di sebuah library crate bernama blog. Ini masih belum bisa di-compile karena kita belum mengimplementasikan crate blog-nya.

Filename: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}
Listing 18-11: Kode yang mendemonstrasikan perilaku yang kita pengen ada di crate blog kita

Kita mau ngebolehin user bikin postingan blog draft baru pakai Post::new. Kita mau ngebolehin teks buat ditambahin ke postingan blog itu. Kalau kita nyoba ngambil konten (content) dari postingan itu langsung, sebelum adanya approval, kita tidak seharusnya dapat teks apa pun karena postingannya masih berupa draft. Kita udah nambahin assert_eq! di kode ini buat tujuan demonstrasi aja. Pengujian unit test yang cakep sekali buat ini adalah dengan menegaskan (assert) kalau postingan blog draft mengembalikan string kosong dari method content, tapi kita tidak bakal nulis tests buat contoh ini.

Berikutnya, kita mau memungkinkan adanya permintaan (request) buat me-review postingan tersebut, dan kita pengen method content tetap mengembalikan string kosong selama masih nungguin review. Pas postingannya udah dapat approval, dia harusnya langsung ke-publish, yang berarti teks dari postingan tersebut bakal dikembalikan saat method content dipanggil.

Perhatikan bahwa satu-satunya tipe yang kita pakai buat berinteraksi dari crate ini adalah tipe Post. Tipe ini bakal memakai state pattern dan bakal menampung sebuah nilai yang merupakan salah satu dari tiga state objects yang mewakili berbagai macam state yang mungkin ada pada suatu postingan—draft, review, atau published. Perubahan dari satu state ke state lainnya bakal dikelola secara internal di dalam tipe Post. States-nya berubah sebagai respons terhadap method-method yang dipanggil sama para pengguna library kita pada instance Post tersebut, tapi mereka sendiri tidak perlu ngurusin (manage) perubahan state-nya secara langsung. Selain itu, user juga tidak bisa ngelakuin kesalahan pada states-nya, kayak nge-publish sebuah postingan sebelum dia di-review.

Mendefinisikan Post dan Membikin Instance Baru di State Draft

Mari kita mulai ngimplementasiin library-nya! Kita tahu kita butuh sebuah struct public Post yang menampung beberapa konten, jadi kita bakal mulai dengan definisi dari struct tersebut dan fungsi associated public bernama new buat bikin sebuah instance dari Post, seperti yang ditunjukkan di Listing 18-12. Kita juga bakal bikin trait private bernama State yang bakal mendefinisikan perilaku yang wajib dimiliki sama semua state objects buat Post.

Terus, Post bakal menampung sebuah trait object berupa Box<dyn State> di dalam sebuah Option<T> di sebuah field private bernama state buat naruh si state object. Kita bakal ngelihat kenapa Option<T> ini dibutuhkan sebentar lagi.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
Listing 18-12: Definisi dari struct Post dan fungsi new yang ngebikin instance Post baru, trait State, dan struct Draft

Trait State mendefinisikan perilaku yang di-share (dibagi) oleh states dari postingan yang beda-beda. State objects-nya adalah Draft, PendingReview, dan Published, dan mereka semua bakal mengimplementasikan trait State. Buat sekarang, trait-nya belum punya method apa-apa, dan kita bakal mulai dengan cuma mendefinisikan state Draft aja karena itu adalah state awal (start) yang kita pengen ada di sebuah postingan.

Pas kita membikin Post baru, kita nge-set field state-nya jadi sebuah nilai Some yang nampung sebuah Box. Box ini menunjuk ke sebuah instance baru dari struct Draft. Hal ini memastikan bahwa kapan pun kita membikin instance baru dari Post, dia bakal selalu mulai sebagai draft. Karena field state pada Post itu private, tidak ada cara buat membikin sebuah Post di state selain draft! Di fungsi Post::new, kita menge-set field content jadi String baru yang masih kosong.

Menyimpan Teks dari Konten Postingan

Kita udah lihat di Listing 18-11 kalau kita pengen bisa memanggil method bernama add_text lalu ngasih dia sebuah &str yang kemudian ditambahkan sebagai teks konten dari postingan blog tersebut. Kita mengimplementasikan ini sebagai sebuah method, bukannya ngekspos field content sebagai pub, supaya nanti kita bisa mengimplementasikan sebuah method yang bakal ngontrol gimana data di field content ini bisa dibaca. Method add_text ini lumayan straightforward (sederhana), jadi mari kita tambahin implementasinya di Listing 18-13 ke dalam blok impl Post.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
Listing 18-13: Mengimplementasikan method add_text buat nambahin teks ke content di sebuah postingan

Method add_text menerima referensi mutable ke self karena kita mau mengubah instance Post tempat kita manggil add_text. Kemudian kita memanggil push_str pada String di content dan masukin argumen text buat ditambahin ke content yang udah tersimpan. Perilaku ini tidak bergantung pada state yang lagi dimiliki postingan, jadi ini bukanlah bagian dari state pattern. Method add_text tidak berinteraksi dengan field state sama sekali, tapi dia adalah bagian dari perilaku yang mau kita dukung (support).

Memastikan Konten dari Postingan Draft Itu Kosong

Bahkan setelah kita manggil add_text dan nambahin beberapa konten ke postingan kita, kita tetap pengen method content buat mengembalikan string slice kosong karena postingannya masih ada di state draft, kayak yang ditunjukin di baris 7 di Listing 18-11. Buat sekarang, mari kita implementasikan method content dengan cara paling simpel yang bisa menuhi persyaratan ini: selalu mengembalikan string slice kosong. Kita bakal mengubah ini nanti pas kita udah ngimplementasiin kemampuan buat mengubah state postingan jadi bisa di-publish. Sejauh ini, postingan cuma bisa ada di state draft, jadi konten postingan harus selalu kosong. Listing 18-14 menunjukkan implementasi placeholder (sementara) ini.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
Listing 18-14: Nambahin implementasi placeholder buat method content pada Post yang selalu ngembaliin string slice kosong

Dengan method content yang ditambahkan ini, semua yang ada di Listing 18-11 sampai baris 7 bakal jalan sesuai rencana.

Meminta Review Bakal Mengubah State dari Postingan

Berikutnya, kita perlu nambahin fungsionalitas buat meminta sebuah review dari sebuah postingan, yang mana bakal mengubah state-nya dari Draft jadi PendingReview. Listing 18-15 nunjukin kodenya.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
Listing 18-15: Mengimplementasikan method request_review pada Post dan trait State

Kita ngasih Post sebuah method public bernama request_review yang bakal menerima referensi mutable ke self. Terus kita memanggil method internal request_review pada state saat ini dari Post, dan method request_review yang kedua ini bakal mengonsumsi state saat ini dan mengembalikan state yang baru.

Kita nambahin method request_review ke trait State; semua tipe yang mengimplementasikan trait tersebut sekarang juga harus mengimplementasikan method request_review. Perhatikan bahwa ketimbang pakai self, &self, atau &mut self sebagai parameter pertama di method ini, kita malah pakai self: Box<Self>. Sintaks ini berarti method tersebut cuma valid pas dipanggil pada sebuah Box yang nampung tipe tersebut. Sintaks ini ngambil kepemilikan (ownership) atas Box<Self>, yang bakal membikin state yang lama jadi tidak valid sehingga nilai state dari Post bisa berubah jadi state yang baru.

Buat mengonsumsi state yang lama, method request_review butuh mengambil kepemilikan dari nilai state-nya. Di sinilah Option yang ada di field state milik Post kepake: kita memanggil method take buat mengambil (take out) nilai Some dari field state dan ninggalin nilai None di tempatnya karena Rust tidak ngebolehin kita punya field yang tidak berisi apa-apa (unpopulated fields) di dalam struct. Ini memungkinkan kita mindahin nilai state keluar dari Post ketimbang meminjamnya (borrowing). Terus kita bakal menge-set nilai state dari postingannya ke hasil dari operasi ini.

Kita perlu nge-set state ke None untuk sementara waktu ketimbang menge-setnya secara langsung pakai kode seperti self.state = self.state.request_review(); supaya kita bisa dapat kepemilikan atas nilai state-nya. Ini memastikan Post tidak bisa memakai nilai state yang lama setelah kita udah ngubah dia jadi state yang baru.

Method request_review pada Draft mengembalikan sebuah instance baru yang dibungkus Box dari sebuah struct baru bernama PendingReview, yang mana merepresentasikan state saat sebuah postingan lagi nunggu review. Struct PendingReview juga mengimplementasikan method request_review tapi dia tidak melakukan perubahan (transformations) apa pun. Sebaliknya, dia ngembaliin dirinya sendiri (returns itself) karena kalau kita meminta review pada postingan yang emang udah ada di state PendingReview, dia seharusnya tetap berada di state PendingReview.

Sekarang kita bisa mulai ngelihat keuntungan dari state pattern: method request_review pada Post itu sama persis terlepas dari apa nilai state-nya. Setiap state bertanggung jawab atas aturannya sendiri.

Kita bakal biarin method content pada Post apa adanya, yang mana bakal ngembaliin string slice kosong. Kita sekarang bisa punya Post di state PendingReview maupun di state Draft, tapi kita pengen perilaku yang sama di state PendingReview. Listing 18-11 sekarang udah bisa jalan sampai baris ke-10!

Menambahkan approve buat Mengubah Perilaku dari content

Method approve bakal mirip sama method request_review: dia bakal menge-set state ke nilai yang dibilang sama state saat ini sebagai nilai yang harusnya dia miliki saat state itu di-approve, seperti yang ditunjukin di Listing 18-16.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
Listing 18-16: Mengimplementasikan method approve pada Post dan trait State

Kita nambahin method approve ke trait State dan nambahin struct baru yang mengimplementasikan State, yaitu state Published.

Mirip sama cara kerja request_review di PendingReview, kalau kita memanggil method approve pada sebuah Draft, hal itu tidak bakal punya efek apa-apa karena approve bakal mengembalikan self. Saat kita memanggil approve pada PendingReview, dia mengembalikan instance baru yang dibungkus Box dari struct Published. Struct Published mengimplementasikan trait State, dan baik untuk method request_review maupun method approve, dia mengembalikan dirinya sendiri karena postingan seharusnya tetap berada di state Published dalam kasus-kasus tersebut.

Sekarang kita perlu meng-update method content pada Post. Kita mau supaya nilai yang dikembalikan dari content itu bergantung sama state saat ini dari si Post, jadi kita bakal membikin si Post mendelegasikan (delegate) panggilan ini ke method content yang didefinisikan pada state-nya, seperti yang ditunjukin di Listing 18-17.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }
    // --snip--

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
Listing 18-17: Meng-update method content pada Post buat mendelegasikan panggilan ke method content pada State

Karena tujuan utamanya adalah buat menyimpan semua aturan ini di dalam struct-struct yang mengimplementasikan State, kita memanggil sebuah method content pada nilai yang ada di dalam state dan memasukkan instance dari postingan tersebut (yaitu, self) sebagai argumen. Terus kita mengembalikan nilai yang dikembalikan dari pemakaian method content pada nilai state tadi.

Kita memanggil method as_ref pada Option tersebut karena kita cuma pengen referensi ke nilai yang ada di dalam Option, bukannya ngambil kepemilikan dari nilai itu sendiri. Karena state adalah Option<Box<dyn State>>, pas kita manggil as_ref, yang dikembalikan adalah Option<&Box<dyn State>>. Kalau kita tidak memanggil as_ref, kita bakal dapat error karena kita tidak bisa mindahin state ke luar dari referensi pinjaman (borrowed reference) &self dari parameter fungsinya.

Terus kita memanggil method unwrap, yang mana kita tahu pasti tidak bakal pernah menyebabkan panic karena kita tahu method-method pada Post memastikan kalau state bakal selalu berisi nilai Some saat method-method itu selesai dijalankan. Ini adalah salah satu kasus yang kita obrolin di “Kasus Di Mana kita Punya Lebih Banyak Informasi Daripada Compiler” di Bab 9, di mana kita tahu pasti kalau nilai None itu mustahil, meskipun compiler tidak mampu buat memahami hal itu.

Pada titik ini, pas kita memanggil content pada &Box<dyn State>, fitur deref coercion (paksaan dereferensi) bakal bekerja pada tanda & dan Box tersebut, sehingga pada akhirnya method content bakal dipanggil pada tipe yang mengimplementasikan trait State. Itu artinya kita perlu nambahin content ke definisi trait State, dan di sanalah kita bakal menaruh logika soal konten mana yang harus dikembalikan tergantung di state mana kita berada sekarang, kayak yang ditunjukin di Listing 18-18.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        ""
    }
}

// --snip--

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}
Listing 18-18: Menambahkan method content ke trait State

Kita nambahin sebuah implementasi default (bawaan) buat method content yang mengembalikan string slice kosong. Itu artinya kita tidak perlu repot-repot mengimplementasikan content di struct Draft dan PendingReview. Nah, buat struct Published, kita bakal menimpa (override) method content ini lalu mengembalikan nilai yang ada di post.content. Walaupun praktis, membiarkan method content di State yang menentukan apa isi dari content milik Post itu agak mengaburkan garis batas antara apa yang jadi tanggung jawab State dan apa yang jadi tanggung jawab Post.

Perhatikan bahwa kita juga butuh anotasi lifetime (waktu hidup) di method ini, seperti yang kita bahas di Bab 10. Kita menerima referensi ke sebuah post sebagai argumen dan mengembalikan sebuah referensi ke bagian dari post itu, jadi lifetime dari referensi yang dikembalikan itu berkaitan erat sama lifetime dari argumen post tersebut.

Dan kita udah selesai—semua kode di Listing 18-11 sekarang berjalan sesuai rencana! Kita sudah berhasil mengimplementasikan state pattern dengan aturan-aturan dari workflow postingan blog. Logika yang berkaitan sama aturan-aturannya kini hidup di dalam state objects ketimbang bertebaran (scattered) ke mana-mana di dalam Post.

Kenapa Tidak Pake Enum Aja?

kita mungkin bingung dan nanya-nanya kenapa kita tidak pakai sebuah enum aja buat berbagai macam kemungkinan state postingan tersebut sebagai varian-variannya. Itu emang salah satu solusi yang mungkin; cobain aja terus bandingin hasil akhirnya buat ngelihat mana yang lebih kita suka! Satu kekurangan dari memakai enum adalah di setiap tempat yang ngecek nilai dari enum itu, kita bakal butuh ekspresi match atau sejenisnya buat menangani setiap kemungkinan varian yang ada. Ini bisa jadi jauh lebih berulang-ulang (repetitive) ketimbang solusi yang memakai trait object ini.

Trade-offs dari State Pattern

Kita udah nunjukin kalau Rust itu mampu buat mengimplementasikan state pattern ala object-oriented buat mengenkapsulasi (encapsulate) berbagai jenis perilaku yang seharusnya dimiliki sama sebuah postingan di tiap state-nya. Method-method pada Post sama sekali tidak tahu soal perilaku yang bermacam-macam itu. Dengan cara kita menata kode ini, kita cuma perlu ngecek di satu tempat buat tahu bermacam-macam cara gimana sebuah postingan yang di-publish bisa berperilaku: yaitu di implementasi trait State pada struct Published.

Seandainya kita membikin implementasi alternatif yang tidak pakai state pattern, kita mungkin bakal milih buat pakai ekspresi match di dalam method-method pada Post atau bahkan di dalam kode main yang ngecek state dari postingannya dan mengubah perilaku di tempat-tempat itu. Itu artinya kita harus ngecek di beberapa tempat buat bisa paham semua implikasi dari sebuah postingan saat ia berada di state published.

Dengan state pattern, method-method di Post dan tempat-tempat di mana kita memakai Post tidak butuh ekspresi match, dan buat nambahin sebuah state baru, kita cuma perlu nambahin satu struct baru lalu mengimplementasikan trait methods pada struct baru itu di satu tempat aja.

Implementasi yang memakai state pattern ini gampang sekali buat diperluas buat nambahin lebih banyak fungsionalitas. Buat ngelihat sendiri seberapa simpelnya memelihara (maintaining) kode yang pakai state pattern, coba deh beberapa saran ini:

  • Tambahin method reject (tolak) yang ngubah state postingan dari PendingReview balik lagi ke Draft.
  • Wajibkan (require) dua panggilan ke approve sebelum state-nya bisa berubah jadi Published.
  • Izinkan (allow) user buat nambahin teks konten cuma pas postingan lagi ada di state Draft. Petunjuk: biarin state object yang bertanggung jawab soal apa yang mungkin berubah terkait konten, tapi jangan biarin dia bertanggung jawab buat memodifikasi Post secara langsung.

Satu kelemahan dari state pattern ini adalah karena states-nya sendirilah yang mengimplementasikan proses transisi (perpindahan) ke state lain, beberapa states jadi terikat (coupled) satu sama lain. Kalau kita nambahin state lain di antara PendingReview dan Published, seperti Scheduled (dijadwalkan), kita harus mengubah kode di PendingReview buat bertransisi ke Scheduled sebagai gantinya. Bakal lebih sedikit kerjaannya kalau seandainya PendingReview tidak perlu diubah pas kita nambahin state baru, tapi itu berarti kita harus beralih ke desain pola yang beda (another design pattern).

Kelemahan lainnya adalah kita jadi menduplikasi beberapa logika. Buat menghilangkan sebagian dari duplikasi ini, kita mungkin nyoba buat bikin implementasi default buat method request_review dan approve di trait State yang selalu mengembalikan self. Namun, ini tidak bakal jalan: pas kita pakai State sebagai trait object, trait itu tidak tahu apa tipe konkret yang bakal jadi self-nya nanti, jadi tipe kembalian (return type) itu tidak bisa diketahui saat compile time. (Ini adalah salah satu dari aturan dyn compatibility yang udah disebutin sebelumnya.)

Duplikasi lainnya termasuk implementasi method request_review dan approve yang mirip-mirip di Post. Kedua method itu memakai Option::take dengan field state milik Post, dan kalau state itu isinya Some, mereka mendelegasikannya ke implementasi nilai yang dibungkus tersebut buat method yang sama lalu menge-set nilai baru dari field state dengan hasil panggilannya. Kalau kita punya sangat banyak method di Post yang ngikutin pola ini, kita mungkin bakal pertimbangin buat mendefinisikan sebuah macro buat ngebuang pengulangan ini (lihat “Macros” di Bab 20).

Dengan mengimplementasikan state pattern persis kayak yang didefinisikan buat bahasa pemrograman object-oriented, kita nyatanya tidak memanfaatkan kekuatan unggulan Rust semaksimal mungkin. Mari kita lihat beberapa perubahan yang bisa kita lakukan pada crate blog yang bisa ngebikin invalid states (state yang tidak valid) dan transisi yang salah menjadi error saat compile-time (compile-time errors).

Menge-encode States dan Perilaku (Behavior) ke dalam Types (Tipe)

Kita bakal nunjukin gimana cara memikirkan ulang (rethink) state pattern buat dapat kumpulan trade-offs yang berbeda. Ketimbang mengenkapsulasi states dan transisi secara keseluruhan supaya kode luar tidak tahu apa-apa soal mereka, kita bakal menge-encode (menyandikan) states ke dalam tipe-tipe yang berbeda-beda. Konsekuensinya, sistem pengecekan tipe Rust (type checking system) bakal mencegah usaha (attempts) buat memakai postingan draft di tempat-tempat yang mana cuma postingan yang udah published (dipublikasikan) yang boleh dipakai, dengan ngeluarin error compiler.

Mari kita perhatikan bagian awal dari main di Listing 18-11:

Filename: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}

Kita tetep mau ngasih kemampuan buat bikin postingan baru di state draft memakai Post::new dan kemampuan buat nambahin teks ke dalam konten postingannya. Tapi ketimbang punya sebuah method content pada postingan draft yang cuma ngembaliin string kosong, kita malah bakal membikin supaya postingan draft itu sama sekali tidak punya method content. Dengan begitu, kalau kita nyoba ngambil konten dari postingan draft, kita bakal dapat error compiler yang ngasih tahu kita kalau method itu tidak eksis. Sebagai hasilnya, bakal jadi mustahil bagi kita buat secara tidak sengaja menampilkan konten dari postingan draft pas udah masuk production (produksi), karena kodenya aja tidak bakal bisa di-compile. Listing 18-19 nunjukin definisi struct Post dan struct DraftPost, beserta method yang ada pada keduanya.

Filename: src/lib.rs
pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}
Listing 18-19: Sebuah Post yang punya method content dan DraftPost yang tidak punya method content

Baik struct Post maupun DraftPost punya field private bernama content yang nyimpen teks dari postingan blog tersebut. Struct-struct ini tidak lagi punya field state karena kita udah mindahin encoding dari state-nya ke dalam tipe dari struct-struct itu. Struct Post bakal merepresentasikan sebuah postingan yang udah di-publish, dan dia punya method content yang bakal ngembaliin nilai dari content.

Kita tetap punya fungsi Post::new, tapi ketimbang mengembalikan sebuah instance dari Post, dia sekarang mengembalikan sebuah instance dari DraftPost. Karena content itu sifatnya private dan tidak ada fungsi lain yang ngembaliin Post, maka mustahil buat membikin sebuah instance dari Post saat ini.

Struct DraftPost punya sebuah method add_text, jadi kita bisa nambahin teks ke dalam content sama kayak sebelumnya, tapi perhatikan kalau DraftPost tidak punya method content yang didefinisikan! Jadi sekarang programnya memastikan kalau semua postingan selalu bermula sebagai postingan draft, dan postingan draft ini belum punya konten yang bisa ditampilkan. Usaha apa pun buat ngakalin atau ngelewatin batasan-batasan ini bakal ngasilin error compiler.

Lalu gimana dong caranya kita dapet postingan yang udah ke-publish? Kita mau menegakkan aturan bahwa sebuah postingan draft itu wajib di-review dan di-approve (disetujui) sebelum bisa di-publish. Postingan yang lagi ada di state pending review (nunggu review) juga seharusnya masih belum bisa nampilin konten apa pun. Mari kita implementasikan batasan-batasan ini dengan menambahkan struct baru, PendingReviewPost, lalu mendefinisikan method request_review pada DraftPost yang bakal mengembalikan PendingReviewPost dan mendefinisikan method approve pada PendingReviewPost yang bakal mengembalikan Post, seperti yang ditunjukin di Listing 18-20.

Filename: src/lib.rs
pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    // --snip--
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn request_review(self) -> PendingReviewPost {
        PendingReviewPost {
            content: self.content,
        }
    }
}

pub struct PendingReviewPost {
    content: String,
}

impl PendingReviewPost {
    pub fn approve(self) -> Post {
        Post {
            content: self.content,
        }
    }
}
Listing 18-20: Sebuah PendingReviewPost yang dibikin lewat manggil request_review pada DraftPost dan method approve yang mengubah PendingReviewPost jadi sebuah Post yang ke-publish

Method request_review dan approve mengambil kepemilikan (ownership) dari self, dengan begitu mengonsumsi instance DraftPost dan PendingReviewPost lalu mengubah (transforming) mereka secara berurutan menjadi PendingReviewPost dan Post yang ke-publish. Dengan cara ini, kita tidak bakal punya sisa-sisa (lingering) instance dari DraftPost setelah kita memanggil request_review pada mereka, dan seterusnya. Struct PendingReviewPost tidak punya method content padanya, jadi usaha buat ngebaca kontennya bakal menghasilkan error compiler, persis kayak DraftPost. Karena satu-satunya cara buat dapetin instance Post yang udah ke-publish (yang mana emang punya method content padanya) adalah dengan manggil method approve pada sebuah PendingReviewPost, dan satu-satunya cara buat dapetin PendingReviewPost adalah dengan manggil method request_review pada sebuah DraftPost, kita sekarang telah berhasil menge-encode workflow dari postingan blog ini ke dalam sistem tipe (type system) di Rust.

Tapi kita juga harus membikin beberapa perubahan kecil di main. Method request_review dan approve mengembalikan instance baru alih-alih memodifikasi struct tempat mereka dipanggil, jadi kita perlu nambahin assignment shadowing let post = lagi buat nyimpen instance-instance baru yang dikembalikan itu. Kita juga tidak bisa lagi punya penegasan (assertions) soal postingan draft maupun postingan yang nunggu review punya konten string kosong, tapi kita juga tidak butuh penegasan itu lagi kok: kita emang udah tidak bisa lagi nge-compile kode yang mencoba buat memakai konten dari postingan yang ada di states tersebut. Kode yang udah di-update di main ini ditunjukin di Listing 18-21.

Filename: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");

    let post = post.request_review();

    let post = post.approve();

    assert_eq!("I ate a salad for lunch today", post.content());
}
Listing 18-21: Modifikasi ke main buat memakai implementasi yang baru dari workflow postingan blog kita

Perubahan-perubahan yang perlu kita lakuin di main buat me-reassign nilai ke post berarti kalau implementasi ini udah tidak bener-bener ngikutin state pattern ala object-oriented lagi: transisi-transisi antara states tersebut udah tidak lagi dienkapsulasi sepenuhnya di dalam implementasi Post. Namun, keuntungan yang kita dapat adalah bahwa invalid states sekarang jadi mustahil terjadi berkat sistem tipe dan type checking (pengecekan tipe) yang terjadi saat compile time! Ini memastikan kalau beberapa jenis bugs tertentu, seperti nge-display (nampilin) konten dari postingan yang belum di-publish, bakal ketahuan jauh-jauh sebelum kodenya berhasil masuk ke production.

Cobalah beberapa tugas yang disarankan di awal bagian ini pada crate blog setelah memakai desain yang ada di Listing 18-21 buat melihat apa pendapat kita soal desain dari versi kode yang ini. Perhatikan kalau beberapa dari tugas tersebut mungkin emang udah terselesaikan secara natural di desain yang ini.

Kita udah melihat kalau walaupun Rust itu mampu mengimplementasikan desain pola object-oriented, pola-pola lain, kayak nge-encode state ke dalam sistem tipe, juga tersedia dan bisa diimplementasikan di Rust. Pola-pola ini punya kumpulan trade-offs yang beda-beda. Walaupun kita mungkin udah familier sekali sama pola-pola object-oriented, memikirkan kembali (rethinking) masalahnya buat memanfaatkan fitur-fitur dari Rust bisa ngasih banyak keuntungan, seperti mencegah munculnya bugs tertentu saat compile time. Pola-pola object-oriented tidak bakal selalu jadi solusi yang terbaik di Rust karena adanya fitur-fitur tertentu, seperti ownership, yang mana memang tidak dipunyai oleh bahasa-bahasa pemrograman object-oriented lainnya.

Ringkasan

Terlepas dari apakah kita mikir kalau Rust itu adalah sebuah bahasa yang object-oriented atau bukan setelah baca bab ini, kita sekarang udah tahu kalau kita bisa memakai trait objects buat dapetin beberapa fitur ala object-oriented di Rust. Dynamic dispatch bisa ngasih kode kita sedikit keluwesan (flexibility) yang harus dibayar dengan sedikit pinalti di performa runtime. Kita bisa memakai keluwesan ini buat mengimplementasikan pola-pola object-oriented yang bisa ngebantu kode kita supaya lebih gampang dipelihara (maintainability). Rust juga punya fitur lain, kayak ownership, yang tidak dipunyai sama bahasa-bahasa object-oriented pada umumnya. Sebuah pola object-oriented tidak bakal selalu jadi cara yang paling oke buat memanfaatkan kekuatan dari Rust, tapi dia jelas adalah salah satu pilihan yang tersedia.

Berikutnya, kita bakal melihat patterns (pola), yang mana merupakan fitur Rust lainnya yang juga ngasih keluwesan tingkat tinggi. Kita udah ngelihat sedikit soal pola-pola ini di sepanjang buku, tapi kita belum ngelihat potensi penuh dari kemampuan mereka. Ayo gas!

Pola (Patterns) dan Pencocokan (Matching)

Patterns (pola) adalah sebuah sintaks spesial di Rust buat mencocokkan (matching) struktur dari berbagai tipe, baik yang kompleks maupun yang sederhana. Memakai patterns bersamaan dengan ekspresi match dan konstruk-konstruk lainnya ngasih kita kontrol yang lebih banyak terhadap control flow (alur kontrol) dari sebuah program. Sebuah pattern terdiri dari beberapa kombinasi dari hal-hal berikut ini:

  • Literals (nilai harfiah)
  • Array, enum, struct, atau tuple yang di-destructure (dipecah-pecah)
  • Variabel
  • Wildcards (kartu liar)
  • Placeholders (tempat pengganti)

Beberapa contoh patterns meliputi x, (a, 3), dan Some(Color::Red). Di dalam konteks di mana patterns itu valid, komponen-komponen ini mendeskripsikan bentuk (shape) dari suatu data. Program kita kemudian mencocokkan nilai dengan patterns tersebut buat menentukan apakah nilai tersebut punya bentuk data yang tepat buat melanjutkan eksekusi potongan kode tertentu.

Buat memakai sebuah pattern, kita membandingkannya dengan suatu nilai. Kalau pattern tersebut cocok dengan nilainya, kita memakai bagian-bagian dari nilai itu di dalam kode kita. Ingat kembali ekspresi match di Bab 6 yang memakai patterns, kayak di contoh mesin penyortir koin. Kalau nilainya cocok sama bentuk dari pattern-nya, kita bisa memakai potongan-potongan yang udah dikasih nama. Kalau tidak cocok, kode yang terkait sama pattern tersebut tidak bakal dijalankan.

Bab ini adalah sebuah referensi tentang semua hal yang berkaitan dengan patterns. Kita bakal membahas tempat-tempat valid di mana kita bisa memakai patterns, perbedaan antara refutable (bisa dibantah/bisa gagal) dan irrefutable (tidak bisa dibantah/pasti sukses) patterns, serta berbagai macam sintaks pattern yang mungkin bakal kita temui. Di akhir bab ini, kita bakal tahu gimana cara memakai patterns buat mengekspresikan banyak konsep dengan cara yang jelas.

Semua Tempat di Mana Patterns Bisa Dipakai

Semua Tempat di Mana Patterns Bisa Dipakai

Patterns muncul di berbagai tempat di Rust, dan kita sebenarnya udah sering sekali memakai mereka tanpa menyadarinya! Bagian ini membahas semua tempat di mana patterns itu valid untuk dipakai.

Lengan (Arms) dari match

Seperti yang sudah dibahas di Bab 6, kita memakai patterns di arms (lengan) dari ekspresi match. Secara formal, ekspresi match didefinisikan sebagai keyword match, sebuah nilai yang mau dicocokkan, dan satu atau lebih match arms yang terdiri dari sebuah pattern dan sebuah ekspresi buat dijalankan kalau nilainya cocok dengan pattern di arm tersebut, kayak gini:

match NILAI {
    PATTERN => EKSPRESI,
    PATTERN => EKSPRESI,
    PATTERN => EKSPRESI,
}

Misalnya, ini adalah ekspresi match dari Listing 6-5 yang mencocokkan sebuah nilai Option<i32> di dalam variabel x:

match x {
    None => None,
    Some(i) => Some(i + 1),
}

Patterns di ekspresi match ini adalah None dan Some(i) yang ada di sebelah kiri dari setiap tanda panah.

Satu persyaratan untuk ekspresi match adalah mereka harus bersifat exhaustive (menyeluruh/tuntas) yang berarti semua kemungkinan nilai buat ekspresi match tersebut harus ditangani (accounted for). Salah satu cara buat memastikan kita udah mencakup semua kemungkinannya adalah dengan memakai catch-all pattern (pola penangkap-semua) buat arm terakhirnya: misalnya, memakai nama variabel yang bakal cocok dengan nilai apa pun itu tidak bakal pernah gagal dan karena itu mencakup semua kasus yang tersisa.

Pattern spesifik _ bakal cocok dengan apa pun, tapi ia tidak pernah mengikat (bind) nilainya ke dalam sebuah variabel, jadi ia sering kali dipakai di match arm yang paling akhir. Pattern _ ini bisa berguna pas kita mau mengabaikan nilai apa pun yang tidak ditentukan (unspecified), sebagai contoh. Kita bakal membahas pattern _ lebih detail di “Mengabaikan Nilai di dalam sebuah Pattern” nanti di bab ini.

Statement let

Sebelum bab ini, kita cuma secara eksplisit ngebahas pemakaian patterns bersama match dan if let, tapi pada kenyataannya, kita udah memakai patterns di tempat lain juga, termasuk di dalam statement let. Misalnya, coba lihat pemberian nilai (variable assignment) langsung pakai let ini:

#![allow(unused)]
fn main() {
let x = 5;
}

Setiap kali kita memakai statement let kayak gini, kita sebenernya udah memakai patterns, biarpun kita mungkin tidak menyadarinya! Lebih formalnya, sebuah statement let itu kelihatannya kayak gini:

let PATTERN = EKSPRESI;

Di statement seperti let x = 5; di mana nama variabel ada di posisi PATTERN, nama variabel itu hanyalah bentuk yang paling sederhana dari sebuah pattern. Rust membandingkan ekspresi tersebut dengan pattern-nya lalu memberikan nilai (assigns) ke nama-nama yang ia temukan. Jadi, di contoh let x = 5;, x adalah sebuah pattern yang artinya “ikat (bind) apa pun yang cocok di sini ke variabel x.” Karena nama x adalah keseluruhan pattern-nya, pattern ini pada praktiknya berarti “ikat semuanya ke variabel x, apa pun nilainya.”

Buat ngelihat aspek pattern-matching (pencocokan pola) dari let dengan lebih jelas, coba lihat Listing 19-1, yang memakai sebuah pattern bareng let buat men-destructure (memecah) sebuah tuple.

fn main() {
    let (x, y, z) = (1, 2, 3);
}
Listing 19-1: Memakai pattern buat men-destructure sebuah tuple dan membikin tiga variabel sekaligus

Di sini, kita mencocokkan sebuah tuple terhadap sebuah pattern. Rust membandingkan nilai (1, 2, 3) dengan pattern (x, y, z) dan melihat kalau nilainya cocok dengan pattern tersebut, dalam arti dia melihat kalau jumlah elemennya sama di kedua sisinya, jadi Rust mengikat 1 ke x, 2 ke y, dan 3 ke z. Kita bisa membayangkan tuple pattern ini seolah-olah menyarangkan (nesting) tiga variable patterns individu di dalamnya.

Kalau jumlah elemen di dalam pattern-nya tidak cocok dengan jumlah elemen di dalam tuple-nya, tipe keseluruhannya tidak bakal cocok dan kita bakal dapat error compiler. Misalnya, Listing 19-2 menunjukkan sebuah percobaan buat men-destructure sebuah tuple yang berisi tiga elemen ke dalam dua variabel, yang mana tidak bakal jalan.

fn main() {
    let (x, y) = (1, 2, 3);
}
Listing 19-2: Membikin sebuah pattern yang salah di mana jumlah variabelnya tidak cocok dengan jumlah elemen di dalam tuple

Mencoba men-compile kode ini bakal menghasilkan type error (error tipe) ini:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error[E0308]: mismatched types
 --> src/main.rs:2:9
  |
2 |     let (x, y) = (1, 2, 3);
  |         ^^^^^^   --------- this expression has type `({integer}, {integer}, {integer})`
  |         |
  |         expected a tuple with 3 elements, found one with 2 elements
  |
  = note: expected tuple `({integer}, {integer}, {integer})`
             found tuple `(_, _)`

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

Buat memperbaiki error-nya, kita bisa mengabaikan satu atau lebih nilai di dalam tuple tersebut memakai _ atau .., kayak yang bakal kita lihat di bagian “Mengabaikan Nilai di dalam sebuah Pattern”. Kalau masalahnya adalah kita punya terlalu banyak variabel di dalam pattern-nya, solusinya adalah mencocokkan (match) tipe-tipenya dengan membuang variabel-variabel tersebut sehingga jumlah variabelnya sama dengan jumlah elemen di tuple-nya.

Ekspresi Bersyarat (Conditional) if let

Di Bab 6, kita ngebahas gimana cara memakai ekspresi if let yang mana utamanya dipakai sebagai cara yang lebih singkat buat menulis bentuk ekuivalen (setara) dari sebuah match yang cuma mencocokkan satu kasus aja. Secara opsional, if let bisa dipasangkan dengan else yang berisi kode buat dijalankan kalau pattern di dalam if let tersebut tidak cocok.

Listing 19-3 menunjukkan kalau kita juga bisa mencampur dan mencocokkan (mix and match) ekspresi if let, else if, dan else if let. Ngelakuin hal ini ngasih kita fleksibilitas yang lebih besar ketimbang ekspresi match di mana kita cuma bisa mengekspresikan satu nilai aja buat dibandingkan dengan semua patterns-nya. Selain itu, Rust tidak mewajibkan agar kondisi-kondisi di dalam rangkaian lengan (arms) if let, else if, dan else if let itu saling berhubungan satu sama lain.

Kode di Listing 19-3 menentukan warna apa yang bakal dijadikan warna background (latar belakang) berdasarkan serangkaian pengecekan pada beberapa kondisi. Untuk contoh ini, kita sudah membikin variabel-variabel dengan nilai yang di-hardcode (ditulis langsung) yang mana di program sungguhan mungkin aja nilai-nilai ini didapat dari input user.

Filename: src/main.rs
fn main() {
    let favorite_color: Option<&str> = None;
    let is_tuesday = false;
    let age: Result<u8, _> = "34".parse();

    if let Some(color) = favorite_color {
        println!("Using your favorite color, {color}, as the background");
    } else if is_tuesday {
        println!("Tuesday is green day!");
    } else if let Ok(age) = age {
        if age > 30 {
            println!("Using purple as the background color");
        } else {
            println!("Using orange as the background color");
        }
    } else {
        println!("Using blue as the background color");
    }
}
Listing 19-3: Mencampur if let, else if, else if let, dan else

Kalau user menentukan sebuah warna favorit, warna itu bakal dipakai sebagai background. Kalau tidak ada warna favorit yang ditentukan dan hari ini adalah hari Selasa (Tuesday), warna background-nya adalah hijau (green). Selain itu, kalau user menentukan umurnya sebagai sebuah string dan kita bisa mem-parse-nya jadi sebuah angka dengan sukses, warnanya bakal jadi ungu (purple) atau oranye (orange) tergantung dari nilai angkanya. Kalau tidak ada satu pun dari kondisi ini yang berlaku, warna background-nya adalah biru (blue).

Struktur kondisional (bersyarat) ini membiarkan kita mendukung persyaratan yang kompleks. Dengan nilai-nilai hardcoded yang kita punya di sini, contoh ini bakal mencetak Using purple as the background color.

Kita bisa melihat kalau if let juga bisa memperkenalkan variabel baru yang menimpa (shadow) variabel yang sudah ada dengan cara yang sama kayak yang dilakukan sama match arms: baris if let Ok(age) = age memperkenalkan sebuah variabel age baru yang berisi nilai di dalam varian Ok-nya, menimpa variabel age yang sudah ada sebelumnya. Ini artinya kita harus menaruh kondisi if age > 30 di dalam blok tersebut: kita tidak bisa menggabungkan kedua kondisi ini jadi if let Ok(age) = age && age > 30. Nilai age baru yang mau kita bandingkan dengan 30 belum valid sampai scope (ruang lingkup) barunya dimulai bersamaan dengan tanda kurung kurawal pembuka.

Kelemahan dari memakai ekspresi if let adalah kalau compiler tidak bakal mengecek kelengkapannya (exhaustiveness), sedangkan dengan ekspresi match compiler bakal mengeceknya. Kalau kita kelupaan menaruh blok else terakhir dan oleh karenanya kelewatan (missed) buat menangani beberapa kasus, compiler tidak bakal memperingatkan kita soal kemungkinan adanya logic bug (kutu logika) tersebut.

Perulangan Bersyarat while let

Mirip dengan konstruksi if let, perulangan (loop) bersyarat while let memungkinkan sebuah loop while buat terus berjalan selama sebuah pattern masih terus cocok. Di Listing 19-4 kita menunjukkan sebuah loop while let yang menunggu pesan-pesan yang dikirim antar threads, tapi di kasus ini dia mengecek sebuah Result ketimbang sebuah Option.

fn main() {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        for val in [1, 2, 3] {
            tx.send(val).unwrap();
        }
    });

    while let Ok(value) = rx.recv() {
        println!("{value}");
    }
}
Listing 19-4: Memakai loop while let buat mencetak nilai-nilai selama rx.recv() mengembalikan Ok

Contoh ini mencetak 1, 2, dan kemudian 3. Method recv mengambil pesan pertama dari sisi penerima (receiver side) saluran (channel) tersebut dan mengembalikan sebuah Ok(value). Saat pertama kali kita melihat recv di Bab 16, kita langsung meng-unwrap error-nya, atau berinteraksi dengannya layaknya sebuah iterator memakai sebuah loop for. Namun, seperti yang ditunjukkan Listing 19-4, kita juga bisa memakai while let, karena method recv mengembalikan sebuah Ok setiap kali ada pesan yang datang, selama si pengirimnya (sender) masih eksis, lalu mengembalikan sebuah Err begitu sisi pengirimnya memutuskan koneksi (disconnects).

Perulangan for

Di dalam loop for, nilai yang langsung mengikuti keyword for itu adalah sebuah pattern. Misalnya, di dalam for x in y, x itu adalah pattern-nya. Listing 19-5 mendemonstrasikan gimana cara memakai sebuah pattern di dalam loop for buat men-destructure (memecah belah) sebuah tuple sebagai bagian dari loop for tersebut.

fn main() {
    let v = vec!['a', 'b', 'c'];

    for (index, value) in v.iter().enumerate() {
        println!("{value} is at index {index}");
    }
}
Listing 19-5: Memakai sebuah pattern di dalam loop for buat men-destructure sebuah tuple

Kode di Listing 19-5 bakal mencetak yang berikut ini:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.52s
     Running `target/debug/patterns`
a is at index 0
b is at index 1
c is at index 2

Kita mengadaptasi sebuah iterator memakai method enumerate sehingga ia menghasilkan sebuah nilai beserta indeks buat nilai tersebut, yang ditaruh di dalam sebuah tuple. Nilai pertama yang dihasilkan adalah tuple (0, 'a'). Saat nilai ini dicocokkan dengan pattern (index, value), index bakal jadi 0 dan value bakal jadi 'a', lalu mencetak baris pertama dari outputnya.

Parameter Fungsi

Parameter dari sebuah fungsi juga bisa berupa patterns. Kode di Listing 19-6, yang mendeklarasikan fungsi bernama foo yang menerima satu parameter bernama x bertipe i32, harusnya sekarang udah terasa familier.

fn foo(x: i32) {
    // code goes here
}

fn main() {}
Listing 19-6: Sebuah signature fungsi yang memakai patterns di dalam parameternya

Bagian x itu adalah sebuah pattern lho! Sama kayak yang kita lakuin dengan let, kita bisa mencocokkan sebuah tuple di dalam argumen sebuah fungsi terhadap suatu pattern. Listing 19-7 memecah nilai-nilai di dalam sebuah tuple saat kita meneruskannya ke sebuah fungsi.

Filename: src/main.rs
fn print_coordinates(&(x, y): &(i32, i32)) {
    println!("Current location: ({x}, {y})");
}

fn main() {
    let point = (3, 5);
    print_coordinates(&point);
}
Listing 19-7: Sebuah fungsi dengan parameter yang men-destructure sebuah tuple

Kode ini mencetak Current location: (3, 5). Nilai-nilai &(3, 5) itu cocok dengan pattern &(x, y), jadi x adalah nilai 3 dan y adalah nilai 5.

Kita juga bisa memakai patterns di dalam daftar parameter closure dengan cara yang sama seperti di dalam daftar parameter fungsi, karena closures itu mirip dengan fungsi, kayak yang udah dibahas di Bab 13.

Pada titik ini, kita udah melihat beberapa cara buat memakai patterns, tapi patterns tidak bekerja dengan cara yang sama persis di setiap tempat di mana kita bisa memakai mereka. Di beberapa tempat, patterns itu wajib bersifat irrefutable (tidak bisa dibantah/pasti sukses); di situasi lain, mereka bisa bersifat refutable (bisa dibantah/bisa gagal). Kita bakal ngebahas kedua konsep ini selanjutnya.

Refutability (Keterbantahan): Apakah sebuah Pattern Bisa Gagal Cocok?

Refutability (Keterbantahan): Apakah sebuah Pattern Bisa Gagal Cocok?

Patterns datang dalam dua bentuk: refutable (bisa dibantah/bisa gagal) dan irrefutable (tidak bisa dibantah/pasti sukses). Patterns yang bakal selalu cocok dengan kemungkinan nilai apa pun yang diberikan ke dia itu disebut irrefutable. Contohnya adalah x di dalam statement let x = 5; karena x cocok dengan apa aja dan oleh karena itu tidak mungkin gagal buat cocok. Patterns yang bisa aja gagal buat cocok dengan beberapa kemungkinan nilai itu disebut refutable. Contohnya adalah Some(x) di dalam ekspresi if let Some(x) = a_value karena kalau nilai di variabel a_value ternyata adalah None bukannya Some, maka pattern Some(x) itu tidak bakal cocok.

Parameter fungsi, statement let, dan loop for cuma bisa nerima patterns yang irrefutable karena programnya tidak bisa ngelakuin hal berguna apa pun kalau nilainya ternyata tidak cocok. Ekspresi if let dan while let serta statement let...else bisa nerima patterns yang refutable maupun irrefutable, tapi compiler bakal ngasih peringatan (warning) kalau kita memakai patterns yang irrefutable di sana. Hal ini karena, secara definisi, konstruk-konstruk tersebut memang ditujukan buat menangani kemungkinan gagal: fungsionalitas dari sebuah kondisional ada pada kemampuannya buat melakukan hal yang berbeda-beda tergantung pada apakah dia sukses atau gagal.

Secara umum, kita tidak perlu pusing mikirin perbedaan antara patterns yang refutable dan irrefutable; namun, kita tetap harus familier dengan konsep refutability ini supaya kita tahu apa yang harus dilakukan pas kita ngelihat istilah ini di dalam sebuah pesan error. Di kasus-kasus seperti itu, kita perlu mengubah entah pattern-nya atau konstruk tempat kita memakai pattern tersebut, tergantung dari perilaku yang kita inginkan buat kodenya.

Mari kita lihat sebuah contoh tentang apa yang terjadi pas kita nyoba memakai sebuah pattern yang refutable di tempat di mana Rust mewajibkan pattern yang irrefutable, dan sebaliknya. Listing 19-8 menunjukkan sebuah statement let, tapi buat pattern-nya, kita menentukan Some(x), yang mana adalah sebuah pattern yang refutable. Seperti yang mungkin kita tebak, kode ini tidak bakal bisa di-compile.

fn main() {
    let some_option_value: Option<i32> = None;
    let Some(x) = some_option_value;
}
Listing 19-8: Mencoba memakai sebuah pattern refutable bersama let

Kalau some_option_value kebetulan bernilai None, maka dia bakal gagal buat cocok sama pattern Some(x), yang artinya pattern tersebut adalah refutable. Namun, statement let cuma bisa nerima pattern yang irrefutable karena tidak ada aksi valid yang bisa dilakuin sama kodenya dengan sebuah nilai None. Saat compile time, Rust bakal komplain kalau kita udah nyoba memakai sebuah pattern refutable di tempat di mana sebuah pattern irrefutable diwajibkan:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error[E0005]: refutable pattern in local binding
 --> src/main.rs:3:9
  |
3 |     let Some(x) = some_option_value;
  |         ^^^^^^^ pattern `None` not covered
  |
  = note: `let` bindings require an "irrefutable pattern", like a `struct` or an `enum` with only one variant
  = note: for more information, visit https://doc.rust-lang.org/book/ch19-02-refutability.html
  = note: the matched value is of type `Option<i32>`
help: you might want to use `let else` to handle the variant that isn't matched
  |
3 |     let Some(x) = some_option_value else { todo!() };
  |                                     ++++++++++++++++

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

Karena kita tidak mencakup (dan memang tidak bisa mencakup!) setiap kemungkinan nilai yang valid dengan pattern Some(x), Rust dengan tepat menghasilkan error compiler.

Kalau kita punya sebuah pattern refutable di tempat di mana pattern irrefutable dibutuhkan, kita bisa memperbaikinya dengan mengubah kode yang memakai pattern tersebut: ketimbang memakai let, kita bisa memakai let else. Dengan begitu, kalau pattern-nya tidak cocok, kodenya bakal sekadar melewati (skip) kode yang ada di dalam kurung kurawal, ngasih jalan (way out) buat dia lanjut dengan cara yang valid. Listing 19-9 menunjukkan cara memperbaiki kode di Listing 19-8.

fn main() {
    let some_option_value: Option<i32> = None;
    let Some(x) = some_option_value else {
        return;
    };
}
Listing 19-9: Memakai let...else dan sebuah blok dengan patterns refutable bukannya let

Kita udah ngasih jalan keluar buat kodenya! Kode ini benar-benar valid sekarang, meskipun ini berarti kita tidak bisa memakai sebuah pattern irrefutable tanpa menerima sebuah peringatan. Kalau kita ngasih let...else sebuah pattern yang bakal selalu cocok, kayak x, seperti yang ditunjukkan di Listing 19-10, compiler bakal ngasih sebuah peringatan.

fn main() {
    let x = 5 else {
        return;
    };
}
Listing 19-10: Mencoba memakai sebuah pattern irrefutable bersama let...else

Rust komplain kalau tidak masuk akal buat memakai let...else dengan sebuah pattern yang irrefutable:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
warning: irrefutable `let...else` pattern
 --> src/main.rs:2:5
  |
2 |     let x = 5 else {
  |     ^^^^^^^^^
  |
  = note: this pattern will always match, so the `else` clause is useless
  = help: consider removing the `else` clause
  = note: `#[warn(irrefutable_let_patterns)]` on by default

warning: `patterns` (bin "patterns") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.39s
     Running `target/debug/patterns`

Atas alasan ini, match arms wajib memakai patterns yang refutable, kecuali buat arm yang paling terakhir, yang seharusnya mencocokkan sisa nilai apa pun yang belum ditangani dengan sebuah pattern yang irrefutable. Rust mengizinkan kita buat memakai sebuah pattern irrefutable di dalam sebuah match yang cuma punya satu arm, tapi sintaks ini tidak terlalu berguna dan bisa aja diganti pakai sebuah statement let yang lebih sederhana.

Sekarang karena kita udah tahu di mana aja bisa memakai patterns dan perbedaan antara patterns refutable dan irrefutable, mari kita bahas semua sintaks yang bisa kita pakai buat bikin patterns.

Sintaks Pattern

Sintaks Pattern

Di bagian ini, kita mengumpulkan semua sintaks yang valid buat dipakai di dalam patterns dan ngebahas kenapa dan kapan kita mungkin mau memakai masing-masing dari sintaks tersebut.

Mencocokkan Literals (Nilai Harfiah)

Seperti yang udah kita lihat di Bab 6, kita bisa mencocokkan patterns secara langsung dengan literals. Kode berikut ini ngasih beberapa contoh:

fn main() {
    let x = 1;

    match x {
        1 => println!("one"),
        2 => println!("two"),
        3 => println!("three"),
        _ => println!("anything"),
    }
}

Kode ini mencetak one karena nilai di dalam x adalah 1. Sintaks ini berguna pas kita pengen kode kita mengambil suatu tindakan tertentu kalau dia dapat sebuah nilai konkret yang spesifik.

Mencocokkan Variabel Bernama (Named Variables)

Variabel bernama adalah patterns irrefutable yang bakal cocok dengan nilai apa pun, dan kita udah memakainya berkali-kali di buku ini. Namun, ada sedikit kerumitan pas kita memakai variabel bernama di dalam ekspresi match, if let, atau while let. Karena setiap jenis ekspresi ini memulai sebuah scope (ruang lingkup) baru, variabel yang dideklarasikan sebagai bagian dari sebuah pattern di dalam ekspresi ini bakal menimpa (shadow) variabel dengan nama yang sama yang ada di luar konstruk tersebut, sama halnya kayak yang terjadi pada semua variabel di Rust. Di Listing 19-11, kita mendeklarasikan sebuah variabel bernama x dengan nilai Some(5) dan sebuah variabel y dengan nilai 10. Terus kita membikin ekspresi match pada nilai x. Coba perhatikan patterns di dalam match arms-nya dan println! di akhir, dan cobalah buat menebak apa yang bakal dicetak oleh kode ini sebelum menjalankannya atau membaca lebih lanjut.

Filename: src/main.rs
fn main() {
    let x = Some(5);
    let y = 10;

    match x {
        Some(50) => println!("Got 50"),
        Some(y) => println!("Matched, y = {y}"),
        _ => println!("Default case, x = {x:?}"),
    }

    println!("at the end: x = {x:?}, y = {y}");
}
Listing 19-11: Sebuah ekspresi match dengan sebuah arm yang memperkenalkan variabel baru yang menimpa variabel y yang sudah ada

Mari kita telusuri apa yang terjadi pas ekspresi match ini dijalankan. Pattern di arm pertama tidak cocok dengan nilai yang udah didefinisikan buat x, jadi kodenya lanjut.

Pattern di arm kedua memperkenalkan sebuah variabel baru bernama y yang bakal cocok dengan nilai apa pun yang ada di dalam sebuah nilai Some. Karena kita sekarang ada di dalam scope baru di dalam ekspresi match ini, ini adalah variabel y yang baru, bukannya y yang kita deklarasikan di awal tadi yang nilainya 10. Binding y yang baru ini bakal cocok dengan nilai apa pun di dalam sebuah Some, yang mana itulah yang kita punya di dalam x. Oleh karena itu, si y baru ini mengikat (binds) dirinya ke nilai internal yang ada di dalam Some yang dimiliki x. Nilai itu adalah 5, jadi ekspresi buat arm tersebut dieksekusi dan mencetak Matched, y = 5.

Seandainya x tadi adalah sebuah nilai None bukannya Some(5), patterns di dua arm pertama tidak bakal ada yang cocok, jadi nilainya bakal cocok sama si garis bawah (underscore). Kita tidak memperkenalkan variabel x di dalam pattern buat arm garis bawah ini, jadi si x di ekspresi tersebut tetap merujuk pada x yang ada di luar yang belum tertimpa (unshadowed). Di kasus hipotetis (seandainya) ini, si match bakal mencetak Default case, x = None.

Begitu ekspresi match ini selesai, scope-nya berakhir, dan scope buat si y internal tadi juga berakhir. println! terakhir menghasilkan at the end: x = Some(5), y = 10.

Buat membikin ekspresi match yang bisa membandingkan nilai-nilai dari x dan y yang ada di luar, ketimbang memperkenalkan variabel baru yang malah menimpa variabel y yang sudah ada, kita wajib memakai kondisional match guard sebagai gantinya. Kita bakal ngebahas soal match guards nanti di “Kondisional Tambahan Memakai Match Guards”.

Multiple Patterns (Banyak Pola Sekaligus)

Di dalam ekspresi match, kita bisa mencocokkan banyak patterns sekaligus dengan memakai sintaks |, yang mana merupakan operator or (atau) buat pattern. Misalnya, di kode berikut ini kita mencocokkan nilai x terhadap match arms-nya, yang mana arm pertamanya punya sebuah opsi or, yang berarti kalau nilai dari x itu cocok sama salah satu dari nilai yang ada di arm itu, kode milik arm itu bakal dijalankan:

fn main() {
    let x = 1;

    match x {
        1 | 2 => println!("one or two"),
        3 => println!("three"),
        _ => println!("anything"),
    }
}

Kode ini mencetak one or two.

Mencocokkan Rentang (Ranges) Nilai Memakai ..=

Sintaks ..= memungkinkan kita buat mencocokkan dengan sebuah rentang nilai yang inklusif (inclusive range). Di kode berikut, saat sebuah pattern cocok sama nilai apa pun yang ada di dalam rentang yang ditentukan, arm tersebut bakal dieksekusi:

fn main() {
    let x = 5;

    match x {
        1..=5 => println!("one through five"),
        _ => println!("something else"),
    }
}

Kalau x itu adalah 1, 2, 3, 4, atau 5, arm pertama bakal cocok. Sintaks ini jauh lebih nyaman dipakai pas kita punya banyak nilai buat dicocokkan ketimbang memakai operator | buat mengekspresikan ide yang sama; kalau kita memakai |, kita harus menentukan 1 | 2 | 3 | 4 | 5. Menentukan sebuah rentang (range) itu jauh lebih singkat, apalagi kalau kita mau mencocokkan, katakanlah, angka apa aja antara 1 dan 1.000!

Compiler mengecek kalau rentang tersebut tidak kosong pas compile time, dan karena tipe-tipe yang mana Rust bisa tahu apakah sebuah rentang itu kosong atau tidak itu cuma char dan nilai-nilai numerik aja, maka dari itu rentang (ranges) cuma diizinkan buat nilai numerik atau char.

Berikut ini adalah contoh yang memakai rentang buat nilai char:

fn main() {
    let x = 'c';

    match x {
        'a'..='j' => println!("early ASCII letter"),
        'k'..='z' => println!("late ASCII letter"),
        _ => println!("something else"),
    }
}

Rust bisa tahu kalau 'c' berada di dalam rentang pattern pertama dan lalu mencetak early ASCII letter.

Destructuring (Membongkar) buat Memecah Nilai

Kita juga bisa memakai patterns buat men-destructure (membongkar/memecah belah) structs, enums, dan tuples supaya kita bisa memakai berbagai bagian-bagian yang berbeda dari nilai-nilai tersebut. Mari kita telusuri masing-masing dari mereka.

Destructuring Structs

Listing 19-12 menunjukkan sebuah struct Point yang punya dua fields (bidang), x dan y, yang bisa kita pecah memakai sebuah pattern dengan statement let.

Filename: src/main.rs
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    let Point { x: a, y: b } = p;
    assert_eq!(0, a);
    assert_eq!(7, b);
}
Listing 19-12: Membongkar field-field sebuah struct ke dalam variabel-variabel yang terpisah

Kode ini membikin variabel a dan b yang masing-masing cocok dengan nilai dari field x dan y pada struct p. Contoh ini nunjukin kalau nama variabel-variabel di dalam pattern-nya tidak harus sama dengan nama-nama field di dalam struct-nya. Namun, sudah menjadi hal yang umum buat menyamakan nama variabelnya dengan nama field-nya supaya lebih gampang buat diingat variabel mana yang berasal dari field mana. Karena penggunaan umum ini, dan karena nulis let Point { x: x, y: y } = p; itu berisi sangat banyak duplikasi, Rust punya bentuk singkat (shorthand) buat patterns yang mencocokkan field struct: kita cuma perlu menyebutkan nama field struct-nya aja, dan variabel yang dibikin dari pattern tersebut bakal punya nama yang sama. Listing 19-13 berperilaku persis sama kayak kode di Listing 19-12, tapi variabel yang dibikin di pattern let-nya itu bernama x dan y bukannya a dan b.

Filename: src/main.rs
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    let Point { x, y } = p;
    assert_eq!(0, x);
    assert_eq!(7, y);
}
Listing 19-13: Membongkar field struct memakai struct field shorthand (sintaks pendek field struct)

Kode ini membikin variabel x dan y yang cocok dengan field x dan y dari variabel p. Hasilnya adalah variabel x dan y itu sekarang berisi nilai-nilai yang ada dari struct p tersebut.

Kita juga bisa melakukan destructure memakai nilai literal (literal values) sebagai bagian dari pattern struct tersebut ketimbang membikin variabel buat semua field-nya. Melakukan hal ini memungkinkan kita buat ngetes (test) beberapa dari field tersebut terhadap suatu nilai tertentu sekaligus membikin variabel buat men-destructure field-field yang lain.

Di Listing 19-14, kita punya sebuah ekspresi match yang memisahkan nilai-nilai Point ke dalam tiga kasus: titik yang terletak persis di sumbu x (yang mana itu benar kalau y = 0), di sumbu y (x = 0), atau tidak di sumbu mana pun.

Filename: src/main.rs
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    match p {
        Point { x, y: 0 } => println!("On the x axis at {x}"),
        Point { x: 0, y } => println!("On the y axis at {y}"),
        Point { x, y } => {
            println!("On neither axis: ({x}, {y})");
        }
    }
}
Listing 19-14: Membongkar dan mencocokkan nilai literal di dalam satu pattern

Arm pertama bakal cocok sama titik apa pun yang terletak di sumbu x dengan menentukan bahwa field y itu bakal dianggap cocok kalau nilainya cocok dengan literal 0. Pattern-nya tetap membikin variabel x yang mana bisa kita pakai di kode buat arm ini.

Serupa dengan itu, arm kedua mencocokkan titik apa pun yang ada di sumbu y dengan menentukan bahwa field x itu bakal dianggap cocok kalau nilainya 0 dan membikin sebuah variabel y buat nilai dari field y tersebut. Arm ketiga sama sekali tidak menentukan literal apa pun, jadi ia cocok buat Point yang mana aja dan membikin variabel buat kedua field x dan y.

Di contoh ini, nilai p itu cocok dengan arm kedua berkat fakta kalau x berisi nilai 0, jadi kode ini bakal mencetak On the y axis at 7.

Ingat bahwa sebuah ekspresi match bakal berhenti ngecek lengan-lengan (arms) lain begitu dia nemuin pattern pertama yang cocok, jadi biarpun Point { x: 0, y: 0} itu ada di sumbu x sekaligus ada di sumbu y, kode ini cuma bakal mencetak On the x axis at 0.

Destructuring Enums

Kita udah sering membongkar (destructured) enums di buku ini (misalnya, di Listing 6-5 di Bab 6), tapi kita belum pernah secara eksplisit ngebahas kalau pattern yang dipakai buat membongkar sebuah enum itu berhubungan erat dengan gimana cara data yang tersimpan di dalam enum tersebut didefinisikan. Sebagai contoh, di Listing 19-15 kita memakai enum Message dari Listing 6-2 lalu kita menulis sebuah match yang punya patterns buat membongkar setiap nilai internalnya.

Filename: src/main.rs
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {
    let msg = Message::ChangeColor(0, 160, 255);

    match msg {
        Message::Quit => {
            println!("The Quit variant has no data to destructure.");
        }
        Message::Move { x, y } => {
            println!("Move in the x direction {x} and in the y direction {y}");
        }
        Message::Write(text) => {
            println!("Text message: {text}");
        }
        Message::ChangeColor(r, g, b) => {
            println!("Change color to red {r}, green {g}, and blue {b}");
        }
    }
}
Listing 19-15: Membongkar varian enum yang memegang berbagai macam nilai yang berbeda

Kode ini bakal mencetak Change color to red 0, green 160, and blue 255. Cobalah buat mengubah nilai dari msg buat melihat kode dari lengan-lengan yang lain itu jalan.

Buat varian enum yang tidak punya data apa pun, kayak Message::Quit, kita tidak bisa membongkar nilainya lebih jauh lagi. Kita cuma bisa mencocokkan terhadap literal nilai Message::Quit secara langsung, dan tidak ada variabel sama sekali di dalam pattern tersebut.

Buat varian enum yang bentuknya kayak struct (struct-like enum variants), seperti Message::Move, kita bisa memakai sebuah pattern yang mirip sama pattern yang kita tentukan buat mencocokkan structs. Setelah nama variannya, kita taruh kurung kurawal lalu kita sebutkan field-fieldnya beserta variabelnya supaya kita bisa memecah-mecah bagian-bagian itu buat dipakai di dalam kode untuk arm ini. Di sini kita memakai bentuk singkat (shorthand) sama seperti yang kita lakuin di Listing 19-13.

Buat varian enum yang bentuknya kayak tuple (tuple-like enum variants), kayak Message::Write yang menampung sebuah tuple dengan satu elemen dan Message::ChangeColor yang menampung sebuah tuple dengan tiga elemen, pattern-nya itu mirip sama pattern yang kita tentukan buat mencocokkan tuples. Jumlah variabel di dalam pattern-nya itu harus cocok dengan jumlah elemen di dalam varian yang lagi kita cocokkan tersebut.

Destructuring Structs dan Enums yang Bersarang (Nested)

Sejauh ini, contoh-contoh kita semuanya cuma mencocokkan structs atau enums dengan kedalaman satu level aja (one level deep), tapi pencocokan itu bisa juga lho dipakai buat item-item yang bersarang (nested items)! Misalnya, kita bisa merombak (refactor) kode yang ada di Listing 19-15 buat ngedukung warna RGB maupun HSV di dalam pesan ChangeColor, kayak yang ditunjukin di Listing 19-16.

enum Color {
    Rgb(i32, i32, i32),
    Hsv(i32, i32, i32),
}

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(Color),
}

fn main() {
    let msg = Message::ChangeColor(Color::Hsv(0, 160, 255));

    match msg {
        Message::ChangeColor(Color::Rgb(r, g, b)) => {
            println!("Change color to red {r}, green {g}, and blue {b}");
        }
        Message::ChangeColor(Color::Hsv(h, s, v)) => {
            println!("Change color to hue {h}, saturation {s}, value {v}");
        }
        _ => (),
    }
}
Listing 19-16: Pencocokan pada enums yang bersarang (nested enums)

Pattern dari arm pertama di ekspresi match itu cocok dengan varian enum Message::ChangeColor yang mengandung varian Color::Rgb; dan kemudian pattern itu bakal mengikat dirinya (binds) ke ketiga nilai i32 internal tersebut. Pattern di arm kedua juga cocok dengan varian enum Message::ChangeColor, tapi enum internalnya itu cocok dengan Color::Hsv sebagai gantinya. Kita bisa menentukan kondisi-kondisi kompleks (complex conditions) ini semuanya di dalam satu ekspresi match tunggal, meskipun ada dua enum yang dilibatkan.

Destructuring Structs dan Tuples Bersamaan

Kita bisa nyampur (mix), mencocokkan (match), dan menyarangkan (nest) destructuring patterns dengan cara yang bahkan lebih kompleks lagi. Contoh berikut nunjukin proses destructure yang lumayan rumit di mana kita menyarangkan structs dan tuples di dalam sebuah tuple lalu kita mengekstrak (destructure) semua nilai primitifnya sampai keluar (out):

fn main() {
    struct Point {
        x: i32,
        y: i32,
    }

    let ((feet, inches), Point { x, y }) = ((3, 10), Point { x: 3, y: -10 });
}

Kode ini membiarkan kita memecah tipe-tipe yang kompleks menjadi bagian-bagian komponen dasarnya (component parts) supaya kita bisa memakai nilai-nilai yang kita butuhkan secara terpisah.

Destructuring memakai patterns ini adalah cara yang nyaman sekali buat memakai potongan-potongan dari sebuah nilai, kayak misalnya nilai yang ada dari masing- masing field di dalam sebuah struct, secara terpisah dari satu sama lain.

Mengabaikan Nilai di dalam sebuah Pattern

kita udah melihat kalau kadang-kadang itu sangat berguna buat mengabaikan nilai- nilai di dalam sebuah pattern, kayak misalnya di arm terakhir dari sebuah match, buat ngedapetin catch-all yang tidak benar-benar ngelakuin apa-apa tapi tetep mempertimbangkan (account for) semua kemungkinan nilai yang tersisa. Ada beberapa cara buat mengabaikan seluruh nilai atau sebagian aja dari suatu nilai di dalam sebuah pattern: dengan memakai pattern _ (yang udah kita lihat), dengan memakai pattern _ di dalam pattern lainnya, dengan memakai nama yang diawali dengan garis bawah (underscore), atau dengan memakai .. buat mengabaikan semua sisa bagian dari sebuah nilai. Mari kita eksplorasi gimana dan kapan alasan yang tepat buat memakai masing-masing patterns ini.

Mengabaikan Seluruh Nilai Memakai _

Kita udah memakai garis bawah (underscore) sebagai wildcard pattern (pola yang bisa jadi apa saja) yang bakal cocok dengan nilai apa pun tapi tidak bakal mengikat dirinya (bind) ke nilai tersebut. Ini sangat berguna dipakai sebagai arm terakhir di dalam ekspresi match, tapi kita juga bisa memakainya di pattern mana aja lho, termasuk di parameter fungsi, kayak yang ditunjukin di Listing 19-17.

Filename: src/main.rs
fn foo(_: i32, y: i32) {
    println!("This code only uses the y parameter: {y}");
}

fn main() {
    foo(3, 4);
}
Listing 19-17: Memakai _ di dalam signature sebuah fungsi

Kode ini bakal bener-bener mengabaikan nilai 3 yang dioper sebagai argumen pertama, dan dia bakal mencetak This code only uses the y parameter: 4.

Di mayoritas kasus saat kita udah tidak butuh suatu parameter fungsi tertentu lagi, seharusnya kita sekalian aja ngubah signature-nya supaya dia tidak nyertain parameter yang tidak terpakai itu. Mengabaikan sebuah parameter fungsi bisa sangat sangat berguna di kasus-kasus di mana, misalnya, kita lagi mengimplementasikan sebuah trait saat kita diwajibkan buat punya type signature yang spesifik tapi isi fungsinya (function body) di implementasi kita sama sekali tidak butuh salah satu parameternya. Dengan melakukan itu kita bisa terhindar dari dapat peringatan (warning) compiler tentang parameter fungsi yang tidak terpakai (unused function parameters), yang mana peringatan itu bakal muncul kalau seandainya kita malah memakai sebuah nama.

Mengabaikan Bagian-bagian dari Sebuah Nilai Memakai _ yang Bersarang (Nested)

Kita juga bisa memakai _ di dalam pattern lain buat cuma ngabaiin sebagian dari sebuah nilai, misalnya, pas kita cuma pengen ngetes sebagian dari sebuah nilai tapi tidak punya alasan apa pun buat memakai bagian-bagian lainnya di dalam kode korespondensinya yang mau kita jalanin. Listing 19-18 nunjukin kode yang bertanggung jawab buat mengelola nilai dari suatu pengaturan (setting). Persyaratan bisnisnya adalah user tidak boleh ngubah (overwrite) penyesuaian (customization) yang udah ada pada suatu setting, tapi user boleh aja menghapus (unset) setting tersebut lalu ngasih dia nilai baru kalau setting tersebut saat itu lagi kosong (unset).

fn main() {
    let mut setting_value = Some(5);
    let new_setting_value = Some(10);

    match (setting_value, new_setting_value) {
        (Some(_), Some(_)) => {
            println!("Can't overwrite an existing customized value");
        }
        _ => {
            setting_value = new_setting_value;
        }
    }

    println!("setting is {setting_value:?}");
}
Listing 19-18: Memakai garis bawah di dalam patterns yang nyocokin varian Some pas kita tidak perlu buat memakai nilai di dalam Some tersebut

Kode ini bakal mencetak Can't overwrite an existing customized value dan terus setting is Some(5). Di match arm yang pertama, kita tidak perlu mencocokkan atau memakai nilai-nilai yang ada di dalam masing-masing varian Some, tapi kita benar-benar perlu buat ngetes kasus di mana setting_value dan new_setting_value dua-duanya adalah varian Some. Di kasus itu, kita mencetak alasan kenapa kita tidak mengubah setting_value, dan nilainya emang tidak jadi diubah.

Di semua kasus lainnya (kalau entah setting_value atau new_setting_value itu None) yang diekspresikan sama pattern _ di arm yang kedua, kita pengen ngebolehin new_setting_value buat ngegantiin (become) setting_value.

Kita juga bisa memakai garis bawah di banyak tempat di dalam satu pattern tunggal buat ngabaiin beberapa nilai tertentu. Listing 19-19 nunjukin sebuah contoh gimana caranya ngabaiin nilai kedua dan keempat di dalam sebuah tuple yang isinya ada lima item.

fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (first, _, third, _, fifth) => {
            println!("Some numbers: {first}, {third}, {fifth}");
        }
    }
}
Listing 19-19: Mengabaikan beberapa bagian dari sebuah tuple

Kode ini bakal mencetak Some numbers: 2, 8, 32, dan nilai 4 dan 16 bakal diabaikan.

Mengabaikan Variabel yang Tidak Terpakai dengan Mengawali Namanya Memakai _

Kalau kita membikin sebuah variabel tapi tidak pernah memakannya di mana pun, Rust biasanya bakal mengeluarkan peringatan (warning) karena variabel yang tidak terpakai itu bisa aja merupakan sebuah bug. Namun, kadang-kadang itu kepake sekali buat bisa bikin sebuah variabel yang belum mau kita pakai sekarang, kayak pas kita lagi nge-prototipe (prototyping) atau pas lagi memulai sebuah project. Di situasi ini, kita bisa ngasih tahu Rust supaya tidak ngasih peringatan ke kita soal variabel yang tidak terpakai dengan ngawalin nama variabelnya pakai sebuah garis bawah (underscore). Di Listing 19-20, kita bikin dua variabel yang tidak dipakai, tapi pas kita mengompilasi kode ini, kita harusnya cuma bakal dapat satu peringatan aja tentang salah satunya.

Filename: src/main.rs
fn main() {
    let _x = 5;
    let y = 10;
}
Listing 19-20: Mengawali nama variabel dengan garis bawah buat ngehindarin peringatan variabel yang tidak terpakai

Di sini, kita dapat satu peringatan karena tidak memakai variabel y, tapi kita tidak dapat peringatan apa pun soal tidak memakai _x.

Perhatikan bahwa ada sedikit perbedaan yang halus (subtle difference) antara memakai _ sendirian dan memakai nama yang diawali dengan sebuah garis bawah. Sintaks _x itu tetap mengikat (binds) nilainya ke variabel tersebut, sementara _ tidak mengikat nilai itu sama sekali. Buat nunjukin di mana letak kasus yang mana perbedaan ini jadi penting, Listing 19-21 bakal ngasih kita sebuah error.

fn main() {
    let s = Some(String::from("Hello!"));

    if let Some(_s) = s {
        println!("found a string");
    }

    println!("{s:?}");
}
Listing 19-21: Sebuah variabel yang tidak terpakai dan diawali dengan sebuah garis bawah itu tetap mengikat nilainya, yang mana bisa aja ngambil kepemilikan (ownership) dari nilai tersebut.

Kita bakal nerima sebuah error karena nilai s tersebut bakal tetep aja di-move (dipindahkan) ke dalam _s, yang mana mencegah kita dari memakai s lagi setelah itu. Namun, memakai garis bawah itu sendiri aja tidak pernah mengikat (bind) ke nilainya. Listing 19-22 bakal berhasil di-compile tanpa error apa pun karena s tidak dipindahkan (moved) ke dalam _.

fn main() {
    let s = Some(String::from("Hello!"));

    if let Some(_) = s {
        println!("found a string");
    }

    println!("{s:?}");
}
Listing 19-22: Memakai sebuah garis bawah aja tidak bakal mengikat nilainya.

Kode ini bisa jalan dengan lancar karena kita bener-bener tidak pernah mengikat s ke mana pun; dia tidak dipindahkan.

Mengabaikan Sisa Bagian dari Sebuah Nilai Memakai ..

Buat nilai-nilai yang punya banyak bagian, kita bisa memakai sintaks .. buat cuma memakai bagian-bagian yang spesifik aja lalu ngabaiin sisanya, sehingga kita bisa menghindari nulisin garis bawah buat setiap nilai yang mau kita abaikan. Pattern .. mengabaikan bagian mana pun dari sebuah nilai yang belum kita cocokkan (matched) secara eksplisit di sisa pattern tersebut. Di Listing 19-23, kita punya struct Point yang memegang titik koordinat di ruang tiga dimensi (three-dimensional space). Di dalam ekspresi match, kita cuma mau beroperasi pada koordinat x doang dan ngabaiin nilai-nilai yang ada di field y dan z.

fn main() {
    struct Point {
        x: i32,
        y: i32,
        z: i32,
    }

    let origin = Point { x: 0, y: 0, z: 0 };

    match origin {
        Point { x, .. } => println!("x is {x}"),
    }
}
Listing 19-23: Mengabaikan semua field dari Point kecuali buat x dengan memakai ..

Kita nyebutin nilai x lalu setelah itu kita cuma masukin pattern .. aja. Ini jauh lebih cepet daripada kita harus nulisin y: _ dan z: _, terutama pas kita lagi ngerjain structs yang punya sangat banyak field di mana cuma ada satu atau dua field doang yang relevan buat kita.

Sintaks .. ini bakal melebar (expand) ke seberapa banyak pun nilai yang dibutuhin. Listing 19-24 nunjukin gimana cara memakai .. bareng sebuah tuple.

Filename: src/main.rs
fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (first, .., last) => {
            println!("Some numbers: {first}, {last}");
        }
    }
}
Listing 19-24: Cuma mencocokkan nilai yang pertama dan yang terakhir dari sebuah tuple dan mengabaikan semua nilai-nilai lainnya

Di kode ini, nilai yang pertama dan yang terakhir bakal dicocokkan dengan first dan last. .. itu bakal nyocokin dan lalu ngabaiin semua hal yang ada di tengah-tengah mereka berdua.

Namun, pemakaian .. itu harus tidak ambigu (unambiguous). Kalau sampai tidak jelas nilai-nilai yang mana aja yang ditujukan buat dicocokin dan nilai yang mana yang seharusnya diabaikan, Rust bakal ngasih kita sebuah error. Listing 19-25 nunjukin sebuah contoh dari pemakaian .. secara ambigu, jadi dia tidak bakal bisa di-compile.

Filename: src/main.rs
fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (.., second, ..) => {
            println!("Some numbers: {second}")
        },
    }
}
Listing 19-25: Sebuah percobaan buat memakai .. dengan cara yang ambigu

Pas kita men-compile contoh ini, kita dapet error ini:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error: `..` can only be used once per tuple pattern
 --> src/main.rs:5:22
  |
5 |         (.., second, ..) => {
  |          --          ^^ can only be used once per tuple pattern
  |          |
  |          previously used here

error: could not compile `patterns` (bin "patterns") due to 1 previous error

Mustahil bagi Rust buat nentuin seberapa banyak nilai di dalam tuple tersebut yang harus diabaikan sebelum dia mencocokkan sebuah nilai dengan second lalu berapa banyak lagi nilai selanjutnya yang harus diabaikan setelahnya. Kode ini bisa aja berarti kalau kita mau ngabaiin 2, mengikat second ke 4, dan terus ngabaiin 8, 16, dan 32; atau bisa juga berarti kalau kita mau ngabaiin 2 dan 4, mengikat second ke 8, dan lalu ngabaiin 16 dan 32; dan seterusnya (and so forth). Nama variabel second itu tidak punya arti khusus apa-apa buat Rust, jadi kita dapet error compiler karena memakai .. di dua tempat kayak gini itu ambigu.

Kondisional Tambahan Memakai Match Guards

Sebuah match guard adalah tambahan kondisi if, yang ditentukan setelah pattern di dalam sebuah lengan (arm) match, yang wajib dicocokkan juga supaya arm tersebut bisa dipilih. Match guards ini berguna buat mengekspresikan ide-ide yang lebih kompleks daripada apa yang bisa diizinkan sama sebuah pattern sendirian. Namun, perhatikan bahwa mereka itu cuma tersedia di dalam ekspresi match, bukan di ekspresi if let atau while let.

Kondisi tersebut bisa memakai variabel yang dibikin di dalam pattern-nya. Listing 19-26 nunjukin sebuah match di mana arm pertamanya punya pattern Some(x) dan juga punya sebuah match guard if x % 2 == 0 (yang bakal jadi true kalau angkanya itu bilangan genap).

fn main() {
    let num = Some(4);

    match num {
        Some(x) if x % 2 == 0 => println!("The number {x} is even"),
        Some(x) => println!("The number {x} is odd"),
        None => (),
    }
}
Listing 19-26: Nambahin sebuah match guard ke dalam sebuah pattern

Contoh ini bakal mencetak The number 4 is even. Pas num dibandingkan dengan pattern di arm yang pertama, dia cocok karena Some(4) cocok dengan Some(x). Terus si match guard mengecek apakah sisa dari ngebagi x dengan 2 itu sama dengan 0, dan karena emang bener, arm yang pertama itu pun dipilih.

Kalau seandainya num tadi adalah Some(5), si match guard di arm pertama bakal jadi false karena sisa pembagian 5 dengan 2 itu adalah 1, yang mana tidak sama dengan 0. Rust terus bakal lanjut ke arm yang kedua, yang mana bakal cocok karena arm yang kedua itu tidak punya match guard dan makanya dia cocok buat varian Some yang mana aja.

Tidak ada cara buat mengekspresikan kondisi if x % 2 == 0 di dalam sebuah pattern secara langsung, jadi match guard ngasih kita kemampuan buat bisa mengekspresikan logika ini. Kekurangan dari penambahan fungsionalitas ekspresi (expressiveness) ekstra ini adalah kalau compiler tidak bakal mencoba buat mengecek kelengkapannya (exhaustiveness) saat ada ekspresi-ekspresi match guard yang dilibatkan.

Di Listing 19-11, kita sempat menyebutkan kalau kita bisa memakai match guards buat nyelesein masalah tertimpanya variabel (pattern-shadowing problem) yang kita alami. Ingat kembali kalau waktu itu kita membikin sebuah variabel baru di dalam pattern di ekspresi match-nya ketimbang memakai variabel yang udah ada di luar match. Variabel yang baru itu berarti kalau kita tidak bisa lagi melakukan pengetesan terhadap (test against) nilai dari variabel yang ada di luar itu. Listing 19-27 nunjukin gimana caranya kita bisa memakai sebuah match guard buat memperbaiki masalah ini.

Filename: src/main.rs
fn main() {
    let x = Some(5);
    let y = 10;

    match x {
        Some(50) => println!("Got 50"),
        Some(n) if n == y => println!("Matched, n = {n}"),
        _ => println!("Default case, x = {x:?}"),
    }

    println!("at the end: x = {x:?}, y = {y}");
}
Listing 19-27: Memakai match guard buat ngetes kesamaan (equality) dengan sebuah variabel di luar

Kode ini sekarang bakal mencetak Default case, x = Some(5). Pattern di arm match yang kedua tidak memperkenalkan sebuah variabel baru y yang mana bakal menimpa y yang ada di luar, yang berarti kita bisa memakai y luar tersebut di dalam match guard-nya. Ketimbang menentukan pattern-nya sebagai Some(y), yang mana bakal menimpa y luar, kita menentukannya sebagai Some(n). Ini membikin sebuah variabel baru n yang tidak menimpa apa pun karena tidak ada variabel n di luar match.

Match guard if n == y itu bukanlah sebuah pattern dan makanya dia tidak memperkenalkan variabel baru apa pun. y ini adalah si y luar ketimbang sebuah y baru yang menimpanya, dan kita bisa nyari-nyari sebuah nilai yang punya nilai yang sama kayak si y luar tersebut dengan membandingkan n ke y.

Kita juga bisa memakai operator or | di dalam sebuah match guard buat menentukan lebih dari satu patterns; di mana kondisi dari match guard tersebut bakal berlaku buat kesemua patterns-nya. Listing 19-28 nunjukin hierarki (precedence) saat ngegabungin sebuah pattern yang memakai | dengan sebuah match guard. Bagian yang penting dari contoh ini adalah bahwa si match guard if y ini berlaku buat 4, 5, dan 6, biarpun dia mungkin kelihatannya kayak si if y ini cuma berlaku buat 6 doang.

fn main() {
    let x = 4;
    let y = false;

    match x {
        4 | 5 | 6 if y => println!("yes"),
        _ => println!("no"),
    }
}
Listing 19-28: Menggabungkan banyak patterns sekaligus dengan sebuah match guard

Kondisi match itu menyatakan kalau arm tersebut cuma cocok kalau nilainya x itu sama dengan 4, 5, atau 6 dan kalau y itu true. Pas kode ini jalan, pattern dari arm pertama cocok karena x adalah 4, tapi si match guard if y itu false, jadi arm pertama tersebut tidak dipilih. Kodenya lanjut ke arm yang kedua, yang mana emang cocok, dan program ini mencetak no. Alasannya adalah karena kondisi if tersebut berlaku buat keseluruhan pattern 4 | 5 | 6, bukan cuma buat nilai terakhir 6 aja. Dengan kata lain, hierarki dari sebuah match guard sehubungan dengan (in relation to) sebuah pattern itu berperilaku kayak gini:

(4 | 5 | 6) if y => ...

bukannya kayak gini:

4 | 5 | (6 if y) => ...

Setelah menjalankan kodenya, perilaku hierarki ini terlihat dengan jelas: kalau match guard tersebut cuma diterapin ke nilai terakhir di daftar nilai yang ditentukan memakai operator |, arm tersebut pasti udah cocok dan programnya pasti bakal mencetak yes.

Binding Memakai @

Operator at @ membiarkan kita membikin sebuah variabel yang nampung sebuah nilai di waktu yang bersamaan dengan kita melakukan tes apakah nilai tersebut cocok sama suatu pattern (pattern match) atau tidak. Di Listing 19-29, kita mau ngetes apakah field id di sebuah Message::Hello itu ada di dalam rentang 3..=7. Kita juga mau mengikat (bind) nilai tersebut ke variabel id supaya kita bisa memakainya di dalam kode yang diasosiasikan (associated) sama arm tersebut.

fn main() {
    enum Message {
        Hello { id: i32 },
    }

    let msg = Message::Hello { id: 5 };

    match msg {
        Message::Hello { id: id @ 3..=7 } => {
            println!("Found an id in range: {id}")
        }
        Message::Hello { id: 10..=12 } => {
            println!("Found an id in another range")
        }
        Message::Hello { id } => println!("Found some other id: {id}"),
    }
}
Listing 19-29: Memakai @ buat nge-bind ke suatu nilai di dalam sebuah pattern sembari juga ngetes (testing) nilai tersebut

Contoh ini bakal mencetak Found an id in range: 5. Dengan menspesifikasikan id @ sebelum rentang 3..=7, kita lagi menangkap nilai apa pun yang sekiranya cocok sama rentang itu lalu menaruhnya ke dalam sebuah variabel bernama id, dan sembari ngelakuin itu kita juga ngetes kalau nilai tersebut cocok sama pattern rentang (range pattern) tersebut.

Di arm yang kedua, di mana kita cuma menentukan sebuah rentang doang di dalam pattern-nya, kode yang terkait sama arm tersebut tidak punya variabel yang berisi nilai asli dari field id itu. Nilai dari field id itu bisa jadi adalah 10, 11, atau 12, tapi kode yang ada di arm pattern tersebut sama sekali tidak tahu nilainya yang mana. Kode di pattern tersebut tidak mampu buat memakai nilai dari field id, karena kita belum nyimpan nilai id tersebut di sebuah variabel.

Di arm terakhir, di mana kita udah menyebutkan sebuah variabel tanpa adanya rentang, kita bener-bener punya akses ke nilai yang bisa kita pakai di dalam kode untuk arm tersebut di sebuah variabel bernama id. Alasannya adalah karena kita udah memakai sintaks shorthand field struct. Tapi kita belum menerapkan tes (test) apa-apa ke dalam nilai di field id ini buat arm yang ini, seperti yang udah kita lakuin di dua arm pertama: nilai apa pun bakal cocok dengan pattern ini.

Memakai @ ngasih kita kemampuan buat ngetes sebuah nilai dan kemudian menyimpannya ke dalam sebuah variabel dalam satu pattern tunggal.

Ringkasan

Patterns di Rust itu sangat sangat berguna dalam membedakan (distinguishing) berbagai macam jenis data yang berbeda. Pas mereka dipakai di dalam ekspresi match, Rust memastikan kalau patterns kita udah mencakup semua kemungkinan nilai yang ada, karena kalau tidak program kita tidak bakal bisa di-compile. Patterns yang ada di dalam statement let dan parameter fungsi ngebikin konstruk-konstruk tersebut jadi jauh lebih berguna, memungkinkan proses memecah (destructuring) sebuah nilai jadi bagian-bagian yang lebih kecil dan meng-assign (ngasih nilai ke) bagian-bagian itu ke dalam berbagai variabel. Kita bisa bikin patterns yang simpel maupun yang kompleks buat menyesuaikan dengan apa yang kita butuhkan.

Berikutnya, untuk bab kedua dari akhir buku ini, kita bakal melihat beberapa aspek tingkat mahir (advanced aspects) dari berbagai macam fitur yang ada di Rust.

Fitur-fitur Tingkat Lanjut (Advanced Features)

Sekarang, kita udah mempelajari bagian-bagian yang paling sering dipakai di bahasa pemrograman Rust. Sebelum kita mengerjakan satu project lagi di Bab 21, kita bakal melihat beberapa aspek dari bahasa ini yang mungkin bakal kita temui sekali-sekali, biarpun kita mungkin tidak memakainya setiap hari. Kita bisa memakai bab ini sebagai referensi saat kita ketemu hal-hal yang belum kita ketahui. Fitur-fitur yang dibahas di sini sangat berguna buat situasi-situasi yang sangat spesifik. Walaupun kita mungkin jarang memakainya, kita pengen memastikan kita punya pemahaman soal semua fitur yang ditawarkan oleh Rust.

Di bab ini, kita bakal membahas:

  • Unsafe Rust: gimana cara keluar (opt out) dari beberapa jaminan yang dikasih Rust dan mengambil tanggung jawab buat menjunjung tinggi jaminan-jaminan itu secara manual
  • Advanced traits: associated types, default type parameters, fully qualified syntax, supertraits, dan newtype pattern (pola tipe baru) sehubungan dengan traits
  • Advanced types: lebih banyak lagi soal newtype pattern, type aliases (alias tipe), tipe never (tipe tak pernah), dan dynamically sized types (tipe-tipe yang berukuran dinamis)
  • Advanced functions dan closures: function pointers (pointer fungsi) dan mengembalikan closures
  • Macros: berbagai cara buat mendefinisikan kode yang membikin lebih banyak kode lagi saat compile time

Ini adalah sekumpulan fitur Rust yang punya sesuatu buat semua orang! Mari kita selami!

Unsafe Rust

Unsafe Rust

Semua kode yang udah kita bahas sejauh ini punya jaminan keamanan memori (memory safety guarantees) yang ditegakkan oleh Rust saat compile time. Namun, Rust punya sebuah bahasa kedua yang tersembunyi di dalamnya yang tidak menegakkan jaminan keamanan memori ini: namanya unsafe Rust dan dia bekerja persis kayak Rust biasa, tapi ngasih kita kekuatan super (superpowers) tambahan.

Unsafe Rust eksis karena, secara natur, analisis statis itu sifatnya konservatif. Saat compiler mencoba buat menentukan apakah sebuah kode mematuhi jaminan keamanannya atau tidak, jauh lebih baik baginya buat menolak beberapa program yang sebenarnya valid ketimbang menerima beberapa program yang ternyata tidak valid. Walaupun kodenya mungkin baik-baik aja, kalau compiler Rust tidak punya informasi yang cukup buat merasa yakin, dia bakal menolak kode tersebut. Di kasus-kasus seperti ini, kita bisa memakai kode unsafe buat ngasih tahu compiler, “Percaya deh, aku tahu apa yang lagi aku lakuin.” Namun, peringatan: kita memakai unsafe Rust dengan risiko kita sendiri: kalau kita memakai kode unsafe dengan tidak benar, masalah-masalah bisa bermunculan akibat tidak amannya memori, seperti proses dereferencing pada null pointer.

Alasan lain kenapa Rust punya sebuah alter ego yang unsafe adalah karena hardware komputer yang mendasarinya (underlying computer hardware) itu sifatnya memang tidak aman (inherently unsafe). Kalau Rust tidak membiarkan kita melakukan operasi-operasi yang tidak aman, kita tidak bakal bisa mengerjakan tugas-tugas tertentu. Rust perlu mengizinkan kita melakukan pemrograman sistem tingkat rendah (low-level systems programming), kayak berinteraksi secara langsung sama sistem operasi atau bahkan menulis sistem operasi kita sendiri. Bekerja dengan pemrograman sistem tingkat rendah adalah salah satu tujuan dari bahasa ini. Mari kita eksplorasi apa aja yang bisa kita lakukan dengan unsafe Rust dan gimana cara melakukannya.

Unsafe Superpowers

Buat beralih ke unsafe Rust, gunakan keyword unsafe lalu mulai sebuah blok baru buat menampung kode unsafe tersebut. Ada lima hal yang bisa kita lakukan di unsafe Rust yang tidak bisa kita lakukan di Rust biasa (safe Rust), yang mana kita sebut sebagai unsafe superpowers. Kekuatan super tersebut meliputi kemampuan buat:

  1. Men-dereferensi sebuah raw pointer (pointer mentah)
  2. Memanggil fungsi atau method unsafe
  3. Mengakses atau memodifikasi variabel static yang mutable
  4. Mengimplementasikan trait unsafe
  5. Mengakses field dari sebuah union

Penting sekali buat dipahami kalau unsafe tidak mematikan borrow checker atau menonaktifkan (disable) pengecekan keamanan Rust lainnya: kalau kita memakai sebuah referensi di dalam kode unsafe, dia bakal tetap dicek. Keyword unsafe cuma ngasih kita akses ke lima fitur ini yang kemudian tidak bakal dicek sama compiler buat keamanan memori. Kita bakal tetap dapat tingkat keamanan tertentu di dalam sebuah blok unsafe.

Selain itu, unsafe tidak berarti kalau kode di dalam blok tersebut pasti berbahaya atau bahwa dia pasti bakal punya masalah keamanan memori: maksud utamanya adalah sebagai programmer, Andalah yang bakal memastikan kalau kode di dalam blok unsafe tersebut bakal mengakses memori dengan cara yang valid.

Manusia itu bisa aja salah dan kesalahan (mistakes) emang bakal terjadi, tapi dengan mewajibkan kelima operasi unsafe ini buat berada di dalam blok-blok yang dianotasi dengan unsafe, kita bakal tahu kalau error apa pun yang berkaitan dengan keamanan memori itu pasti ada di dalam sebuah blok unsafe. Usahakan supaya blok unsafe itu kecil; kita bakal bersyukur nanti pas kita lagi nyelidikin bugs memori.

Buat mengisolasi kode unsafe sebanyak mungkin, praktik terbaiknya adalah membungkus (enclose) kode semacam itu di dalam sebuah abstraksi yang aman (safe abstraction) lalu menyediakan API yang aman, yang mana bakal kita bahas nanti di bab ini pas kita meneliti fungsi dan method unsafe. Beberapa bagian dari standard library diimplementasikan sebagai abstraksi yang aman di atas kode unsafe yang udah diaudit. Membungkus kode unsafe di dalam sebuah abstraksi yang aman bakal mencegah penggunaan unsafe agar tidak bocor (leaking out) ke semua tempat di mana kita atau user kita mungkin pengen memakai fungsionalitas yang diimplementasikan dengan kode unsafe tersebut, karena memakai sebuah abstraksi yang aman itu sifatnya aman.

Mari kita bahas masing-masing dari lima unsafe superpowers tersebut satu per satu. Kita juga bakal melihat beberapa abstraksi yang nyediain interface (antarmuka) yang aman buat mengakses kode unsafe.

Men-dereferensi sebuah Raw Pointer

Di Bab 4, di bagian “Dangling References”, kita menyebutkan kalau compiler selalu memastikan kalau referensi itu selalu valid. Unsafe Rust punya dua tipe baru bernama raw pointers yang mirip sama referensi. Sama kayak referensi, raw pointers bisa bersifat immutable atau mutable dan masing-masing ditulis sebagai *const T dan *mut T. Tanda bintang (*) di sini bukanlah operator dereferensi; dia adalah bagian dari nama tipenya. Di dalam konteks raw pointers, immutable berarti kalau pointer tersebut tidak bisa secara langsung di-assign (diisi nilai baru) setelah ia di-dereferensi.

Berbeda dari referensi dan smart pointers, raw pointers:

  • Diizinkan buat ngabaikan aturan borrowing dengan membiarkan kita punya baik pointer immutable maupun mutable, atau banyak pointer mutable yang menunjuk ke lokasi yang sama
  • Tidak dijamin bakal menunjuk ke memori yang valid
  • Diizinkan buat bernilai null
  • Tidak mengimplementasikan pembersihan otomatis (automatic cleanup) apa pun

Dengan memilih keluar (opting out) dari ditegakkannya jaminan-jaminan ini oleh Rust, kita bisa mengorbankan (give up) keamanan yang dijamin demi mendapatkan performa yang lebih besar atau kemampuan buat berinteraksi sama bahasa pemrograman lain atau hardware yang mana jaminan Rust tidak berlaku di situ.

Listing 20-1 menunjukkan gimana cara membikin sebuah raw pointer yang immutable dan yang mutable.

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;
}
Listing 20-1: Membikin raw pointers memakai operator raw borrow

Perhatikan bahwa kita tidak memasukkan keyword unsafe di kode ini. Kita bisa membikin raw pointers di dalam safe code (kode yang aman); kita cuma tidak bisa men-dereferensi raw pointers di luar sebuah blok unsafe, kayak yang bakal kita lihat sebentar lagi.

Kita udah membikin raw pointers dengan memakai operator raw borrow: &raw const num membikin sebuah raw pointer immutable *const i32, dan &raw mut num membikin sebuah raw pointer mutable *mut i32. Karena kita membikin mereka secara langsung dari sebuah variabel lokal, kita tahu pasti kalau raw pointers spesifik ini itu valid, tapi kita tidak bisa bikin asumsi kayak gitu buat sembarang raw pointer yang mana aja.

Buat mendemonstrasikan hal ini, selanjutnya kita bakal membikin sebuah raw pointer yang mana validitasnya tidak bisa kita pastikan, dengan memakai keyword as buat meng-cast (mengubah tipe) sebuah nilai ketimbang memakai operator raw borrow. Listing 20-2 menunjukkan gimana cara membikin sebuah raw pointer ke sebuah lokasi sembarang (arbitrary location) di memori. Mencoba buat memakai memori sembarang itu adalah perilaku yang tidak terdefinisi (undefined behavior): mungkin ada data di alamat tersebut atau mungkin tidak ada, compiler mungkin bakal mengoptimasi kodenya sehingga tidak ada akses memori yang terjadi, atau programnya mungkin bakal berhenti (terminate) karena segmentation fault. Biasanya, tidak ada alasan yang bagus buat menulis kode kayak gini, apalagi di kasus-kasus di mana kita bisa memakai operator raw borrow sebagai gantinya, tapi hal ini tetap memungkinkan buat dilakukan.

fn main() {
    let address = 0x012345usize;
    let r = address as *const i32;
}
Listing 20-2: Membikin sebuah raw pointer ke alamat memori sembarang

Ingat kembali kalau kita bisa membikin raw pointers di safe code, tapi kita tidak bisa men-dereferensi raw pointers dan membaca data yang ditunjuknya. Di Listing 20-3, kita memakai operator dereferensi * pada sebuah raw pointer yang mana mewajibkan sebuah blok unsafe.

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;

    unsafe {
        println!("r1 is: {}", *r1);
        println!("r2 is: {}", *r2);
    }
}
Listing 20-3: Men-dereferensi raw pointers di dalam sebuah blok unsafe

Membikin sebuah pointer itu tidak membahayakan apa-apa; cuma pas kita mencoba buat mengakses nilai yang ditunjuknya barulah kita berisiko berurusan dengan sebuah nilai yang tidak valid.

Perhatikan juga kalau di Listing 20-1 dan 20-3, kita membikin raw pointers *const i32 dan *mut i32 yang dua-duanya menunjuk ke lokasi memori yang sama, tempat di mana num disimpan. Kalau kita nyoba buat membikin referensi immutable dan mutable ke num sebagai gantinya, kodenya tidak bakal bisa di-compile karena aturan ownership Rust tidak mengizinkan adanya referensi mutable di saat yang bersamaan dengan referensi immutable apa pun. Dengan raw pointers, kita bisa membikin pointer mutable dan pointer immutable ke lokasi yang sama dan mengubah datanya lewat pointer mutable tersebut, yang secara potensial bisa ngebikin sebuah data race. Hati-hati ya!

Dengan semua bahaya ini, kenapa kita mau repot-repot memakai raw pointers? Salah satu skenario penggunaan (use case) utamanya adalah saat berinteraksi sama kode C, kayak yang bakal kita lihat di bagian selanjutnya. Skenario lainnya adalah pas kita lagi ngebangun abstraksi yang aman yang mana si borrow checker tidak bisa pahami. Kita bakal mengenalkan fungsi-fungsi unsafe lalu melihat sebuah contoh abstraksi aman yang memakai kode unsafe.

Memanggil Fungsi atau Method Unsafe

Jenis operasi kedua yang bisa kita lakukan di dalam blok unsafe adalah memanggil fungsi-fungsi unsafe. Fungsi dan method unsafe kelihatannya persis kayak fungsi dan method biasa, tapi mereka punya tambahan unsafe sebelum sisa definisinya. Keyword unsafe di konteks ini mengindikasikan bahwa fungsi tersebut punya persyaratan yang harus kita junjung tinggi pas kita manggil fungsi ini, karena Rust tidak bisa ngejamin kalau kita udah menuhi persyaratan tersebut. Dengan memanggil fungsi unsafe di dalam blok unsafe, kita menyatakan kalau kita udah ngebaca dokumentasi fungsi ini dan kita mengambil tanggung jawab buat mematuhi kontrak dari fungsi tersebut.

Berikut ini adalah fungsi unsafe bernama dangerous yang tidak melakukan apa-apa di dalam isinya (body):

fn main() {
    unsafe fn dangerous() {}

    unsafe {
        dangerous();
    }
}

Kita wajib memanggil fungsi dangerous di dalam sebuah blok unsafe yang terpisah. Kalau kita mencoba buat memanggil dangerous tanpa blok unsafe, kita bakal dapat error:

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0133]: call to unsafe function `dangerous` is unsafe and requires unsafe block
 --> src/main.rs:4:5
  |
4 |     dangerous();
  |     ^^^^^^^^^^^ call to unsafe function
  |
  = note: consult the function's documentation for information on how to avoid undefined behavior

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

Dengan blok unsafe, kita lagi menegaskan ke Rust kalau kita udah membaca dokumentasi dari fungsinya, kita ngerti gimana cara memakainya dengan benar, dan kita udah memverifikasi kalau kita mematuhi kontrak dari fungsi tersebut.

Buat melakukan operasi unsafe di dalam isi dari sebuah fungsi unsafe, kita tetap butuh memakai sebuah blok unsafe, sama seperti di dalam fungsi biasa, dan compiler bakal ngingetin (warn) kita kalau kita lupa. Ini ngebantu kita ngejaga supaya blok unsafe itu sekecil mungkin, karena operasi unsafe mungkin tidak diperlukan di seluruh isi fungsi tersebut.

Membikin Abstraksi yang Aman di atas Kode Unsafe

Cuma karena sebuah fungsi mengandung kode unsafe tidak berarti kita harus menandai keseluruhan fungsinya sebagai unsafe. Faktanya, membungkus kode unsafe di dalam sebuah fungsi yang aman adalah sebuah abstraksi yang sangat umum. Sebagai contoh, mari kita pelajari fungsi split_at_mut dari standard library, yang mana mewajibkan beberapa kode unsafe. Kita bakal mengeksplorasi gimana kita mungkin mengimplementasikannya. Method aman ini didefinisikan pada slices mutable: dia mengambil satu slice lalu membikinnya jadi dua dengan membelah slice tersebut di indeks yang diberikan sebagai argumen. Listing 20-4 menunjukkan gimana cara memakai split_at_mut.

fn main() {
    let mut v = vec![1, 2, 3, 4, 5, 6];

    let r = &mut v[..];

    let (a, b) = r.split_at_mut(3);

    assert_eq!(a, &mut [1, 2, 3]);
    assert_eq!(b, &mut [4, 5, 6]);
}
Listing 20-4: Memakai fungsi aman split_at_mut

Kita tidak bisa mengimplementasikan fungsi ini kalau cuma memakai Rust yang aman saja. Percobaannya mungkin bakal kelihatan kayak yang ada di Listing 20-5, yang mana tidak bakal bisa di-compile. Demi kesederhanaan, kita bakal mengimplementasikan split_at_mut sebagai sebuah fungsi bukannya method dan cuma buat slices yang berisi nilai i32 bukannya buat sebuah tipe generik T.

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();

    assert!(mid <= len);

    (&mut values[..mid], &mut values[mid..])
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}
Listing 20-5: Sebuah percobaan implementasi split_at_mut yang cuma memakai Rust yang aman

Fungsi ini pertama-tama mengambil panjang (length) total dari slice tersebut. Terus dia menegaskan (asserts) kalau indeks yang diberikan sebagai parameter itu ada di dalam batas-batas slice dengan mengecek apakah indeks tersebut kurang dari atau sama dengan panjangnya. Penegasan ini berarti kalau kita ngasih indeks yang lebih besar dari panjang slice tersebut buat ngebelah si slice, fungsinya bakal panic sebelum dia mencoba buat memakai indeks tersebut.

Lalu kita mengembalikan dua slices mutable di dalam sebuah tuple: yang satu dari awal (start) slice aslinya sampai ke indeks mid, dan satu lagi dari mid sampai ke akhir dari slice tersebut.

Pas kita mencoba buat men-compile kode di Listing 20-5, kita bakal dapat sebuah error:

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0499]: cannot borrow `*values` as mutable more than once at a time
 --> src/main.rs:6:31
  |
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
  |                         - let's call the lifetime of this reference `'1`
...
6 |     (&mut values[..mid], &mut values[mid..])
  |     --------------------------^^^^^^--------
  |     |     |                   |
  |     |     |                   second mutable borrow occurs here
  |     |     first mutable borrow occurs here
  |     returning this value requires that `*values` is borrowed for `'1`
  |
  = help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices

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

Borrow checker Rust tidak bisa ngerti kalau kita lagi meminjam (borrowing) bagian yang berbeda dari slice tersebut; dia cuma tahu kalau kita lagi meminjam dari slice yang sama sebanyak dua kali. Meminjam bagian yang berbeda dari sebuah slice itu secara fundamental baik-baik aja karena kedua slices tersebut tidak saling tumpang tindih (overlapping), tapi Rust tidak cukup pintar buat tahu hal ini. Pas kita tahu kalau kodenya itu aman, tapi Rust tidak tahu, inilah saatnya buat ngambil kode unsafe.

Listing 20-6 nunjukin gimana caranya memakai sebuah blok unsafe, sebuah raw pointer, dan beberapa pemanggilan ke fungsi unsafe buat ngebikin implementasi dari split_at_mut ini jalan.

use std::slice;

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    let ptr = values.as_mut_ptr();

    assert!(mid <= len);

    unsafe {
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}
Listing 20-6: Memakai kode unsafe di dalam implementasi fungsi split_at_mut

Ingat kembali dari “Tipe Slice” di Bab 4 kalau sebuah slice itu adalah pointer ke suatu data beserta panjang dari slice tersebut. Kita memakai method len buat dapat panjang dari sebuah slice dan method as_mut_ptr buat ngakses raw pointer dari sebuah slice. Di kasus ini, karena kita punya slice mutable ke nilai i32, as_mut_ptr mengembalikan sebuah raw pointer dengan tipe *mut i32, yang udah kita simpan di dalam variabel ptr.

Kita tetap naruh penegasan kalau indeks mid itu ada di dalam slice. Terus kita masuk ke kode unsafe-nya: fungsi slice::from_raw_parts_mut menerima sebuah raw pointer dan sebuah panjang, terus dia ngebikin sebuah slice. Kita memakai fungsi ini buat ngebikin sebuah slice yang dimulai dari ptr dan panjangnya sebesar mid item. Terus kita panggil method add pada ptr dengan mid sebagai argumen buat dapetin raw pointer yang mulai di posisi mid, dan kita ngebikin sebuah slice memakai pointer itu dan sisa jumlah item setelah mid sebagai panjangnya.

Fungsi slice::from_raw_parts_mut itu sifatnya unsafe karena dia menerima sebuah raw pointer dan harus percaya kalau pointer ini benar-benar valid. Method add pada raw pointers juga sifatnya unsafe karena dia harus percaya kalau lokasi offset-nya itu juga merupakan pointer yang valid. Oleh karena itu, kita harus menaruh blok unsafe di sekeliling pemanggilan ke slice::from_raw_parts_mut dan add supaya kita bisa memanggil mereka. Dengan ngelihat kodenya dan dengan menambahkan penegasan kalau mid itu harus kurang dari atau sama dengan len, kita bisa tahu pasti kalau semua raw pointers yang dipakai di dalam blok unsafe tersebut bakal jadi pointers yang valid yang menunjuk ke data di dalam slice tersebut. Ini adalah penggunaan dari unsafe yang bisa diterima dan sangat tepat.

Perhatikan bahwa kita tidak perlu menandai fungsi split_at_mut hasilnya sebagai unsafe, dan kita bisa memanggil fungsi ini dari safe code. Kita udah ngebikin sebuah abstraksi yang aman ke dalam kode unsafe tersebut dengan sebuah implementasi fungsi yang memakai kode unsafe dengan cara yang aman, karena dia cuma membikin pointers yang valid dari data yang emang bisa diakses sama fungsi ini.

Sebaliknya, pemakaian slice::from_raw_parts_mut di Listing 20-7 kemungkinan besar bakal crash (rusak) pas slice-nya dipakai. Kode ini mengambil sebuah lokasi memori sembarang lalu membikin sebuah slice yang panjangnya 10.000 item.

fn main() {
    use std::slice;

    let address = 0x01234usize;
    let r = address as *mut i32;

    let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
}
Listing 20-7: Membikin sebuah slice dari lokasi memori sembarang

Kita tidak memiliki memori di lokasi sembarang ini, dan tidak ada jaminan kalau slice yang dibikin sama kode ini benar-benar berisi nilai i32 yang valid. Mencoba buat memakai values seolah-olah dia adalah slice yang valid bakal menghasilkan perilaku yang tidak terdefinisi (undefined behavior).

Memakai Fungsi extern Buat Memanggil Kode Eksternal

Kadang-kadang kode Rust kita mungkin perlu berinteraksi sama kode yang ditulis di bahasa pemrograman lain. Buat keperluan ini, Rust punya keyword extern yang memfasilitasi pembuatan dan penggunaan Foreign Function Interface (FFI), yang mana adalah sebuah cara bagi sebuah bahasa pemrograman buat mendefinisikan fungsi lalu memungkinkan bahasa pemrograman (asing) lain buat memanggil fungsi-fungsi tersebut.

Listing 20-8 mendemonstrasikan gimana caranya nge-setup sebuah integrasi dengan fungsi abs dari standard library C. Fungsi-fungsi yang dideklarasikan di dalam blok extern itu umumnya tidak aman (unsafe) buat dipanggil dari kode Rust, jadi blok extern tersebut juga harus ditandai sebagai unsafe. Alasannya adalah karena bahasa- bahasa lain tidak menerapkan (enforce) aturan-aturan dan jaminan-jaminan yang dipunyai Rust, dan Rust tidak bisa ngecek mereka, jadi tanggung jawabnya jatuh ke tangan si programmer buat memastikan keamanan kodenya.

Filename: src/main.rs
unsafe extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}
Listing 20-8: Mendeklarasikan dan memanggil sebuah fungsi extern yang didefinisikan di bahasa pemrograman lain

Di dalam blok unsafe extern "C", kita mendaftarkan (list) nama-nama dan signatures dari fungsi-fungsi eksternal dari bahasa lain yang mau kita panggil. Bagian "C" itu mendefinisikan application binary interface (ABI) mana yang dipakai sama fungsi eksternal tersebut: ABI ini mendefinisikan gimana caranya memanggil fungsinya pada tingkat bahasa rakitan (assembly level). ABI "C" adalah yang paling umum dipakai dan mengikuti standar ABI dari bahasa pemrograman C. Informasi tentang semua ABI yang didukung oleh Rust ada di Rust Reference.

Semua item yang dideklarasikan di dalam blok unsafe extern itu secara implisit sifatnya unsafe. Namun, beberapa fungsi FFI memang aman buat dipanggil. Contohnya, fungsi abs dari standard library C itu tidak punya pertimbangan apa pun soal keamanan memori dan kita tahu dia bisa dipanggil pakai sembarang i32. Di kasus-kasus kayak gini, kita bisa memakai keyword safe buat ngasih tahu kalau fungsi yang spesifik ini itu aman buat dipanggil biarpun dia ada di dalam sebuah blok unsafe extern. Begitu kita bikin perubahan tersebut, manggil fungsinya udah tidak perlu blok unsafe lagi, kayak yang ditunjukin di Listing 20-9.

Filename: src/main.rs
unsafe extern "C" {
    safe fn abs(input: i32) -> i32;
}

fn main() {
    println!("Absolute value of -3 according to C: {}", abs(-3));
}
Listing 20-9: Secara eksplisit menandai sebuah fungsi sebagai safe di dalam sebuah blok unsafe extern dan memanggilnya dengan aman

Menandai sebuah fungsi sebagai safe tidak secara otomatis (inherently) membikin fungsinya jadi aman ya! Sebaliknya, ini ibarat janji yang kita buat ke Rust kalau dia itu aman. Itu tetap jadi tanggung jawab kita buat mastiin kalau janji itu ditepati!

Memanggil Fungsi Rust dari Bahasa Lain

Kita juga bisa memakai extern buat membikin interface yang memungkinkan bahasa pemrograman lain buat memanggil fungsi Rust. Ketimbang membikin satu blok extern utuh, kita menambahkan keyword extern dan menentukan ABI apa yang mau dipakai tepat sebelum keyword fn buat fungsi yang relevan. Kita juga perlu nambahin anotasi #[unsafe(no_mangle)] buat ngasih tahu compiler Rust supaya tidak nge-mangle nama dari fungsi ini. Mangling adalah pas compiler ngubah nama yang udah kita kasih ke sebuah fungsi jadi nama lain yang mengandung lebih banyak informasi biar bisa dikonsumsi sama bagian-bagian lain dari proses kompilasi tapi jadinya kurang enak dibaca sama manusia. Setiap compiler bahasa pemrograman nge-mangle nama dengan cara yang agak berbeda-beda, jadi supaya sebuah fungsi Rust bisa dipanggil namanya sama bahasa lain, kita harus mematikan fitur name mangling dari compiler Rust. Hal ini sifatnya unsafe karena bisa aja terjadi bentrok nama (name collisions) antar libraries kalau mangling bawaan ini dimatiin, jadi ini adalah tanggung jawab kita buat mastiin kalau nama yang kita pilih itu aman buat diekspor tanpa di-mangle.

Di contoh berikut ini, kita membikin fungsi call_from_c supaya bisa diakses dari kode C, setelah dia di-compile jadi shared library dan di-link dari C:

#[unsafe(no_mangle)]
pub extern "C" fn call_from_c() {
    println!("Just called a Rust function from C!");
}

Penggunaan extern ini cuma mewajibkan adanya unsafe di dalam atributnya aja, bukan di blok extern-nya.

Mengakses atau Memodifikasi Variabel Static yang Mutable

Di buku ini, kita belum pernah ngebahas soal variabel global (global variables), yang mana memang didukung oleh Rust tapi bisa jadi bermasalah kalau digabungkan sama aturan ownership Rust. Kalau ada dua threads yang lagi mengakses variabel global yang mutable yang sama, hal itu bisa nyebabin sebuah data race.

Di Rust, variabel global itu disebut sebagai variabel static (statis). Listing 20-10 menunjukkan contoh deklarasi dan penggunaan dari sebuah variabel statis dengan nilai berupa sebuah string slice.

Filename: src/main.rs
static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("value is: {HELLO_WORLD}");
}
Listing 20-10: Mendefinisikan dan memakai sebuah variabel statis yang immutable

Variabel static itu mirip sama konstanta (constants), yang mana udah kita bahas di “Konstanta” di Bab 3. Nama-nama buat variabel statis itu ditulis pakai gaya SCREAMING_SNAKE_CASE secara konvensi. Variabel statis cuma bisa menyimpan referensi dengan lifetime 'static, yang berarti compiler Rust udah tahu soal lifetime-nya dan kita tidak diwajibkan buat menganotasinya secara eksplisit. Mengakses sebuah variabel statis yang immutable itu aman.

Satu perbedaan yang cukup halus (subtle) antara konstanta dan variabel statis immutable adalah nilai-nilai yang ada di dalam sebuah variabel statis punya alamat yang tetap di memori. Memakai nilai tersebut bakal selalu mengakses data yang sama. Konstanta, di sisi lain, diizinkan buat menduplikasi data mereka kapan pun mereka dipakai. Perbedaan lainnya adalah variabel statis itu bisa bersifat mutable (bisa diubah). Mengakses dan memodifikasi variabel statis mutable itu sifatnya unsafe. Listing 20-11 menunjukkan gimana caranya mendeklarasikan, mengakses, dan memodifikasi sebuah variabel statis mutable bernama COUNTER.

Filename: src/main.rs
static mut COUNTER: u32 = 0;

/// SAFETY: Calling this from more than a single thread at a time is undefined
/// behavior, so you *must* guarantee you only call it from a single thread at
/// a time.
unsafe fn add_to_count(inc: u32) {
    unsafe {
        COUNTER += inc;
    }
}

fn main() {
    unsafe {
        // SAFETY: This is only called from a single thread in `main`.
        add_to_count(3);
        println!("COUNTER: {}", *(&raw const COUNTER));
    }
}
Listing 20-11: Ngebaca atau nulis ke sebuah variabel statis mutable itu unsafe.

Sama kayak variabel biasa, kita menentukan mutabilitasnya dengan memakai keyword mut. Kode apa pun yang membaca atau menulis dari COUNTER wajib berada di dalam sebuah blok unsafe. Kode di Listing 20-11 berhasil di-compile dan mencetak COUNTER: 3 kayak yang kita harapkan karena ini adalah program single-threaded (utas tunggal). Kalau sampai ada banyak threads yang mengakses COUNTER, itu kemungkinan besar bakal nyebabin data races, jadi itu dianggap sebagai perilaku yang tidak terdefinisi. Oleh karena itu, kita perlu menandai keseluruhan fungsinya sebagai unsafe dan mendokumentasikan batasan keamanannya, supaya siapa pun yang memanggil fungsi ini tahu apa aja yang boleh dan tidak boleh mereka lakuin dengan aman.

Kapan pun kita nulis sebuah fungsi yang unsafe, sangatlah idiomatik buat nulis sebuah komentar yang diawali dengan kata SAFETY dan ngejelasin apa aja yang perlu dilakuin sama si pemanggil fungsi buat memanggil fungsi tersebut dengan aman. Sama halnya, kapan pun kita ngelakuin operasi unsafe, juga sangat idiomatik buat nulis sebuah komentar yang diawali dengan SAFETY buat ngejelasin gimana aturan-aturan keamanan tersebut dijunjung tinggi.

Selain itu, compiler secara default bakal melarang usaha apa pun buat ngebikin referensi ke sebuah variabel statis yang mutable lewat mekanisme lint (peringatan kode) compiler. Kita wajib secara eksplisit keluar (opt-out) dari perlindungan lint tersebut dengan nambahin anotasi #[allow(static_mut_refs)] atau ngakses variabel statis mutable tersebut lewat sebuah raw pointer yang dibikin pakai salah satu dari operator raw borrow. Hal ini termasuk juga kasus-kasus di mana referensi tersebut dibikin secara kasatmata, kayak pas dia dipakai di println! di dalam kode ini. Mewajibkan supaya referensi ke variabel statis yang mutable harus dibikin lewat raw pointers ngebantu ngebikin persyaratan keamanannya jadi lebih terlihat jelas saat kita memakai mereka.

Dengan data mutable yang bisa diakses secara global, itu susah sekali buat memastikan kalau tidak bakal ada data races, yang mana inilah alasan kenapa Rust menganggap variabel statis mutable itu sebagai unsafe. Kalau memungkinkan, jauh lebih baik buat memakai teknik-teknik konkurensi dan smart pointers yang thread-safe yang udah kita bahas di Bab 16 supaya compiler bisa ngecek kalau akses data dari berbagai threads yang berbeda dilakukan dengan aman.

Mengimplementasikan Sebuah Unsafe Trait

Kita bisa memakai unsafe buat mengimplementasikan sebuah trait unsafe. Sebuah trait itu dianggap unsafe saat minimal salah satu dari method-methodnya punya aturan mutlak (invariant) yang mana tidak bisa diverifikasi sama compiler. Kita mendeklarasikan kalau sebuah trait itu unsafe dengan menambahkan keyword unsafe sebelum keyword trait dan menandai implementasi dari trait tersebut sebagai unsafe juga, seperti yang ditunjukin di Listing 20-12.

unsafe trait Foo {
    // methods go here
}

unsafe impl Foo for i32 {
    // method implementations go here
}

fn main() {}
Listing 20-12: Mendefinisikan dan mengimplementasikan sebuah unsafe trait

Dengan memakai unsafe impl, kita lagi berjanji kalau kita bakal menjunjung tinggi aturan-aturan mutlak (invariants) yang tidak bisa diverifikasi sama compiler.

Sebagai contoh, ingat kembali marker traits Send dan Sync yang kita bahas di “Konkurensi yang Bisa Diperluas dengan Trait Send dan Sync di Bab 16: compiler mengimplementasikan trait-trait ini secara otomatis kalau tipe-tipe kita sepenuhnya disusun dari tipe-tipe lain yang udah mengimplementasikan Send dan Sync. Kalau kita ngimplementasiin sebuah tipe yang mengandung sebuah tipe yang tidak mengimplementasikan Send atau Sync, contohnya raw pointers, dan kita mau menandai tipe tersebut sebagai Send atau Sync, kita wajib memakai unsafe. Rust tidak bisa memverifikasi kalau tipe kita itu mematuhi jaminan bahwa dia bisa dikirim ke thread lain dengan aman atau diakses dari banyak threads dengan aman; makanya, kita perlu ngelakuin pengecekan itu sendiri secara manual dan ngasih tahu hal itu lewat keyword unsafe.

Mengakses Field dari Union

Tindakan (action) terakhir yang cuma bisa bekerja dengan keyword unsafe adalah mengakses field (bidang) dari sebuah union. Sebuah union (gabungan) itu mirip sama sebuah struct, tapi cuma satu aja dari field yang dideklarasikan yang bisa dipakai di dalam sebuah instance pada satu waktu tertentu. Unions biasanya dipakai buat berinteraksi dengan unions yang ada di dalam kode C. Mengakses field dari union itu unsafe karena Rust tidak bisa ngejamin tipe dari data yang saat itu lagi disimpan di dalam instance union tersebut. Kita bisa belajar lebih banyak soal unions di Rust Reference.

Memakai Miri Buat Mengecek Kode Unsafe

Pas kita lagi nulis kode unsafe, kita mungkin pengen ngecek apakah kode yang udah kita tulis itu benar-benar aman dan udah betul (correct) atau tidak. Salah satu cara terbaik buat ngebuktiin itu adalah dengan memakai Miri, sebuah tool resmi Rust buat mendeteksi perilaku yang tidak terdefinisi (undefined behavior). Kalau borrow checker itu adalah sebuah tool yang statis (static) yang bekerja pas compile time, Miri itu adalah tool yang dinamis (dynamic) yang bekerja pas runtime. Miri mengecek kode kita dengan cara menjalankan program kita, atau seperangkat pengujiannya (test suite), lalu mendeteksi kapan kita melanggar aturan-aturan yang Miri pahami tentang gimana seharusnya Rust bekerja.

Memakai Miri mewajibkan kita punya instalasi Rust versi nightly (yang mana kita obrolin lebih banyak di Lampiran G: Gimana Rust Dibuat dan “Nightly Rust”). Kita bisa menginstal baik Rust versi nightly sekaligus tool Miri dengan mengetikkan perintah rustup +nightly component add miri. Hal ini tidak bakal ngubah versi Rust apa yang lagi dipakai sama project kita; dia cuma nambahin tool tersebut ke sistem kita biar kita bisa memakainya pas kita mau aja. Kita bisa menjalankan Miri di sebuah project dengan ngetik cargo +nightly miri run atau cargo +nightly miri test.

Sebagai contoh betapa bergunanya ini, bayangin apa yang terjadi pas kita menjalankan Miri buat nge-tes kode di Listing 20-7.

$ cargo +nightly miri run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
     Running `file:///home/.rustup/toolchains/nightly/bin/cargo-miri runner target/miri/debug/unsafe-example`
warning: integer-to-pointer cast
 --> src/main.rs:5:13
  |
5 |     let r = address as *mut i32;
  |             ^^^^^^^^^^^^^^^^^^^ integer-to-pointer cast
  |
  = help: this program is using integer-to-pointer casts or (equivalently) `ptr::with_exposed_provenance`, which means that Miri might miss pointer bugs in this program
  = help: see https://doc.rust-lang.org/nightly/std/ptr/fn.with_exposed_provenance.html for more details on that operation
  = help: to ensure that Miri does not miss bugs in your program, use Strict Provenance APIs (https://doc.rust-lang.org/nightly/std/ptr/index.html#strict-provenance, https://crates.io/crates/sptr) instead
  = help: you can then set `MIRIFLAGS=-Zmiri-strict-provenance` to ensure you are not relying on `with_exposed_provenance` semantics
  = help: alternatively, `MIRIFLAGS=-Zmiri-permissive-provenance` disables this warning
  = note: BACKTRACE:
  = note: inside `main` at src/main.rs:5:13: 5:32

error: Undefined Behavior: pointer not dereferenceable: pointer must be dereferenceable for 40000 bytes, but got 0x1234[noalloc] which is a dangling pointer (it has no provenance)
 --> src/main.rs:7:35
  |
7 |     let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
  |                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here
  |
  = help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
  = help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
  = note: BACKTRACE:
  = note: inside `main` at src/main.rs:7:35: 7:70

note: some details are omitted, run with `MIRIFLAGS=-Zmiri-backtrace=full` for a verbose backtrace

error: aborting due to 1 previous error; 1 warning emitted

Miri ngingetin kita dengan tepat kalau kita lagi nge-cast (mengubah tipe) sebuah integer jadi sebuah pointer, yang mana mungkin aja jadi masalah tapi Miri tidak bisa mendeteksi apakah emang ada masalah atau tidak karena Miri tidak tahu dari mana asal-usul si pointer tersebut. Terus, Miri ngembaliin sebuah error di mana Listing 20-7 punya perilaku yang tidak terdefinisi (undefined behavior) karena kita punya sebuah dangling pointer. Berkat Miri, sekarang kita jadi tahu kalau ada risiko terjadinya perilaku yang tidak terdefinisi, dan kita bisa mikirin gimana caranya ngebikin kodenya jadi aman. Di beberapa kasus, Miri bahkan bisa ngasih saran gimana cara buat ngeberesin error-error tersebut.

Miri tidak bisa menangkap semua hal yang mungkin keliru pas kita nulis kode unsafe. Miri itu adalah sebuah tool analisis yang dinamis, jadi dia cuma bisa nangkap masalah pada kode yang emang benar-benar dijalankan. Itu artinya kita perlu memakainya barengan sama teknik-teknik pengujian (testing techniques) yang oke buat ningkatin rasa percaya diri kita terhadap kode unsafe yang udah kita tulis. Miri juga tidak mencakup setiap kemungkinan yang ada yang bikin kode kita jadi cacat (unsound).

Dengan kata lain: Kalau Miri emang nangkap sebuah masalah, kita jadi tahu kalau ada sebuah bug, tapi cuma karena Miri tidak nangkap sebuah bug itu tidak berarti kalau tidak ada masalah di situ. Walaupun begitu, dia bisa nangkap sangat banyak lho. Cobain deh ngejalanin Miri di contoh-contoh kode unsafe lain di bab ini dan lihat apa aja yang dia bilang!

Kita bisa belajar lebih lanjut soal Miri di repositori GitHub-nya.

Kapan Harus Memakai Kode Unsafe

Memakai unsafe buat melakukan salah satu dari lima superpowers yang baru aja kita bahas itu bukanlah hal yang salah atau yang dilarang keras, tapi emang lebih tricky (susah) buat bikin kode unsafe itu jadi benar (correct) karena compiler tidak bisa ngebantu buat menjunjung tinggi keamanan memori. Pas kita punya alasan buat memakai kode unsafe, kita bebas aja buat melakukannya, dan dengan adanya anotasi unsafe yang eksplisit itu malah ngebikin kita lebih gampang buat ngelacak sumber dari suatu masalah kalau sampai masalah itu muncul nanti. Kapan pun kita nulis kode unsafe, kita bisa memakai Miri buat ngebantu kita jadi lebih yakin kalau kode yang udah kita tulis itu benar-benar mematuhi aturan-aturan Rust.

Buat penjelajahan yang jauh lebih dalam soal gimana cara kerja efektif dengan unsafe Rust, silakan baca panduan resmi dari Rust soal subjek ini, yaitu Rustonomicon.

Advanced Traits (Traits Tingkat Lanjut)

Advanced Traits (Traits Tingkat Lanjut)

Kita pertama kali ngebahas soal traits di “Traits: Mendefinisikan Perilaku Bersama” di Bab 10, tapi kita tidak ngebahas detail-detail yang lebih mahirnya. Sekarang setelah kita tahu lebih banyak soal Rust, kita bisa masuk ke seluk beluk (nitty-gritty) dari traits ini.

Associated Types

Associated types (tipe terkait) menghubungkan sebuah placeholder (tempat pengganti) tipe dengan sebuah trait sedemikian rupa sehingga definisi method dari trait tersebut bisa memakai tipe placeholder ini di dalam signatures-nya. Si peng-implementasi (implementor) dari trait tersebut bakal menentukan tipe konkret yang bakal dipakai buat menggantikan tipe placeholder itu buat implementasi khususnya. Dengan begitu, kita bisa mendefinisikan sebuah trait yang memakai tipe-tipe tertentu tanpa perlu tahu persis apa tipe-tipe tersebut sampai trait-nya benar-benar diimplementasikan.

Kita udah mendeskripsikan sebagian besar fitur-fitur tingkat lanjut di bab ini sebagai hal-hal yang jarang dibutuhkan. Associated types ini letaknya ada di tengah-tengah: mereka dipakai lebih jarang ketimbang fitur-fitur yang dijelaskan di bagian lain buku ini tapi lebih sering dipakai ketimbang banyak fitur lain yang dibahas di bab ini.

Salah satu contoh dari trait yang punya associated type adalah trait Iterator yang disediakan oleh standard library. Associated type-nya dinamakan Item dan dia bertindak sebagai pengganti buat tipe dari nilai-nilai yang lagi diiterasi sama tipe yang mengimplementasikan trait Iterator tersebut. Definisi dari trait Iterator ini ditunjukkan di Listing 20-13.

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}
Listing 20-13: Definisi dari trait Iterator yang punya sebuah associated type Item

Tipe Item itu adalah sebuah placeholder, dan definisi method next nunjukin kalau dia bakal mengembalikan nilai-nilai bertipe Option<Self::Item>. Peng-implementasi dari trait Iterator bakal menentukan tipe konkret buat Item, dan method next bakal mengembalikan sebuah Option yang berisi sebuah nilai dari tipe konkret tersebut.

Associated types mungkin kelihatannya mirip kayak konsep generik (generics), di mana generik itu memungkinkan kita buat mendefinisikan sebuah fungsi tanpa menentukan tipe-tipe apa yang bisa ditanganinya. Buat memeriksa perbedaan antara kedua konsep ini, kita bakal melihat sebuah implementasi dari trait Iterator pada sebuah tipe bernama Counter yang menentukan kalau tipe Item-nya adalah u32:

Filename: src/lib.rs
struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        // --snip--
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

Sintaks ini kelihatannya bisa disamain sama sintaksnya generik. Terus kenapa tidak sekalian aja mendefinisikan trait Iterator pakai generik, seperti yang ditunjukkan di Listing 20-14?

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}
Listing 20-14: Sebuah definisi hipotetis (andaian) dari trait Iterator yang memakai generik

Perbedaannya adalah saat memakai generik, seperti di Listing 20-14, kita wajib menganotasi tipe-tipenya di setiap implementasinya; karena kita juga bisa mengimplementasikan Iterator<String> for Counter atau tipe apa pun lainnya, kita jadinya bisa punya banyak implementasi dari Iterator buat Counter. Dengan kata lain, saat sebuah trait punya parameter generik, dia bisa diimplementasikan buat satu tipe berkali-kali, asalkan tipe konkret dari parameter tipe generiknya selalu berbeda setiap kalinya. Saat kita memakai method next pada Counter, kita harus ngasih anotasi tipe buat nunjukin implementasi dari Iterator yang mana yang mau kita pakai.

Dengan associated types, kita tidak perlu menganotasi tipe-tipe karena kita tidak bisa mengimplementasikan sebuah trait pada satu tipe berkali-kali. Di Listing 20-13 yang mana definisinya memakai associated types, kita cuma bisa memilih tipe apa yang bakal jadi Item itu satu kali aja karena cuma boleh ada satu impl Iterator for Counter. Kita tidak perlu menyebutkan kalau kita mau sebuah iterator dari nilai-nilai u32 di mana-mana di kode pas kita manggil next pada Counter.

Associated types juga menjadi bagian dari kontrak si trait tersebut: para peng-implementasi dari trait tersebut wajib menyediakan sebuah tipe buat menggantikan placeholder associated type-nya. Associated types sering kali punya nama yang mendeskripsikan gimana tipe tersebut bakal dipakai, dan mendokumentasikan associated type di dalam dokumentasi API adalah sebuah praktik yang baik.

Parameter Tipe Generik Default (Bawaan) dan Operator Overloading

Saat kita memakai parameter tipe generik, kita bisa menentukan tipe konkret default (bawaan) buat tipe generik tersebut. Ini ngehapus kebutuhan bagi para peng-implementasi dari trait tersebut buat menentukan tipe konkret kalau tipe default-nya emang udah pas. Kita menentukan sebuah tipe default saat mendeklarasikan sebuah tipe generik dengan sintaks <PlaceholderType=ConcreteType>.

Satu contoh keren dari situasi di mana teknik ini sangat berguna adalah pada operator overloading (penumpukan fungsi operator), di mana kita mengkustomisasi perilaku dari sebuah operator (seperti +) di situasi-situasi tertentu.

Rust tidak mengizinkan kita buat membikin operator kita sendiri atau melakukan overload pada sembarang operator. Tapi kita bisa melakukan overload pada operasi- operasi dan trait-trait korespondennya yang terdaftar di std::ops dengan mengimplementasikan trait-trait yang berkaitan sama operator tersebut. Misalnya, di Listing 20-15 kita melakukan overload pada operator + buat menjumlahkan dua instance Point bersama-sama. Kita ngelakuin ini dengan mengimplementasikan trait Add pada struct Point.

Filename: src/main.rs
use std::ops::Add;

#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    assert_eq!(
        Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
        Point { x: 3, y: 3 }
    );
}
Listing 20-15: Mengimplementasikan trait Add buat nge-overload operator + untuk instance-instance Point

Method add ngejumlahin nilai x dari dua instance Point dan nilai y dari dua instance Point buat membikin sebuah Point baru. Trait Add punya sebuah associated type bernama Output yang menentukan tipe yang dikembalikan dari method add.

Tipe generik default di kode ini ada di dalam trait Add. Ini adalah definisinya:

#![allow(unused)]
fn main() {
trait Add<Rhs=Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}
}

Kode ini harusnya kelihatan familier pada umumnya: sebuah trait dengan satu method dan satu associated type. Bagian yang baru adalah Rhs=Self: sintaks ini disebut default type parameters (parameter tipe default). Parameter tipe generik Rhs (singkatan dari “right-hand side” atau sisi kanan) mendefinisikan tipe dari parameter rhs di dalam method add. Kalau kita tidak menentukan sebuah tipe konkret buat Rhs saat kita mengimplementasikan trait Add, tipe dari Rhs bakal secara default menjadi Self, yang mana merupakan tipe di mana kita lagi mengimplementasikan trait Add tersebut.

Saat kita mengimplementasikan Add buat Point, kita memakai nilai default buat Rhs karena kita mau menjumlahkan dua instance Point. Mari kita lihat sebuah contoh pengimplementasian trait Add di mana kita mau mengkustomisasi tipe Rhs ketimbang memakai nilai default-nya.

Kita punya dua struct, Millimeters dan Meters, yang menampung nilai-nilai dalam satuan (units) yang berbeda. Pembungkusan tipis (thin wrapping) dari sebuah tipe yang udah ada ke dalam struct lain ini dikenal sebagai newtype pattern, yang mana bakal kita jelasin lebih detail di bagian “Memakai Newtype Pattern Buat Mengimplementasikan External Traits”. Kita pengen bisa menjumlahkan nilai-nilai dalam millimeter dengan nilai-nilai dalam meter lalu punya implementasi dari Add yang melakukan konversinya dengan benar. Kita bisa mengimplementasikan Add buat Millimeters dengan Meters sebagai si Rhs, seperti yang ditunjukkan di Listing 20-16.

Filename: src/lib.rs
use std::ops::Add;

struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
    type Output = Millimeters;

    fn add(self, other: Meters) -> Millimeters {
        Millimeters(self.0 + (other.0 * 1000))
    }
}
Listing 20-16: Mengimplementasikan trait Add pada Millimeters buat menjumlahkan Millimeters dan Meters

Buat menjumlahkan Millimeters dan Meters, kita menentukan impl Add<Meters> buat menge-set nilai dari parameter tipe Rhs ketimbang memakai nilai default Self.

Kita bakal memakai parameter tipe default dalam dua cara utama:

  1. Buat memperluas sebuah tipe tanpa merusak kode yang udah ada (existing code)
  2. Buat memungkinkan adanya kustomisasi di kasus-kasus spesifik yang mana mayoritas user tidak bakal membutuhkannya

Trait Add di standard library adalah contoh dari tujuan yang kedua: biasanya, kita bakal menjumlahkan dua tipe yang sama, tapi trait Add menyediakan kemampuan buat melakukan kustomisasi lebih dari itu. Memakai sebuah parameter tipe default di dalam definisi trait Add berarti kita tidak perlu menyebutkan parameter tambahan itu di sebagian besar waktunya. Dengan kata lain, sedikit boilerplate code (kode berulang-ulang) tidak lagi diperlukan, ngebikin penggunaan trait-nya jadi lebih gampang.

Tujuan pertama itu mirip sama tujuan kedua tapi kebalikannya: kalau kita mau nambahin sebuah parameter tipe ke sebuah trait yang udah ada, kita bisa ngasih dia sebuah nilai default biar ekstensi fungsionalitas dari trait tersebut tidak merusak kode implementasi yang udah ada.

Menghilangkan Ambiguitas (Disambiguating) di Antara Method-method yang Punya Nama yang Sama

Tidak ada aturan di Rust yang mencegah sebuah trait dari punya method dengan nama yang sama dengan method dari trait lain, dan Rust juga tidak mencegah kita buat mengimplementasikan kedua trait tersebut pada satu tipe. Sangat mungkin juga buat mengimplementasikan sebuah method secara langsung pada tipe tersebut dengan nama yang sama kayak nama-nama method dari trait-trait tadi.

Pas kita memanggil method-method yang punya nama yang sama ini, kita harus ngasih tahu Rust mana yang mau kita pakai. Coba perhatikan kode di Listing 20-17 di mana kita udah mendefinisikan dua trait, Pilot dan Wizard, yang mana dua-duanya punya sebuah method bernama fly. Kita lalu mengimplementasikan kedua trait tersebut pada sebuah tipe Human yang ternyata juga udah punya sebuah method bernama fly yang diimplementasikan langsung padanya. Masing-masing method fly ini ngelakuin hal yang berbeda.

Filename: src/main.rs
trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {}
Listing 20-17: Dua trait didefinisikan punya method fly dan diimplementasikan pada tipe Human, dan sebuah method fly juga diimplementasikan langsung pada Human.

Saat kita memanggil fly pada sebuah instance dari Human, compiler secara default bakal memanggil method yang diimplementasikan secara langsung pada tipe tersebut, seperti yang ditunjukkan di Listing 20-18.

Filename: src/main.rs
trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    person.fly();
}
Listing 20-18: Memanggil fly pada sebuah instance dari Human

Menjalankan kode ini bakal mencetak *waving arms furiously* (melambaikan tangan dengan geram), nunjukin kalau Rust memanggil method fly yang diimplementasikan pada Human secara langsung.

Buat memanggil method fly dari trait Pilot atau trait Wizard, kita harus memakai sintaks yang lebih eksplisit buat menyebutkan method fly yang mana yang kita maksud. Listing 20-19 mendemonstrasikan sintaks ini.

Filename: src/main.rs
trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    Pilot::fly(&person);
    Wizard::fly(&person);
    person.fly();
}
Listing 20-19: Menyebutkan method fly dari trait mana yang mau kita panggil

Menyebutkan nama trait sebelum nama method-nya memperjelas bagi Rust implementasi dari fly yang mana yang mau kita panggil. Kita juga bisa menuliskan Human::fly(&person), yang mana ini ekuivalen (sama) dengan person.fly() yang kita pakai di Listing 20-19, tapi cara ini sedikit lebih panjang buat ditulis padahal kita tidak perlu menghilangkan ambiguitas apa pun di sana.

Menjalankan kode ini bakal mencetak yang berikut ini:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.46s
     Running `target/debug/traits-example`
This is your captain speaking.
Up!
*waving arms furiously*

Karena method fly punya parameter self, kalau kita punya dua tipe yang dua- duanya mengimplementasikan satu trait, Rust bisa nyari tahu implementasi dari trait mana yang harus dipakai berdasarkan tipe dari self.

Namun, associated functions (fungsi terkait) yang bukan methods tidak punya parameter self. Saat ada beberapa tipe atau trait yang mendefinisikan fungsi-fungsi non-method dengan nama fungsi yang sama, Rust tidak selalu tahu tipe mana yang kita maksud kecuali kalau kita memakai fully qualified syntax (sintaks yang dikualifikasikan secara penuh). Misalnya, di Listing 20-20 kita membikin sebuah trait buat penampungan hewan (animal shelter) yang mau menamai semua anjing bayi (baby dogs) dengan nama Spot. Kita membikin sebuah trait Animal dengan sebuah fungsi associated non-method bernama baby_name. Trait Animal ini diimplementasikan buat struct Dog, yang mana padanya kita juga menyediakan sebuah fungsi associated non-method baby_name secara langsung.

Filename: src/main.rs
trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Dog::baby_name());
}
Listing 20-20: Sebuah trait dengan fungsi associated dan sebuah tipe dengan fungsi associated yang namanya sama yang juga mengimplementasikan trait tersebut

Kita mengimplementasikan kode buat menamai semua anak anjing dengan Spot di dalam fungsi associated baby_name yang didefinisikan pada Dog. Tipe Dog juga mengimplementasikan trait Animal, yang mendeskripsikan karakteristik yang dimiliki semua hewan. Anjing bayi dipanggil puppies (anak anjing), dan itu diekspresikan di dalam implementasi trait Animal pada Dog di dalam fungsi baby_name yang diasosiasikan dengan trait Animal.

Di main, kita memanggil fungsi Dog::baby_name, yang mana memanggil fungsi associated yang didefinisikan pada Dog secara langsung. Kode ini mencetak yang berikut ini:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.54s
     Running `target/debug/traits-example`
A baby dog is called a Spot

Output ini bukanlah apa yang kita inginkan. Kita pengen memanggil fungsi baby_name yang merupakan bagian dari trait Animal yang kita implementasikan pada Dog supaya kodenya mencetak A baby dog is called a puppy. Teknik menyebutkan nama trait yang kita pakai di Listing 20-19 tidak bisa membantu di sini; kalau kita ngubah main menjadi kode yang ada di Listing 20-21, kita bakal dapat error kompilasi.

Filename: src/main.rs
trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Animal::baby_name());
}
Listing 20-21: Mencoba memanggil fungsi baby_name dari trait Animal, tapi Rust tidak tahu implementasi mana yang harus dipakai

Karena Animal::baby_name tidak punya parameter self, dan bisa aja ada tipe-tipe lain yang mengimplementasikan trait Animal, Rust tidak bisa nyari tahu implementasi dari Animal::baby_name yang mana yang kita pengen. Kita bakal dapat error compiler ini:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
  --> src/main.rs:20:43
   |
 2 |     fn baby_name() -> String;
   |     ------------------------- `Animal::baby_name` defined here
...
20 |     println!("A baby dog is called a {}", Animal::baby_name());
   |                                           ^^^^^^^^^^^^^^^^^^^ cannot call associated function of trait
   |
help: use the fully-qualified path to the only available implementation
   |
20 |     println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
   |                                           +++++++       +

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

Buat menghilangkan ambiguitas ini dan ngasih tahu Rust kalau kita mau memakai implementasi Animal buat Dog ketimbang implementasi Animal buat tipe lain, kita perlu memakai fully qualified syntax. Listing 20-22 mendemonstrasikan gimana cara memakai fully qualified syntax.

Filename: src/main.rs
trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}
Listing 20-22: Memakai fully qualified syntax buat menyebutkan secara spesifik kalau kita mau manggil fungsi baby_name dari trait Animal seperti yang diimplementasikan pada Dog

Kita menyediakan Rust dengan sebuah anotasi tipe di dalam kurung sudut, yang mana mengindikasikan kalau kita mau memanggil method baby_name dari trait Animal seperti yang diimplementasikan pada Dog dengan mengatakan bahwa kita mau memperlakukan tipe Dog sebagai sebuah Animal buat pemanggilan fungsi ini. Kode ini sekarang bakal mencetak apa yang kita mau:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/traits-example`
A baby dog is called a puppy

Secara umum, fully qualified syntax didefinisikan kayak gini:

<Type as Trait>::function(receiver_if_method, next_arg, ...);

Buat associated functions yang bukan method, tidak bakal ada yang namanya receiver (penerima): yang ada cuma daftar dari argumen-argumen lainnya aja. Kita bisa aja memakai fully qualified syntax di mana-mana setiap kali kita manggil fungsi atau method. Namun, kita dibolehin buat ngilangin (omit) bagian apa pun dari sintaks ini yang mana Rust bisa cari tahu sendiri dari informasi lain di programnya. Kita cuma perlu memakai sintaks yang lebih panjang (verbose) ini di kasus-kasus di mana ada banyak implementasi yang memakai nama yang sama dan Rust butuh bantuan buat mengidentifikasi implementasi mana yang mau kita panggil.

Memakai Supertraits

Terkadang kita mungkin menulis sebuah definisi trait yang bergantung sama trait lain: supaya sebuah tipe bisa mengimplementasikan trait yang pertama, kita mau mewajibkan agar tipe tersebut juga mengimplementasikan trait yang kedua. Kita bakal melakukan ini supaya definisi trait kita bisa memanfaatkan item-item associated (terkait) dari trait yang kedua tersebut. Trait yang diandalkan (relied on) oleh definisi trait kita itu disebut sebagai sebuah supertrait dari trait kita.

Misalnya, katakanlah kita mau membikin sebuah trait OutlinePrint dengan sebuah method outline_print yang bakal mencetak sebuah nilai yang udah diformat sehingga dia dibingkai pakai tanda bintang (asterisks). Yakni, misalkan ada sebuah struct Point yang mengimplementasikan trait Display dari standard library sehingga hasilnya (x, y), maka saat kita memanggil outline_print pada instance Point yang punya nilai 1 buat x dan 3 buat y, dia seharusnya mencetak yang berikut ini:

**********
*        *
* (1, 3) *
*        *
**********

Di dalam implementasi dari method outline_print, kita pengen memakai fungsionalitas dari trait Display. Oleh karena itu, kita perlu menentukan kalau trait OutlinePrint ini cuma bakal bekerja buat tipe-tipe yang juga mengimplementasikan Display dan menyediakan fungsionalitas yang dibutuhin sama OutlinePrint. Kita bisa melakukan itu di definisi trait-nya dengan menentukan OutlinePrint: Display. Teknik ini mirip sama menambahkan sebuah trait bound ke dalam sebuah trait. Listing 20-23 menunjukkan sebuah implementasi dari trait OutlinePrint.

Filename: src/main.rs
use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

fn main() {}
Listing 20-23: Mengimplementasikan trait OutlinePrint yang mewajibkan fungsionalitas dari Display

Karena kita udah menentukan kalau OutlinePrint mewajibkan adanya trait Display, kita jadi bisa memakai fungsi to_string yang mana otomatis diimplementasikan buat tipe apa pun yang mengimplementasikan Display. Kalau kita mencoba memakai to_string tanpa menambahkan titik dua dan menentukan trait Display setelah nama trait-nya, kita bakal dapat error yang bilang kalau tidak ada method bernama to_string yang ditemukan buat tipe &Self di dalam scope saat ini.

Mari kita lihat apa yang terjadi saat kita mencoba mengimplementasikan OutlinePrint pada sebuah tipe yang tidak mengimplementasikan Display, kayak struct Point ini misalnya:

Filename: src/main.rs
use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

Kita dapat error yang bilang kalau Display itu diwajibkan tapi tidak diimplementasikan:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:20:23
   |
20 | impl OutlinePrint for Point {}
   |                       ^^^^^ the trait `std::fmt::Display` is not implemented for `Point`
   |
note: required by a bound in `OutlinePrint`
  --> src/main.rs:3:21
   |
 3 | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint`

error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:24:7
   |
24 |     p.outline_print();
   |       ^^^^^^^^^^^^^ the trait `std::fmt::Display` is not implemented for `Point`
   |
note: required by a bound in `OutlinePrint::outline_print`
  --> src/main.rs:3:21
   |
 3 | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint::outline_print`
 4 |     fn outline_print(&self) {
   |        ------------- required by a bound in this associated function

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

Buat memperbaiki ini, kita mengimplementasikan Display pada Point buat memenuhi (satisfy) batasan yang diwajibkan sama OutlinePrint, kayak gini:

Filename: src/main.rs
trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

use std::fmt;

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

Lalu setelahnya, mengimplementasikan trait OutlinePrint pada Point bakal berhasil di-compile dengan sukses, dan kita bisa memanggil outline_print pada sebuah instance Point buat nampilin dia di dalam sebuah bingkai yang isinya tanda bintang.

Memakai Newtype Pattern Buat Mengimplementasikan External Traits

Di “Mengimplementasikan Trait pada Sebuah Tipe” di Bab 10, kita sempat nyebut soal orphan rule (aturan yatim piatu) yang menyatakan kalau kita cuma dibolehin buat mengimplementasikan sebuah trait pada sebuah tipe kalau entah trait tersebut atau tipe tersebut, atau bahkan keduanya, itu berada di (local to) crate kita sendiri. Kita mungkin aja ngakalin (get around) batasan ini memakai newtype pattern, yang melibatkan pembuatan sebuah tipe baru di dalam sebuah tuple struct. (Kita udah ngebahas tuple structs di “Memakai Tuple Structs Tanpa Field Bernama buat Bikin Tipe yang Beda” di Bab 5.) Tuple struct ini bakal punya satu field dan bertindak sebagai sebuah pembungkus tipis (thin wrapper) di sekitar tipe yang mana mau kita implementasikan trait padanya. Kemudian tipe pembungkus (wrapper type) itu jadinya sifatnya lokal buat crate kita, dan kita bisa mengimplementasikan trait tersebut pada si pembungkus ini. Newtype adalah sebuah istilah yang asalnya dari bahasa pemrograman Haskell. Tidak ada pinalti performa runtime akibat memakai pola ini, dan tipe pembungkus ini bakal dihilangkan (elided) saat compile time.

Sebagai contoh, katakanlah kita mau mengimplementasikan Display pada Vec<T>, yang mana dilarang secara langsung sama si orphan rule karena baik trait Display maupun tipe Vec<T> itu didefinisikan di luar crate kita. Kita bisa membikin sebuah struct Wrapper yang menampung sebuah instance dari Vec<T>; terus kita bisa mengimplementasikan Display pada Wrapper dan lalu memakai nilai Vec<T> tersebut, seperti yang ditunjukkan di Listing 20-24.

Filename: src/main.rs
use std::fmt;

struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let w = Wrapper(vec![String::from("hello"), String::from("world")]);
    println!("w = {w}");
}
Listing 20-24: Membikin sebuah tipe Wrapper di sekitar Vec<String> buat mengimplementasikan Display

Implementasi dari Display memakai self.0 buat ngakses nilai Vec<T> yang ada di dalamnya karena Wrapper adalah sebuah tuple struct dan Vec<T> adalah item yang ada di indeks 0 di dalam tuple tersebut. Terus kita bisa deh memakai fungsionalitas dari trait Display ini pada Wrapper.

Kelemahan dari memakai teknik ini adalah bahwa Wrapper adalah sebuah tipe baru, jadi dia tidak punya method-method dari nilai yang dia tampung di dalamnya. Kita harus mengimplementasikan semua method-method dari Vec<T> secara langsung pada Wrapper sedemikian rupa sehingga method-method itu mendelegasikan panggilannya ke self.0, yang mana bakal memungkinkan kita buat memperlakukan Wrapper persis kayak sebuah Vec<T>. Kalau kita pengen tipe baru ini buat punya setiap method yang dipunyai sama tipe internalnya, mengimplementasikan trait Deref pada si Wrapper buat mengembalikan tipe internalnya bisa jadi sebuah solusi (kita udah ngebahas pengimplementasian trait Deref di “Memperlakukan Smart Pointers seperti Referensi Biasa dengan Deref di Bab 15). Kalau kita tidak pengen tipe Wrapper ini buat punya semua method dari tipe internalnya—misalnya, buat ngebatesin perilaku dari si tipe Wrapper tersebut—maka kita harus mengimplementasikan hanya method-method yang emang kita mau aja secara manual.

Newtype pattern ini juga berguna bahkan ketika tidak ada trait yang terlibat. Mari kita alihkan fokus kita lalu ngelihat beberapa cara tingkat lanjut (advanced ways) buat berinteraksi dengan sistem tipe Rust.

Advanced Types (Tipe Tingkat Lanjut)

Advanced Types (Tipe Tingkat Lanjut)

Sistem tipe Rust punya beberapa fitur yang sejauh ini cuma kita sebut aja tapi belum benar-benar kita bahas. Kita bakal mulai dengan membahas newtypes secara umum sembari kita menyelidiki kenapa newtypes itu berguna sebagai tipe. Terus kita bakal lanjut ke type aliases (alias tipe), sebuah fitur yang mirip sama newtypes tapi punya semantik yang agak beda. Kita juga bakal ngebahas tipe ! dan dynamically sized types (tipe-tipe yang berukuran dinamis).

Memakai Newtype Pattern Buat Keamanan Tipe dan Abstraksi

Bagian ini berasumsi kalau kita udah ngebaca bagian sebelumnya “Memakai Newtype Pattern Buat Mengimplementasikan External Traits”. Newtype pattern (pola tipe baru) ini juga berguna buat hal-hal di luar dari apa yang udah kita bahas sejauh ini, termasuk secara statis menegakkan aturan supaya nilai-nilai tidak pernah tertukar (confused) dan buat mengindikasikan satuan (units) dari sebuah nilai. Kita udah lihat contoh pemakaian newtypes buat mengindikasikan satuan di Listing 20-16: ingat kembali kalau struct Millimeters dan Meters itu membungkus nilai u32 di dalam sebuah newtype. Kalau kita nulis sebuah fungsi dengan parameter bertipe Millimeters, kita tidak bakal bisa men-compile program yang secara tidak sengaja mencoba memanggil fungsi tersebut dengan nilai bertipe Meters atau nilai u32 biasa.

Kita juga bisa memakai newtype pattern buat mengabstraksi beberapa detail implementasi dari sebuah tipe: si tipe baru tersebut bisa ngekspos API public yang mana berbeda dari API milik tipe private yang ada di dalamnya.

Newtypes juga bisa menyembunyikan (hide) implementasi internal. Misalnya, kita bisa aja menyediakan tipe People buat ngebungkus sebuah HashMap<i32, String> yang nyimpan ID seseorang yang diasosiasikan dengan nama mereka. Kode yang memakai People cuma bakal berinteraksi sama API public yang kita sediakan, kayak method buat nambahin string nama ke dalam koleksi People; kode tersebut tidak perlu tahu kalau kita secara internal menaruh nilai ID i32 ke nama-nama tersebut. Newtype pattern adalah cara yang ringan (lightweight) buat mendapatkan enkapsulasi (encapsulation) guna menyembunyikan detail implementasi, yang mana udah kita bahas di “Encapsulation (Enkapsulasi) yang Menyembunyikan Detail Implementasi” di Bab 18.

Membikin Sinonim Tipe dengan Type Aliases

Rust menyediakan kemampuan buat mendeklarasikan type alias (alias tipe) buat ngasih nama lain ke tipe yang udah ada. Buat hal ini kita memakai keyword type. Misalnya, kita bisa membikin alias Kilometers buat i32 kayak gini:

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

    println!("x + y = {}", x + y);
}

Sekarang si alias Kilometers adalah sebuah sinonim buat i32; beda sama tipe Millimeters dan Meters yang kita bikin di Listing 20-16, Kilometers bukanlah sebuah tipe baru yang terpisah. Nilai-nilai yang punya tipe Kilometers bakal diperlakukan persis sama kayak nilai-nilai bertipe i32:

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

    println!("x + y = {}", x + y);
}

Karena Kilometers dan i32 adalah tipe yang sama, kita bisa menjumlahkan nilai dari kedua tipe tersebut dan kita bisa ngoper nilai Kilometers ke fungsi-fungsi yang menerima parameter i32. Namun, dengan memakai cara ini, kita tidak dapetin keuntungan pengecekan tipe (type-checking benefits) yang kita dapat dari pemakaian newtype pattern yang dibahas sebelumnya. Dengan kata lain, kalau kita nyampur aduk (mix up) nilai Kilometers dan i32 di suatu tempat, compiler tidak bakal ngasih kita error.

Kegunaan utama (main use case) dari sinonim tipe adalah buat ngurangin pengulangan (repetition). Misalnya, kita mungkin punya tipe yang panjang sekali kayak gini:

Box<dyn Fn() + Send + 'static>

Nulisin tipe sepanjang ini di signatures fungsi dan sebagai anotasi tipe di semua tempat di kode kita bisa jadi melelahkan dan rentan kena error (error prone). Bayangin aja kalau punya sebuah project yang penuh dengan kode kayak yang ada di Listing 20-25.

fn main() {
    let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));

    fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
        // --snip--
    }

    fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
        // --snip--
        Box::new(|| ())
    }
}
Listing 20-25: Memakai tipe yang panjang di banyak tempat

Sebuah type alias ngebikin kode ini jadi lebih gampang dikelola dengan cara ngurangin pengulangan tersebut. Di Listing 20-26, kita memperkenalkan sebuah alias bernama Thunk buat tipe yang panjang (verbose) tadi dan bisa mengganti semua penggunaan dari tipe tersebut dengan si alias Thunk yang lebih pendek.

fn main() {
    type Thunk = Box<dyn Fn() + Send + 'static>;

    let f: Thunk = Box::new(|| println!("hi"));

    fn takes_long_type(f: Thunk) {
        // --snip--
    }

    fn returns_long_type() -> Thunk {
        // --snip--
        Box::new(|| ())
    }
}
Listing 20-26: Memperkenalkan sebuah type alias, Thunk, buat ngurangin pengulangan

Kode ini jadinya jauh lebih gampang buat dibaca dan ditulis! Milih nama yang punya makna (meaningful) buat type alias juga bisa ngebantu ngomunikasiin niat (intent) kita (thunk adalah kata yang artinya kode yang bakal dievaluasi nanti, jadi ini adalah nama yang tepat buat sebuah closure yang lagi disimpen).

Type aliases juga umumnya dipakai bareng sama tipe Result<T, E> buat ngurangin pengulangan. Coba perhatikan modul std::io di standard library. Operasi I/O (input/output) itu sering sekali mengembalikan Result<T, E> buat menangani situasi-situasi pas operasinya gagal jalan. Library ini punya struct std::io::Error yang merepresentasikan semua kemungkinan error I/O. Banyak dari fungsi-fungsi di std::io bakal mengembalikan Result<T, E> di mana si E itu adalah std::io::Error, kayak misalnya fungsi-fungsi di dalam trait Write ini:

use std::fmt;
use std::io::Error;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
    fn flush(&mut self) -> Result<(), Error>;

    fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}

Bagian Result<..., Error> itu diulang berkali-kali. Karena hal itu, std::io punya deklarasi type alias berikut ini:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

Karena deklarasi ini ada di dalam modul std::io, kita bisa memakai alias fully qualified std::io::Result<T>; yakni, sebuah Result<T, E> dengan si E udah diisi sebagai std::io::Error. Alhasil, signatures dari fungsi-fungsi di trait Write kelihatannya jadi kayak gini deh:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

Type alias ini sangat ngebantu dalam dua hal: dia ngebikin kodenya jadi lebih gampang ditulis dan dia ngasih kita sebuah antarmuka (interface) yang konsisten di seluruh std::io. Karena dia cuma sebuah alias, dia sebenernya cuma Result<T, E> biasa aja, yang berarti kita bisa memakai method apa pun yang berlaku buat Result<T, E> dengannya, sekaligus juga sintaks-sintaks spesial kayak operator ?.

Tipe Never (Tak Pernah) yang Tidak Pernah Mengembalikan Apa-apa

Rust punya tipe spesial bernama ! yang mana dikenal di bahasa gaulnya teori tipe sebagai empty type (tipe kosong) karena dia tidak punya nilai sama sekali. Kita lebih milih menyebutnya never type (tipe tak pernah) karena dia berdiri menempati posisi dari tipe kembalian saat sebuah fungsi tidak bakal pernah mengembalikan (never return) apa-apa. Ini adalah contohnya:

fn bar() -> ! {
    // --snip--
    panic!();
}

Kode ini dibaca sebagai “fungsi bar mengembalikan never.” Fungsi-fungsi yang mengembalikan never disebut diverging functions (fungsi divergen). Kita tidak bisa membikin nilai dari tipe !, jadi si bar itu emang tidak mungkin bisa mengembalikan apa-apa.

Tapi apa gunanya coba sebuah tipe yang kita tidak bisa bikin nilai buatnya sama sekali? Ingat kembali kode dari Listing 2-5, bagian dari game tebak angka; kita udah menaruh sedikit dari bagian kode itu di sini di Listing 20-27.

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        // --snip--

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}
Listing 20-27: Sebuah match dengan arm yang berakhir dengan continue

Waktu itu, kita mengabaikan (skipped over) beberapa detail di kode ini. Di “Konstruk Control Flow match di Bab 6, kita ngebahas kalau match arms itu semuanya wajib mengembalikan tipe yang sama. Jadi, misalnya, kode berikut ini tidak bakal jalan:

fn main() {
    let guess = "3";
    let guess = match guess.trim().parse() {
        Ok(_) => 5,
        Err(_) => "hello",
    };
}

Tipe dari guess di kode ini wajib jadi integer sekaligus string, padahal Rust mewajibkan guess buat cuma punya satu tipe aja. Terus si continue itu ngembaliin apa dong? Gimana ceritanya kita dibolehin buat mengembalikan nilai u32 dari satu arm padahal punya arm lain yang berakhir dengan continue di Listing 20-27?

Seperti yang mungkin udah kita tebak, continue itu punya nilai !. Yaitu, saat Rust menghitung tipe dari guess, dia ngelihat ke kedua match arms tersebut, yang pertama bernilai u32 dan yang terakhir (latter) bernilai !. Karena ! itu tidak bakal pernah bisa punya nilai, Rust memutuskan kalau tipe dari guess adalah u32.

Cara formal buat mendeskripsikan perilaku ini adalah bahwa ekspresi-ekspresi dari tipe ! itu bisa dipaksa (coerced) menjadi tipe apa aja yang lain. Kita dibolehin buat mengakhiri match arm ini dengan continue karena continue tidak mengembalikan nilai; sebagai gantinya, dia memindahkan kontrol kembali ke atas perulangannya (loop), jadi di kasus Err, kita tidak pernah memberikan sebuah nilai ke guess.

Tipe never ini berguna bareng macro panic! juga. Ingat kembali fungsi unwrap yang kita panggil pada nilai-nilai Option<T> buat menghasilkan nilai atau jadi panic dengan definisi seperti ini:

enum Option<T> {
    Some(T),
    None,
}

use crate::Option::*;

impl<T> Option<T> {
    pub fn unwrap(self) -> T {
        match self {
            Some(val) => val,
            None => panic!("called `Option::unwrap()` on a `None` value"),
        }
    }
}

Di kode ini, hal yang sama juga terjadi kayak yang ada di match di Listing 20-27: Rust ngelihat kalau val punya tipe T dan panic! punya tipe !, jadi hasil dari keseluruhan ekspresi match tersebut adalah T. Kode ini bisa jalan karena panic! tidak memproduksi sebuah nilai; dia sekadar memberhentikan programnya. Di kasus None, kita tidak bakal mengembalikan nilai dari unwrap, jadi kode ini itu valid.

Satu ekspresi terakhir yang punya tipe ! adalah sebuah loop:

fn main() {
    print!("forever ");

    loop {
        print!("and ever ");
    }
}

Di sini, loop tersebut tidak pernah berakhir, jadi nilai dari ekspresinya adalah !. Namun, ini tidak bakal benar kalau seandainya kita memasukkan break, karena perulangan tersebut bakal dihentikan pas dia mencapai break.

Tipe yang Berukuran Dinamis (Dynamically Sized Types) dan Trait Sized

Rust perlu tahu detail-detail tertentu tentang tipe-tipenya, seperti seberapa banyak ruang yang harus dialokasikan buat menyimpan sebuah nilai dari suatu tipe tertentu. Hal ini ngebikin satu sudut dari sistem tipenya jadi agak membingungkan pada awalnya: yakni konsep tentang dynamically sized types (tipe-tipe yang berukuran dinamis). Terkadang disebut juga sebagai DSTs atau unsized types (tipe tanpa ukuran tetap), tipe-tipe ini membiarkan kita nulis kode yang memakai nilai-nilai yang mana ukurannya cuma bisa kita ketahui saat runtime.

Mari kita gali detail-detail dari sebuah tipe berukuran dinamis yang bernama str, yang mana udah sering kita pakai di sepanjang buku ini. Yap benar, bukan &str, melainkan si str itu sendiri sendirian, dia itu adalah sebuah DST. Di banyak kasus, kayak misalnya pas lagi nyimpan teks yang dimasukkan (entered) oleh user, kita tidak bisa tahu seberapa panjang string-nya tersebut sampai runtime datang. Itu artinya kita tidak bisa membikin variabel dengan tipe str, dan kita juga tidak bisa memakai argumen bertipe str. Coba perhatikan kode berikut, yang mana tidak bisa jalan:

fn main() {
    let s1: str = "Hello there!";
    let s2: str = "How's it going?";
}

Rust butuh tahu seberapa besar memori yang harus dialokasikan buat nilai apa pun dari suatu tipe tertentu, dan semua nilai dari sebuah tipe itu diwajibkan buat memakai jumlah ruang memori yang sama. Kalau seandainya Rust ngebolehin kita buat nulis kode ini, kedua nilai str ini pasti dituntut buat menempati jumlah ruang yang sama besarnya. Tapi kenyataannya panjang mereka itu berbeda: s1 butuh penyimpanan memori 12 bytes dan s2 butuh 15 bytes. Inilah alasan kenapa mustahil buat ngebikin variabel yang menampung sebuah tipe berukuran dinamis secara langsung.

Terus apa yang harus kita lakuin? Di kasus ini, kita sebenarnya udah tahu jawabannya: kita harus membikin tipe dari s1 dan s2 menjadi &str ketimbang str. Ingat kembali dari “String Slices” di Bab 4 kalau struktur data slice itu cuma sekadar menyimpan posisi awal (starting position) dan panjang (length) dari slice tersebut. Jadi, meskipun &T itu merupakan sebuah nilai tunggal yang menyimpan alamat memori di mana si T tersebut berada, sebuah &str itu terdiri dari dua nilai: alamat dari si str dan juga panjangnya. Alhasil, kita bisa tahu pasti ukuran dari nilai sebuah &str saat compile time: ukurannya adalah dua kali panjang dari sebuah usize. Yaitu, kita selalu tahu ukuran dari sebuah &str, tidak peduli sepanjang apa pun string yang ia tunjuk tersebut. Secara umum, beginilah cara gimana tipe berukuran dinamis itu dipakai di Rust: mereka punya ekstra sedikit metadata yang menyimpan besaran ukuran dari informasi yang dinamis tersebut. Aturan emas (golden rule) dari tipe yang berukuran dinamis adalah kita harus selalu menaruh nilai-nilai dari tipe berukuran dinamis tersebut di balik (behind) semacam pointer.

Kita bisa menggabungkan str dengan berbagai macam pointer lainnya: misalnya, Box<str> atau Rc<str>. Faktanya, kita udah ngelihat hal ini sebelumnya tapi dengan tipe berukuran dinamis yang berbeda: yakni, traits. Setiap trait itu adalah sebuah tipe berukuran dinamis yang bisa kita rujuk (refer to) dengan memakai nama dari trait tersebut. Di “Memakai Trait Objects Buat Mengabstraksi Perilaku Bersama” di Bab 18, kita nyebutin kalau buat memakai traits sebagai trait objects, kita wajib menaruh mereka di balik sebuah pointer, seperti &dyn Trait atau Box<dyn Trait> (Rc<dyn Trait> juga bisa jalan kok).

Buat bekerja sama DSTs, Rust menyediakan trait Sized buat menentukan apakah ukuran dari suatu tipe itu bisa diketahui saat compile time atau tidak. Trait ini secara otomatis diimplementasikan buat semua hal yang ukurannya bisa diketahui saat compile time. Selain itu, Rust juga secara implisit menambahkan batasan (bound) pada Sized ke semua fungsi generik (generic function). Yakni, definisi fungsi generik kayak gini:

fn generic<T>(t: T) {
    // --snip--
}

itu sebenernya bakal diperlakukan seolah-olah kita udah nulis kayak gini:

fn generic<T: Sized>(t: T) {
    // --snip--
}

Secara bawaan (by default), fungsi-fungsi generik cuma bakal bekerja buat tipe-tipe yang ukurannya itu diketahui pas compile time. Namun, kita bisa memakai sintaks spesial berikut ini buat mengendurkan (relax) pembatasan tersebut:

fn generic<T: ?Sized>(t: &T) {
    // --snip--
}

Batasan trait bound pada ?Sized itu artinya “T mungkin Sized atau mungkin juga tidak Sized” dan notasi ini menimpa (overrides) sifat bawaan yang mewajibkan tipe generik buat harus punya ukuran yang udah diketahui pas compile time. Sintaks ?Trait yang punya arti (meaning) kayak gini cuma tersedia buat trait Sized doang ya, tidak bisa dipakai buat traits yang lain.

Perhatikan juga kalau kita juga mengubah tipe dari parameter t dari asalnya T menjadi &T. Karena tipe tersebut bisa aja tidak Sized, kita wajib memakai dia di balik semacam pointer. Di kasus ini, kita milih buat memakai reference (referensi).

Berikutnya, kita bakal membahas tentang fungsi dan closures!

Advanced Functions (Fungsi Tingkat Lanjut) dan Closures

Advanced Functions (Fungsi Tingkat Lanjut) dan Closures

Bagian ini mengeksplorasi beberapa fitur tingkat lanjut yang berkaitan dengan fungsi dan closures, termasuk function pointers (pointer fungsi) dan mengembalikan closures.

Function Pointers

Kita udah ngebahas gimana caranya mengoper closures ke fungsi-fungsi; kita juga bisa mengoper fungsi biasa ke fungsi-fungsi lho! Teknik ini sangat berguna pas kita mau ngoper fungsi yang emang udah kita definisikan sebelumnya ketimbang harus ngebikin closure baru. Fungsi itu bisa dipaksa (coerce) menjadi tipe fn (dengan huruf f kecil), yang mana jangan sampai tertukar sama trait closure Fn. Tipe fn ini disebut sebagai function pointer. Mengoper fungsi memakai function pointers bakal memungkinkan kita buat memakai fungsi sebagai argumen buat fungsi yang lainnya.

Sintaks buat menentukan kalau sebuah parameter itu adalah function pointer itu mirip sama sintaks closures, kayak yang ditunjukin di Listing 20-28, di mana kita udah mendefinisikan sebuah fungsi add_one yang menjumlahkan 1 ke parameternya. Fungsi do_twice menerima dua parameter: sebuah function pointer ke fungsi mana aja yang nerima parameter i32 dan ngembaliin sebuah i32, dan satu nilai i32. Fungsi do_twice ini memanggil fungsi f sebanyak dua kali, mengoper nilai arg ke dalamnya, lalu menjumlahkan kedua hasil pemanggilan fungsi tersebut bersama-sama. Fungsi main lalu memanggil do_twice dengan argumen add_one dan 5.

Filename: src/main.rs
fn add_one(x: i32) -> i32 {
    x + 1
}

fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
    f(arg) + f(arg)
}

fn main() {
    let answer = do_twice(add_one, 5);

    println!("The answer is: {answer}");
}
Listing 20-28: Memakai tipe fn buat nerima function pointer sebagai argumen

Kode ini mencetak The answer is: 12. Kita menentukan kalau parameter f di dalam do_twice itu adalah sebuah fn yang nerima satu parameter bertipe i32 dan mengembalikan sebuah i32. Terus kita bisa manggil f di dalam isi (body) dari do_twice. Di main, kita bisa mengoper nama fungsi add_one sebagai argumen pertama ke do_twice.

Beda sama closures, fn itu adalah sebuah tipe bukannya trait, jadi kita menentukan fn sebagai tipe parameternya secara langsung ketimbang harus mendeklarasikan sebuah parameter tipe generik yang mana trait bounds-nya memakai salah satu dari trait Fn.

Function pointers mengimplementasikan ketiga trait closure sekaligus (Fn, FnMut, dan FnOnce), yang berarti kita bakal selalu bisa mengoper sebuah function pointer sebagai argumen buat fungsi yang membutuhkan sebuah closure. Praktik terbaiknya adalah nulis fungsi memakai tipe generik dan salah satu dari trait-trait closure tersebut supaya fungsi kita bisa nerima fungsi biasa maupun closures.

Namun, ada satu contoh di mana kita mungkin cuma mau nerima tipe fn aja dan tidak mau nerima closures, yaitu pas kita lagi berinteraksi (interfacing) dengan kode eksternal yang emang tidak punya closures: Fungsi-fungsi C bisa menerima fungsi biasa sebagai argumen, tapi C tidak punya fitur closures.

Sebagai contoh buat situasi di mana kita bisa memakai sebuah closure yang didefinisikan secara langsung (inline) atau sebuah fungsi bernama, mari kita lihat penggunaan method map yang disediain sama trait Iterator di standard library. Buat memakai method map buat mengubah sebuah vector angka-angka jadi vector string-string, kita bisa memakai closure, kayak di Listing 20-29.

fn main() {
    let list_of_numbers = vec![1, 2, 3];
    let list_of_strings: Vec<String> =
        list_of_numbers.iter().map(|i| i.to_string()).collect();
}
Listing 20-29: Memakai sebuah closure dengan method map buat mengubah angka jadi string

Atau kita juga bisa memakai fungsi bernama sebagai argumen buat method map ketimbang memakai sebuah closure. Listing 20-30 nunjukin bakal seperti apa kelihatannya kalau pakai cara ini.

fn main() {
    let list_of_numbers = vec![1, 2, 3];
    let list_of_strings: Vec<String> =
        list_of_numbers.iter().map(ToString::to_string).collect();
}
Listing 20-30: Memakai fungsi String::to_string bareng method map buat mengubah angka jadi string

Perhatikan bahwa kita wajib memakai fully qualified syntax (sintaks terkualifikasi penuh) yang udah kita obrolin di “Advanced Traits” karena ada banyak fungsi tersedia yang namanya sama-sama to_string.

Di sini, kita memakai fungsi to_string yang didefinisikan di dalam trait ToString, yang mana udah diimplementasikan secara otomatis sama standard library buat tipe apa aja yang mengimplementasikan trait Display.

Ingat kembali dari “Nilai Enum (Enum Values)” di Bab 6 kalau nama dari setiap varian enum yang kita definisikan itu juga bertindak sebagai sebuah fungsi inisialisasi (initializer function). Kita bisa memakai fungsi-fungsi inisialisasi ini sebagai function pointers yang mengimplementasikan trait-trait closure, yang berarti kita bisa mengoper fungsi inisialisasi tersebut sebagai argumen buat method-method yang nerima closures, kayak yang kelihatan di Listing 20-31.

fn main() {
    enum Status {
        Value(u32),
        Stop,
    }

    let list_of_statuses: Vec<Status> = (0u32..20).map(Status::Value).collect();
}
Listing 20-31: Memakai fungsi inisialisasi enum bareng method map buat ngebikin instance Status dari angka-angka

Di sini, kita ngebikin instance-instance Status::Value memakai setiap nilai u32 di dalam rentang yang mana dipanggil oleh method map dengan jalan memakai fungsi inisialisasi dari varian Status::Value tersebut. Beberapa orang lebih suka gaya penulisan kayak gini dan beberapa orang lainnya lebih milih buat pakai closures. Mereka di-compile jadi hasil kode yang sama kok, jadi pakai aja gaya mana yang lebih jelas dan enak dibaca buat kita.

Mengembalikan Closures

Closures direpresentasikan memakai traits, yang artinya kita tidak bisa ngembaliin closures secara langsung. Di sebagian besar kasus di mana kita mungkin pengen mengembalikan sebuah trait, kita bisa memakai tipe konkret yang emang mengimplementasikan trait tersebut sebagai nilai kembalian fungsinya. Tapi, biasanya kita tidak bisa ngelakuin itu buat closures karena mereka tidak punya tipe konkret yang bisa di-return (dikembalikan); contohnya, kita tidak diizinkan buat memakai function pointer fn sebagai tipe kembalian kalau closure-nya itu menangkap (captures) nilai-nilai apa pun dari scope-nya.

Sebaliknya, kita biasanya bakal memakai sintaks impl Trait yang udah kita pelajarin di Bab 10. Kita bisa ngembaliin tipe fungsi apa aja, dengan memakai Fn, FnOnce dan FnMut. Contohnya, kode di Listing 20-32 bakal sukses di-compile dengan baik.

#![allow(unused)]
fn main() {
fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}
}
Listing 20-32: Mengembalikan sebuah closure dari sebuah fungsi memakai sintaks impl Trait

Namun, kayak yang udah kita sebutin di “Inferensi Tipe dan Anotasi pada Closure” di Bab 13, masing-masing closure itu juga merupakan tipe mereka sendiri yang berbeda-beda. Kalau kita perlu beroperasi sama fungsi-fungsi yang punya signature yang sama tapi implementasi yang berbeda-beda, kita perlu memakai trait object buat mereka. Coba bayangin apa yang terjadi kalau kita nulis kode kayak yang ditunjukin di Listing 20-33.

Filename: src/main.rs
fn main() {
    let handlers = vec![returns_closure(), returns_initialized_closure(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}

fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
    move |x| x + init
}
Listing 20-33: Membikin sebuah Vec<T> berisi closures yang didefinisikan sama fungsi-fungsi yang mengembalikan tipe impl Fn

Di sini kita punya dua fungsi, returns_closure dan returns_initialized_closure, yang mana dua-duanya mengembalikan impl Fn(i32) -> i32. Perhatikan kalau closures yang mereka kembalikan itu berbeda isinya, biarpun mereka mengimplementasikan tipe yang sama. Kalau kita mencoba men-compile ini, Rust bakal ngasih tahu kita kalau ini tidak bisa jalan:

$ cargo build
   Compiling functions-example v0.1.0 (file:///projects/functions-example)
error[E0308]: mismatched types
  --> src/main.rs:2:44
   |
 2 |     let handlers = vec![returns_closure(), returns_initialized_closure(123)];
   |                                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected opaque type, found a different opaque type
...
 9 | fn returns_closure() -> impl Fn(i32) -> i32 {
   |                         ------------------- the expected opaque type
...
13 | fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
   |                                              ------------------- the found opaque type
   |
   = note: expected opaque type `impl Fn(i32) -> i32`
              found opaque type `impl Fn(i32) -> i32`
   = note: distinct uses of `impl Trait` result in different opaque types

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

Pesan error ini ngasih tahu kita kalau kapan pun kita mengembalikan impl Trait, Rust ngebikin tipe opaque (buram/tidak tembus pandang) yang unik, yaitu sebuah tipe di mana kita tidak bisa melihat ke dalam detail dari apa yang dibangun sama Rust buat kita, dan kita juga tidak bisa nebak-nebak tipe apa yang bakal dihasilkan (generated) sama Rust supaya kita bisa nulisin tipenya sendiri secara manual. Jadi meskipun fungsi-fungsi ini sama-sama ngembaliin closures yang mengimplementasikan trait yang sama, yaitu Fn(i32) -> i32, tipe opaque yang dihasilkan oleh Rust buat masing-masing fungsinya itu tetap berbeda. (Ini mirip dengan gimana cara Rust memproduksi tipe konkret yang berbeda-beda untuk blok async yang berbeda bahkan pas mereka punya tipe output yang sama, kayak yang kita lihat di “Bekerja dengan Jumlah Futures yang Sembarang” di Bab 17.) Kita udah pernah lihat solusi buat masalah ini beberapa kali sebelumnya: kita bisa memakai sebuah trait object, kayak di Listing 20-34.

fn main() {
    let handlers = vec![returns_closure(), returns_initialized_closure(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
    Box::new(|x| x + 1)
}

fn returns_initialized_closure(init: i32) -> Box<dyn Fn(i32) -> i32> {
    Box::new(move |x| x + init)
}
Listing 20-34: Membikin sebuah Vec<T> berisi closures yang didefinisikan sama fungsi-fungsi yang mengembalikan Box<dyn Fn> supaya mereka punya tipe yang sama

Kode ini bakal sukses di-compile dengan mulus. Buat tahu lebih lanjut soal trait objects, silakan mengacu ke bagian “Memakai Trait Objects Buat Mengabstraksi Perilaku Bersama” di Bab 18.

Berikutnya, mari kita lihat tentang macro!

Macros

Macros

Kita udah sering memakai macros (makro) kayak println! di sepanjang buku ini, tapi kita belum sepenuhnya mengeksplorasi apa itu macro dan gimana cara kerjanya. Istilah macro mengacu ke sekumpulan fitur di Rust: macros declarative (deklaratif) yang memakai macro_rules! dan tiga macam macros procedural (prosedural):

  • Macros #[derive] kustom yang menentukan kode yang bakal ditambahin pakai atribut derive yang dipakai pada structs dan enums
  • Macros mirip atribut (attribute-like) yang mendefinisikan atribut kustom yang bisa dipakai pada item apa pun
  • Macros mirip fungsi (function-like) yang kelihatannya kayak pemanggilan fungsi tapi beroperasi pada tokens yang ditentukan sebagai argumen mereka

Kita bakal ngebahas masing-masing dari ini secara bergantian, tapi pertama- tama, mari kita bahas kenapa kita butuh macros padahal kita udah punya fungsi biasa.

Perbedaan Antara Macros dan Fungsi

Secara fundamental, macros itu adalah sebuah cara buat nulis kode yang nulisin kode lain, yang mana dikenal dengan istilah metaprogramming (metapemrograman). Di Lampiran C, kita ngebahas soal atribut derive, yang menghasilkan (generates) sebuah implementasi dari berbagai traits buat kita. Kita juga udah memakai macro println! dan vec! di sepanjang buku ini. Semua macros ini melebar (expand) buat menghasilkan lebih banyak kode ketimbang kode yang secara manual kita tulis.

Metaprogramming itu sangat berguna buat ngurangin seberapa banyak kode yang harus kita tulis dan pelihara, yang mana juga merupakan salah satu dari peran fungsi biasa. Namun, macros punya beberapa kekuatan tambahan yang tidak dipunyai sama fungsi biasa.

Sebuah signature fungsi wajib mendeklarasikan jumlah dan tipe dari parameter-parameter yang dipunyai fungsi tersebut. Macros, di sisi lain, bisa menerima jumlah parameter yang bervariasi (variable number of parameters): kita bisa memanggil println!("hello") dengan satu argumen atau println!("hello {}", name) dengan dua argumen. Selain itu, macros itu dijabarkan (expanded) sebelum compiler menginterpretasi (menafsirkan) makna dari kode tersebut, jadi sebuah macro bisa, contohnya, mengimplementasikan sebuah trait pada suatu tipe tertentu. Sebuah fungsi tidak bisa ngelakuin ini, karena dia dipanggil pas runtime dan sebuah trait wajib diimplementasikan pas compile time.

Kelemahan dari mengimplementasikan sebuah macro ketimbang sebuah fungsi adalah kalau definisi macro itu lebih kompleks daripada definisi fungsi karena kita lagi nulis kode Rust yang bertugas buat nulis kode Rust. Gara-gara ketidaklangsungan (indirection) ini, definisi macro itu umumnya lebih susah buat dibaca, dipahami, dan dipelihara ketimbang definisi fungsi.

Perbedaan penting lainnya antara macros dan fungsi adalah kita wajib mendefinisikan macros atau membawa mereka ke dalam scope (ruang lingkup) sebelum kita manggil mereka di dalam sebuah file, berlawanan dengan fungsi biasa yang bisa kita definisikan di mana aja dan panggil dari mana aja.

Macros Declarative dengan macro_rules! buat Metaprogramming Umum

Bentuk macros yang paling sering dipakai di Rust adalah declarative macro (makro deklaratif). Ini kadang-kadang juga disebut sebagai “macros by example” (makro lewat contoh), “macros macro_rules!”, atau sekadar “macros” doang. Pada intinya, macros deklaratif membiarkan kita nulis sesuatu yang mirip sama ekspresi match di Rust. Seperti yang udah dibahas di Bab 6, ekspresi match adalah struktur kontrol yang mengambil sebuah ekspresi, ngebandingin nilai hasil dari ekspresi tersebut dengan serangkaian patterns (pola), lalu menjalankan kode yang berasosiasi sama pattern yang cocok tersebut. Macros juga membandingkan sebuah nilai terhadap patterns yang diasosiasikan dengan kode tertentu: di situasi ini, nilainya itu adalah kode sumber (source code) Rust literal yang dioper ke dalam macro tersebut; lalu patterns tersebut dibandingkan dengan struktur dari kode sumber tadi; dan kode yang terkait dengan setiap pattern itu, pas dia cocok, bakal menggantikan kode yang dioper ke dalam macro tersebut. Ini semua terjadi pas masa kompilasi.

Buat mendefinisikan sebuah macro, kita memakai konstruk macro_rules!. Mari kita telusuri gimana cara memakai macro_rules! dengan melihat gimana si macro vec! itu didefinisikan. Bab 8 mencakup gimana kita bisa memakai macro vec! buat ngebikin sebuah vector baru dengan nilai-nilai tertentu. Misalnya, macro berikut ini ngebikin sebuah vector baru yang berisi tiga buah integer:

#![allow(unused)]
fn main() {
let v: Vec<u32> = vec![1, 2, 3];
}

Kita juga bisa memakai macro vec! buat ngebikin sebuah vector yang berisi dua integer atau sebuah vector yang berisi lima string slices. Kita tidak bakal bisa memakai fungsi biasa buat ngelakuin hal yang sama karena kita tidak bakal tahu jumlah atau tipe nilai-nilainya secara pasti dari awal (up front).

Listing 20-35 nunjukin definisi yang sedikit disederhanakan dari macro vec!.

Filename: src/lib.rs
#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}
Listing 20-35: Sebuah versi yang disederhanakan dari definisi macro vec!

Catatan: Definisi asli dari macro vec! yang ada di standard library juga mengandung kode buat mengalokasikan (pre-allocate) jumlah memori yang tepat dari awal. Kode tersebut adalah sebuah optimasi (optimization) yang tidak kita sertakan di sini supaya contohnya lebih simpel.

Anotasi #[macro_export] mengindikasikan kalau macro ini seharusnya dibikin tersedia di mana pun crate di mana macro ini didefinisikan dibawa ke dalam scope. Tanpa anotasi ini, si macro tersebut tidak bisa dibawa masuk ke dalam scope.

Terus kita memulai definisi macro-nya dengan macro_rules! dan nama dari macro yang lagi kita definisikan ini tanpa pake tanda seru. Nama itu, di kasus ini yakni vec, lalu diikuti dengan kurung kurawal yang menandakan isi (body) dari definisi macro tersebut.

Struktur yang ada di dalam isi dari vec! ini mirip sekali sama struktur dari ekspresi match. Di sini kita punya satu arm (lengan) dengan pattern ( $( $x:expr ),* ), lalu diikuti dengan => dan blok kode yang terkait sama pattern ini. Kalau pattern ini cocok, blok kode yang terkait itu bakal dipancarkan (emitted). Mengingat bahwa ini adalah satu-satunya pattern yang ada di dalam macro ini, berarti cuma ada satu cara valid buat mencocokkan nilainya; pattern lain apa pun bakal nyebabin error. Macros yang lebih kompleks bakal punya lebih dari satu arm.

Sintaks pattern yang valid di dalam definisi macro itu berbeda dengan sintaks pattern yang udah kita bahas di Bab 19 karena patterns pada macro itu dicocokkan terhadap struktur dari kode Rust ketimbang terhadap nilai. Mari kita telusuri apa arti dari potongan-potongan pattern yang ada di Listing 20-29; buat ngelihat sintaks pattern macro yang seutuhnya, silakan lihat Rust Reference.

Pertama-tama kita memakai sepasang tanda kurung biasa (parentheses) buat ngebungkus keseluruhan pattern tersebut. Kita memakai tanda dolar ($) buat mendeklarasikan sebuah variabel di dalam sistem macro tersebut yang bakal menampung kode Rust yang cocok dengan pattern-nya. Tanda dolar ini ngejelasin (makes it clear) kalau ini adalah sebuah variabel macro bukannya variabel Rust biasa. Berikutnya ada sepasang tanda kurung lagi yang menangkap nilai-nilai yang cocok sama pattern yang ada di dalam tanda kurung tersebut buat dipakai di dalam kode penggantinya. Di dalam $() ada $x:expr, yang mana bakal cocok sama sembarang ekspresi Rust dan lalu ngasih nama $x ke ekspresi tersebut.

Koma yang ada di belakang $() mengindikasikan kalau sebuah karakter pemisah (separator) berupa koma secara literal itu wajib muncul di antara setiap instance kode yang cocok sama kode yang ada di dalam $(). Tanda bintang * menentukan kalau pattern tersebut cocok nol atau sekian kali (zero or more) dari apa pun yang ngeduluin (precedes) tanda * tersebut.

Saat kita memanggil macro ini pakai vec![1, 2, 3];, pattern $x bakal cocok sebanyak tiga kali dengan tiga ekspresi 1, 2, dan 3.

Sekarang mari kita lihat pada pola (pattern) yang ada di dalam isi blok kode yang terkait sama arm ini: temp_vec.push() yang ada di dalam $()* bakal dihasilkan buat setiap bagian yang cocok sama $() di dalam pattern sebelumnya sebanyak nol atau lebih kali (tergantung dari seberapa banyak pattern itu cocok). Si $x bakal diganti dengan masing-masing ekspresi yang cocok tadi. Saat kita manggil macro ini pakai vec![1, 2, 3];, kode yang dihasilkan yang bakal menggantikan pemanggilan macro ini bakal jadi kayak gini:

{
    let mut temp_vec = Vec::new();
    temp_vec.push(1);
    temp_vec.push(2);
    temp_vec.push(3);
    temp_vec
}

Kita udah mendefinisikan sebuah macro yang bisa menerima jumlah argumen sebanyak apa pun yang bertipe apa pun dan bisa menghasilkan kode buat ngebikin sebuah vector yang berisi elemen-elemen yang udah kita tentukan.

Buat belajar lebih jauh soal gimana cara nulis macros, silakan konsultasi ke dokumentasi online atau sumber referensi lainnya, kayak misalnya “The Little Book of Rust Macros” yang dimulai sama Daniel Keep dan terus dilanjutin sama Lukas Wirth.

Macros Procedural Buat Menghasilkan Kode dari Atribut

Bentuk kedua dari macros adalah procedural macro (makro prosedural), yang mana bekerjanya lebih mirip kayak sebuah fungsi (dan emang merupakan sebuah tipe prosedur). Procedural macros nerima beberapa kode sebagai input, lalu beroperasi pada kode tersebut, dan akhirnya memproduksi (menghasilkan) beberapa kode sebagai output, bukannya mencocokkan patterns lalu nggantiin kode tersebut dengan kode lain kayak yang dilakuin sama macros deklaratif. Tiga macam macros prosedural ini adalah derive kustom, mirip atribut (attribute-like), dan mirip fungsi (function-like), dan mereka semua bekerja pakai cara yang mirip-mirip.

Pas lagi membikin macros prosedural, definisi-definisi ini wajib berada di dalam crate mereka sendiri (tersendiri) dengan sebuah tipe crate spesial. Ini terjadi karena alasan-alasan teknis yang rumit yang mana kita harap bisa kita hilangkan di masa depan. Di Listing 20-36, kita nunjukin gimana cara mendefinisikan sebuah macro prosedural, di mana some_attribute itu adalah placeholder buat memakai salah satu variasi spesifik dari macro tersebut.

Filename: src/lib.rs
use proc_macro;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
Listing 20-36: Sebuah contoh definisi macro prosedural

Fungsi yang mendefinisikan sebuah macro prosedural menerima sebuah TokenStream sebagai input dan memproduksi sebuah TokenStream sebagai output. Tipe TokenStream ini didefinisikan sama crate proc_macro yang mana emang disertakan bareng Rust dan merepresentasikan sekumpulan dari tokens. Inilah inti (core) dari macro tersebut: kode sumber (source code) yang lagi dioperasikan sama si macro itu ngebentuk input TokenStream tersebut, dan kode yang dihasilkan sama si macro itu adalah output TokenStream hasilnya. Fungsi ini juga punya atribut yang nempel ke dirinya yang menentukan jenis macro prosedural apa yang lagi kita bikin. Kita bisa punya berbagai jenis macros prosedural di dalam satu crate yang sama.

Mari kita ngelihat jenis-jenis dari macros prosedural tersebut. Kita bakal mulai dengan macro derive kustom dan terus ngejelasin sedikit perbedaan kecil (small dissimilarities) yang ngebikin bentuk-bentuk lainnya jadi beda.

Gimana Cara Menulis Sebuah Macro derive Kustom

Mari kita bikin sebuah crate bernama hello_macro yang mendefinisikan sebuah trait bernama HelloMacro dengan satu fungsi associated bernama hello_macro. Ketimbang harus maksa supaya user kita mengimplementasikan trait HelloMacro secara manual buat setiap tipe mereka, kita bakal nyediain sebuah macro prosedural sehingga para pengguna bisa menganotasi tipe mereka dengan #[derive(HelloMacro)] buat dapetin implementasi default (bawaan) dari fungsi hello_macro ini. Implementasi default ini bakal mencetak Hello, Macro! My name is TypeName! di mana TypeName itu adalah nama dari tipe di mana trait ini baru aja didefinisikan. Dengan kata lain, kita bakal nulis sebuah crate yang memungkinkan programmer lain buat nulis kode kayak di Listing 20-37 dengan memakai crate kita.

Filename: src/main.rs
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
}
Listing 20-37: Kode yang bisa ditulis sama user crate kita pas lagi memakai macro prosedural kita

Kode ini bakal mencetak Hello, Macro! My name is Pancakes! pas kita udah selesai. Langkah pertamanya adalah membikin library crate baru, kayak gini:

$ cargo new hello_macro --lib

Berikutnya, di Listing 20-38, kita bakal mendefinisikan trait HelloMacro dan fungsi associated-nya.

Filename: src/lib.rs
pub trait HelloMacro {
    fn hello_macro();
}
Listing 20-38: Sebuah trait sederhana yang bakal kita pakai bareng macro derive

Kita udah punya trait dan fungsinya. Pada titik ini, user dari crate kita udah bisa mengimplementasikan trait ini buat dapetin fungsionalitas yang mereka inginkan, kayak di Listing 20-39.

Filename: src/main.rs
use hello_macro::HelloMacro;

struct Pancakes;

impl HelloMacro for Pancakes {
    fn hello_macro() {
        println!("Hello, Macro! My name is Pancakes!");
    }
}

fn main() {
    Pancakes::hello_macro();
}
Listing 20-39: Gimana kelihatannya kalau users menulis implementasi manual dari trait HelloMacro

Namun, mereka masih butuh buat nulis blok implementasi ini buat setiap tipe yang pengen mereka pakai dengan hello_macro; kita mau nyelametin mereka dari keharusan (having to do) ngelakuin kerjaan ini.

Selain itu, kita belum bisa nyediain fungsi hello_macro dengan implementasi default yang mana bakal bisa nyetak nama dari tipe di mana trait itu diimplementasikan: Rust tidak punya kapabilitas reflection (kemampuan buat meneliti tipe-tipe saat jalan), jadi dia tidak bisa nyari tahu nama dari sebuah tipe pas runtime dateng. Kita butuh macro buat bisa menghasilkan (generate) kode tersebut saat compile time.

Langkah selanjutnya adalah mendefinisikan macro prosedural tersebut. Saat tulisan ini dibikin, macros prosedural wajib berada di dalam crate mereka sendiri (terpisah). Suatu hari nanti, pembatasan ini mungkin bakal diangkat (lifted). Konvensi buat menata struktur (structuring) crates dan crates macro itu kayak gini: buat sebuah crate yang namanya foo, crate macro prosedural derive kustomnya itu dikasih nama foo_derive. Mari kita mulai crate baru bernama hello_macro_derive di dalam project hello_macro kita:

$ cargo new hello_macro_derive --lib

Dua crates kita ini itu saling berkaitan erat (tightly related), jadi kita ngebikin crate macro prosedural ini di dalam directory dari crate hello_macro kita. Kalau kita ngubah definisi trait yang ada di dalam hello_macro, kita juga bakal harus mengubah implementasi macro prosedural di dalam hello_macro_derive. Kedua crates ini harus dipublikasikan (published) secara terpisah, dan programmer-programmer yang memakai crates ini harus menambahkan dua-duanya sebagai dependensi lalu membawa keduanya masuk ke dalam scope. Sebagai gantinya, kita juga bisa sih bikin supaya crate hello_macro itu memakai hello_macro_derive sebagai dependensi terus dia yang mengekspor ulang (re-export) kode macro prosedural tersebut. Namun, cara kita menata struktur project kita ini membiarkan programmer-programmer buat memakai hello_macro bahkan kalau mereka sebenernya tidak butuh sama fungsionalitas derive-nya.

Kita perlu mendeklarasikan crate hello_macro_derive ini sebagai sebuah crate macro prosedural. Kita juga bakal butuh fungsionalitas dari crates syn dan quote, kayak yang bakal kita lihat sebentar lagi, jadi kita perlu nambahin mereka sebagai dependensi. Tambahkan yang berikut ini ke dalam file Cargo.toml untuk hello_macro_derive:

Filename: hello_macro_derive/Cargo.toml
[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"

Buat mulai mendefinisikan macro prosedural ini, tempatin kode dari Listing 20-40 ke dalam file src/lib.rs kita buat crate hello_macro_derive. Perhatikan bahwa kode ini belum bakal bisa di-compile sampai kita udah nambahin definisi buat fungsi impl_hello_macro.

Filename: hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate.
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation.
    impl_hello_macro(&ast)
}
Listing 20-40: Kode yang mana sebagian besar crates macro prosedural butuhkan buat memproses kode Rust

Coba perhatikan kalau kita udah membelah (split) kodenya ke dalam fungsi hello_macro_derive, yang mana bertanggung jawab buat nge-parse (mengurai/menguraikan) si TokenStream, dan fungsi impl_hello_macro, yang mana bertanggung jawab buat mengubah (transforming) struktur pohon sintaksnya (syntax tree): ini ngebikin penulisan sebuah macro prosedural jadi lebih nyaman (convenient). Kode di dalam fungsi yang luar (hello_macro_derive di kasus ini) itu bakal sama aja bunyinya buat hampir sebagian besar dari crates macro prosedural yang pernah kita lihat atau bikin. Kode yang kita tentuin di dalam isi fungsi dalamnya (impl_hello_macro di kasus ini) itu bakal berbeda-beda tergantung dari apa tujuan macro prosedural kita itu sebenernya.

Kita udah memperkenalkan tiga crates baru di sini: proc_macro, syn, dan quote. Crate proc_macro itu emang udah dibawa bareng sama Rust, jadi kita tidak perlu nambahin dia ke dalam dependensi di Cargo.toml kita. Crate proc_macro ini adalah API dari compiler yang memungkinkan kita buat membaca dan memanipulasi kode Rust dari dalam kode kita.

Crate syn itu mem-parse kode Rust yang asalnya dari string menjadi sebuah struktur data yang mana bisa kita operasikan lebih lanjut. Crate quote kemudian mengubah si struktur data syn tersebut kembali (turns back) menjadi kode Rust biasa. Crates ini ngebikin gampang sekali buat mem-parse segala macam kode Rust apa pun yang mungkin pengen kita tangani (handle): nulis parser yang sempurna (full parser) buat bahasa pemrograman Rust bukanlah tugas yang gampang lho.

Fungsi hello_macro_derive bakal dipanggil pas ada user library kita yang mencantumkan #[derive(HelloMacro)] pada sebuah tipe. Hal ini dimungkinkan karena kita udah ngasih anotasi ke fungsi hello_macro_derive di sini pakai proc_macro_derive dan nentuin nama HelloMacro, yang mana nama ini emang cocok sama nama dari trait kita; ini adalah konvensi yang paling banyak diikuti sama macros prosedural.

Fungsi hello_macro_derive pertama-tama mengkonversi input yang asalnya dari sebuah TokenStream menjadi sebuah struktur data yang lalu bisa kita interpretasikan dan kita operasikan lebih lanjut. Di sinilah si syn ikut campur. Fungsi parse di dalam syn mengambil sebuah TokenStream lalu mengembalikan sebuah struct DeriveInput yang merepresentasikan kode Rust yang udah selesai di-parse. Listing 20-41 nunjukin bagian-bagian yang relevan dari struct DeriveInput yang kita dapetin sebagai hasil dari menge-parse string struct Pancakes;.

DeriveInput {
    // --snip--

    ident: Ident {
        ident: "Pancakes",
        span: #0 bytes(95..103)
    },
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Unit,
            semi_token: Some(
                Semi
            )
        }
    )
}
Listing 20-41: Instance DeriveInput yang kita dapetin pas kita mem-parse kode yang punya atribut macro tersebut di Listing 20-37

Bidang (fields) dari struct ini nunjukin kalau kode Rust yang udah kita parse ini adalah sebuah unit struct dengan ident (identifier/pengidentifikasi, yang artinya nama) Pancakes. Ada lebih banyak bidang lagi pada struct ini buat mendeskripsikan segala macam variasi kode Rust; silakan cek dokumentasi syn untuk DeriveInput buat informasi lebih lengkap.

Bentar lagi kita bakal mendefinisikan fungsi impl_hello_macro, yang mana ini bakal jadi tempat di mana kita ngebangun kode Rust baru yang pengen kita masukkan (include) ke dalem programnya. Tapi sebelum kita melakukan itu, perhatikan kalau output dari macro derive kita ini juga merupakan sebuah TokenStream. Si TokenStream kembalian (returned) ini bakal ditambahin ke kode yang dibikin sama user crate kita, jadi pas mereka mengompilasi (compile) crate mereka, mereka bakal dapetin fungsi tambahan yang udah kita sediain di dalam TokenStream yang udah dimodifikasi tadi.

Kita mungkin sempat merhatiin kalau kita tadi memanggil unwrap yang mana bakal ngebikin fungsi hello_macro_derive jadi panic kalau pemanggilan ke fungsi syn::parse tersebut ternyata gagal (fails) di sini. Sangat diwajibkan buat macro prosedural kita supaya jadi panic pas ada error karena fungsi-fungsi proc_macro_derive itu wajib ngembaliin tipe TokenStream ketimbang Result supaya dia bisa patuh (conform) sama API macro prosedural tersebut. Kita udah menyederhanakan contoh ini dengan memakai unwrap; kalau di dalem kode produksi sungguhan (production code), kita harusnya menyediakan pesan error yang jauh lebih spesifik soal apa yang sebenernya salah dengan memakai panic! atau expect.

Nah, sekarang karena kita udah punya kode buat ngubah kode Rust yang udah dianotasikan (annotated Rust code) dari sebuah TokenStream menjadi sebuah instance DeriveInput, mari kita hasilkan kode (generate the code) yang mengimplementasikan trait HelloMacro pada tipe yang dianotasi tersebut, kayak yang ditunjukin di Listing 20-42.

Filename: hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation
    impl_hello_macro(&ast)
}

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let generated = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello, Macro! My name is {}!", stringify!(#name));
            }
        }
    };
    generated.into()
}
Listing 20-42: Mengimplementasikan trait HelloMacro memakai kode Rust yang udah diparse

Kita mendapatkan sebuah instance dari struct Ident yang mengandung nama (identifier) dari tipe yang dianotasi tadi memakai ast.ident. Struct yang ada di Listing 20-41 tadi nunjukin kalau pas kita ngejalanin fungsi impl_hello_macro pada kode yang ada di Listing 20-37, si ident yang bakal kita dapet ini bakal punya field ident dengan nilai "Pancakes". Oleh karenanya variabel name di dalam Listing 20-42 bakal berisi instance struct Ident yang mana, pas dicetak, dia bakal ngasih string "Pancakes", nama dari struct di dalam Listing 20-37.

Macro quote! membiarkan kita mendefinisikan kode Rust yang mau kita kembalikan (return). Compiler mengharapkan sesuatu yang agak beda dengan hasil eksekusi langsung dari macro quote!, jadi kita perlu buat ngubahnya (convert it) menjadi sebuah TokenStream. Kita melakukan hal ini dengan cara memanggil method into, yang mana memakan (consumes) si representasi menengah (intermediate representation) ini lalu mengembalikan sebuah nilai dengan tipe TokenStream yang diwajibkan tersebut.

Macro quote! ini juga nyediain mekanisme templat (templating) yang keren sekali lho: kita bisa memasukkan #name, dan quote! bakal nggantiin itu dengan nilai yang ada di dalam variabel name. Kita bahkan bisa ngelakuin pengulangan yang mirip sama cara kerja macros yang biasa. Silakan cek dokumentasi crate quote buat perkenalan yang komprehensif (thorough).

Kita pengen macro prosedural kita ini buat menghasilkan sebuah implementasi dari trait HelloMacro kita buat tipe yang dianotasi sama si user, yang mana nama tipenya itu bisa kita dapetin dengan memakai #name. Implementasi trait-nya ini punya satu fungsi doang yaitu hello_macro, di mana isinya (body) mengandung fungsionalitas yang pengen kita berikan: yaitu mencetak tulisan Hello, Macro! My name is lalu diikuti dengan nama dari tipe yang dianotasi tersebut.

Macro stringify! yang dipakai di sini emang udah tertanam (built into) di dalam Rust. Dia mengambil (takes) sebuah ekspresi Rust, kayak misalnya 1 + 2, lalu pada saat compile time dia bakal ngerubah ekspresi tersebut jadi string literal (string harfiah), kayak misalnya "1 + 2". Ini beda sama format! atau println!, dua macro ini kan mengevaluasi ekspresinya dan baru kemudian ngubah hasilnya jadi sebuah String. Ada kemungkinan input #name ini bisa jadi adalah sebuah ekspresi yang mana harus dicetak apa adanya secara harfiah (literally), jadi makanya kita memakai stringify!. Memakai stringify! juga ngirit alokasi (saves an allocation) karena dia ngubah #name jadi string literal saat compile time (saat kompilasi).

Pada titik ini, jalanin cargo build seharusnya udah bisa kelar tanpa masalah di dalam hello_macro sekaligus hello_macro_derive. Mari kita pasangkan (hook up) crates ini dengan kode yang ada di dalam Listing 20-37 tadi buat ngelihat gimana macro prosedural kita ini beraksi! Bikin sebuah project binary baru di dalam directory projects kita memakai cargo new pancakes. Kita perlu menambahkan hello_macro dan hello_macro_derive sebagai dependensi (dependencies) di dalam Cargo.toml milik crate pancakes ini. Kalau seandainya kita mempublikasikan versi dari hello_macro dan hello_macro_derive punya kita ke crates.io, mereka bakal jadi kayak dependensi biasa; kalau belum dipublikasikan, kita bisa menspesifikasikan mereka sebagai dependensi bertipe path (jalur ke folder) kayak gini:

[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }

Masukin kode dari Listing 20-37 tadi ke dalam file src/main.rs, terus coba jalanin cargo run: dia seharusnya mencetak Hello, Macro! My name is Pancakes! Implementasi dari trait HelloMacro dari macro prosedural tersebut udah dimasukkan (included) tanpa crate pancakes ini harus mengimplementasikannya sendiri; si atribut #[derive(HelloMacro)] itulah yang udah nambahin implementasi trait tersebut secara otomatis.

Selanjutnya, mari kita telusuri di mana letak perbedaan dari jenis-jenis macro prosedural lain kalau dibandingin dengan macros derive kustom.

Macros Mirip Atribut (Attribute-Like Macros)

Macros yang mirip atribut itu serupa sama macros derive kustom, tapi ketimbang menghasilkan (generating) kode buat atribut derive, macros ini ngebolehin kita buat membikin atribut baru (new attributes). Mereka juga lebih fleksibel: derive itu kan cuma bisa kerja buat structs dan enums doang; sedangkan atribut itu bisa aja diterapkan ke item-item lain, kayak fungsi misalnya. Berikut ini adalah sebuah contoh penggunaan macro yang mirip atribut. Katakanlah kita punya sebuah atribut bernama route yang mana nganotasi fungsi-fungsi pas lagi makek framework aplikasi web (web application framework):

#[route(GET, "/")]
fn index() {

Atribut #[route] ini idealnya bakal didefinisikan sama framework tersebut sebagai sebuah macro prosedural. Signature dari fungsi pendefinisi macro-nya (macro definition function) mungkin bakal kelihatan kayak gini:

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {

Di sini, kita punya dua parameter bertipe TokenStream. Yang pertama itu adalah buat menampung konten dari atributnya: yaitu di bagian GET, "/". Terus yang kedua itu adalah buat isinya (body) si item di mana atribut itu ditempelkan (attached to): di contoh kasus ini, buat si fn index() {} beserta sisa isi dari fungsi tersebut.

Selain perbedaan tadi, macros mirip atribut ini bekerja dengan cara yang sama persis dengan macros derive kustom: kita membikin sebuah crate dengan tipe crate proc-macro lalu kita mengimplementasikan fungsi yang tugasnya buat ngehasilin kode (generates the code) yang pengen kita bikin!

Macros Mirip Fungsi (Function-Like Macros)

Macros mirip fungsi itu mendefinisikan macros yang kelihatannya kayak pemanggilan fungsi. Sama kayak macros macro_rules!, macros ini itu lebih luwes (fleksibel) daripada fungsi biasa; misalnya, mereka bisa nerima jumlah argumen yang bervariasi (unknown number of arguments). Namun, macros macro_rules! itu cuma bisa didefinisikan memakai sintaks yang mirip match (match-like syntax) yang udah kita obrolin di “Macros Declarative dengan macro_rules! buat Metaprogramming Umum” tadi sebelumnya. Macros yang mirip fungsi ini mengambil satu parameter TokenStream dan kemudian di definisinya dia memanipulasi TokenStream tersebut memakai kode Rust sama persis kayak apa yang dilakuin sama kedua tipe macros prosedural sebelumnya. Sebuah contoh dari macro mirip fungsi adalah macro sql! yang mana mungkin bakal dipanggil kayak gini:

let sql = sql!(SELECT * FROM posts WHERE id=1);

Macro ini bakal mem-parse pernyataan (statement) SQL yang ada di dalamnya dan ngecek apakah secara sintaks itu (syntactically) benar atau tidak, yang mana itu merupakan pemrosesan yang jauh lebih ribet (complex processing) ketimbang apa yang sanggup dilakukan sama macro tipe macro_rules!. Definisi macro sql! ini kelihatannya bakal kayak gini:

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {

Definisi ini mirip sekali kan sama signature dari macro derive kustom tadi: kita menerima tokens yang ada di dalam tanda kurung tersebut (parentheses) dan terus kita ngembaliin (return) kode yang emang pengen kita hasilin (generate).

Ringkasan

Fiuh! (Whew!) Nah sekarang kita punya segelintir fitur Rust baru di sabuk perkakas (toolbox) kita yang mana kemungkinan besar kita tidak bakal sering memakainya, tapi seenggaknya kita bakal tahu kalau mereka itu tersedia di situasi-situasi tertentu yang amat spesifik. Kita udah memperkenalkan beberapa topik yang kompleks (complex topics) sehingga saat kita kebetulan menjumpainya di dalam saran-saran pesan error (error message suggestions) atau di dalam kode orang lain, kita bakal sanggup buat mengenali berbagai macam konsep dan sintaks ini. Gunakan bab ini sebagai sebuah pedoman referensi (reference guide) buat ngebimbing kita nemuin solusinya.

Berikutnya, kita bakal mempraktikkan (put into practice) segala macam hal yang udah kita bicarain di sepanjang buku ini dengan cara mengerjakan satu project lagi!

Project Akhir: Membikin Sebuah Multithreaded Web Server

Udah jadi perjalanan panjang ya, tapi akhirnya kita nyampe juga di akhir buku ini. Di bab ini, kita bakal ngebikin satu project lagi bareng-bareng buat mendemonstrasikan beberapa konsep yang udah kita bahas di bab-bab akhir, sekaligus juga ngeringkas (recap) pelajaran-pelajaran yang ada sebelumnya.

Buat project akhir kita, kita bakal membikin sebuah web server yang ngomong “hello” dan kelihatannya kayak Gambar 21-1 di web browser.

Berikut ini adalah rencana kita buat ngebangun web server-nya:

  1. Belajar sedikit soal TCP dan HTTP.
  2. Mendengarkan (listen) koneksi-koneksi TCP di dalam sebuah socket.
  3. Mem-parse sejumlah kecil requests (permintaan) HTTP.
  4. Membikin sebuah response (respons/balasan) HTTP yang layak.
  5. Ningkatin throughput (kemampuan ngelayanin banyak permintaan) dari server kita dengan sebuah thread pool.

hello dari rust

Gambar 21-1: Project akhir yang kita buat bareng-bareng

Sebelum kita mulai, kita harus nyebutin dua hal detail. Pertama, metode yang bakal kita pakai ini bukanlah cara terbaik buat ngebangun sebuah web server pakai Rust. Anggota komunitas udah memublikasikan beberapa crates yang siap buat dipakai di production (production-ready) yang tersedia di crates.io yang mana menyediakan implementasi web server dan thread pool yang jauh lebih lengkap ketimbang apa yang bakal kita bikin ini. Namun, niat kita di bab ini adalah ngebantu kita buat belajar, bukannya ngambil jalan yang gampang. Karena Rust itu adalah bahasa pemrograman sistem (systems programming language), kita bisa milih tingkat abstraksi yang pengen kita kerjain dan kita bisa turun ke tingkat (level) yang lebih rendah ketimbang apa yang mungkin atau praktis buat dilakukan di bahasa pemrograman lainnya.

Kedua, kita tidak bakal memakai async dan await di sini. Ngebangun sebuah thread pool aja itu udah tantangan yang lumayan gede sendirian, jadi kita tidak perlu nambah-nambahin keruwetan dengan ngebangun sebuah runtime async sekalian! Walaupun begitu, kita bakal ngasih catetan gimana sih async dan await mungkin bisa diterapin ke beberapa permasalahan yang bakal kita temui di bab ini. Pada akhirnya, seperti yang udah kita sebutin balik di Bab 17, banyak runtimes async yang juga memakai thread pools buat mengelola kerjaan mereka kok.

Oleh karena itu, kita bakal menulis sebuah HTTP server dasar dan thread pool secara manual supaya kita bisa belajar ide-ide umum dan teknik-teknik di balik crates yang mana mungkin bakal kita pakai di masa depan.

Membikin Web Server yang Single-Threaded

Membikin Web Server yang Single-Threaded

Kita bakal mulai dengan ngebikin supaya sebuah web server single-threaded (satu utas/thread) bisa jalan. Sebelum kita mulai, mari kita lihat ikhtisar (overview) kilat soal protokol-protokol yang dilibatin di dalam pembuatan web servers. Detail-detail dari protokol ini emang ada di luar dari cakupan buku ini, tapi ikhtisar singkat ini bakal ngasih kita informasi yang kita butuhkan.

Dua protokol utama yang dilibatin di dalam web servers adalah Hypertext Transfer Protocol (HTTP) dan Transmission Control Protocol (TCP). Kedua protokol ini adalah protokol request-response (minta dan balas), yang artinya sebuah client (klien) ngirim requests dan sebuah server ngedengerin (listens to) requests tersebut lalu ngasih sebuah response (respons/balasan) ke si client tadi. Konten (isi) dari requests dan responses ini didefinisikan sama protokol-protokol tersebut.

TCP itu adalah protokol tingkat lebih rendah (lower-level protocol) yang mendeskripsikan detail-detail soal gimana informasi itu nyampe dari satu server ke server lainnya tapi dia tidak menentukan secara spesifik apa sebenarnya bentuk informasi itu. HTTP ngebangun di atas (builds on top of) TCP dengan cara mendefinisikan konten dari requests dan responses tersebut. Secara teknis itu mungkin aja buat memakai HTTP pakai protokol selain TCP, tapi di mayoritas kasus yang ada, HTTP ngirim datanya lewat TCP. Kita bakal kerja dengan barisan bytes mentah (raw bytes) dari requests dan responses TCP dan HTTP ini.

Mendengarkan Koneksi TCP

Web server kita perlu mendengarkan (listen to) sebuah koneksi TCP, jadi itulah bagian pertama yang bakal kita kerjain. Standard library menawarkan sebuah modul std::net yang membiarkan kita buat ngelakuin ini. Mari kita bikin project baru pakai cara yang biasa:

$ cargo new hello
     Created binary (application) `hello` project
$ cd hello

Sekarang masukin kode yang ada di Listing 21-1 ke dalam src/main.rs buat memulai. Kode ini bakal dengerin di alamat lokal 127.0.0.1:7878 nyari streams (aliran data) TCP yang lagi mau masuk (incoming). Pas dia dapat sebuah stream yang masuk, dia bakal mencetak Connection established!.

Filename: src/main.rs
use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        println!("Connection established!");
    }
}
Listing 21-1: Mendengarkan streams yang masuk dan mencetak sebuah pesan pas kita nerima sebuah stream

Memakai TcpListener, kita bisa ngedengerin nyari koneksi-koneksi TCP di alamat 127.0.0.1:7878. Di alamat tersebut, bagian sebelum titik dua itu adalah alamat IP yang merepresentasikan komputer kita (ini sama aja di setiap komputer dan tidak merepresentasikan spesifik komputernya si penulis ya), dan 7878 itu adalah port-nya. Kita udah milih port ini karena dua alasan: HTTP itu umumnya tidak diterima di port ini, jadi server kita ini punya kemungkinan kecil buat berkonflik sama web server lain yang mungkin lagi jalan di mesin komputer kita, dan 7878 itu adalah kata rust yang diketik di telepon jadul.

Fungsi bind (ikat) di dalam skenario ini bekerja kayak fungsi new di mana dia bakal mengembalikan (return) sebuah instance TcpListener yang baru. Fungsi ini dikasih nama bind karena, di dunia jaringan komputer (networking), nyambung ke sebuah port buat mulai mendengarkan ke sana itu dikenal dengan istilah “binding to a port” (ngikat ke sebuah port).

Fungsi bind ini mengembalikan sebuah Result<T, E>, yang mana mengindikasikan kalau proses binding ini mungkin aja gagal (fail). Misalnya, kalau kita ngejalanin dua instance dari program kita sehingga ada dua program yang dengerin di port yang sama persis. Karena kita ini lagi nulis sebuah server super dasar (basic) cuma buat tujuan pembelajaran aja, kita tidak bakal ambil pusing buat menangani (handling) error-error semacam ini; sebaliknya, kita bakal memakai unwrap buat ngehentiin programnya kalau error-error ini emang kejadian.

Method incoming pada TcpListener mengembalikan sebuah iterator yang ngasih kita serangkaian streams (lebih spesifiknya, streams dari tipe TcpStream). Sebuah stream tunggal itu merepresentasikan sebuah koneksi terbuka (open connection) antara si client sama si server. Sebuah connection (koneksi) itu adalah nama buat proses pemanggilan request dan response secara utuh di mana si client nyambung ke server-nya, si server ngehasilin sebuah response, lalu si server menutup koneksi tersebut. Makanya, kita bakal membaca (read) dari TcpStream ini buat tahu apa yang dikirim sama si client dan lalu menulis (write) response kita ke stream tersebut buat ngirim datanya kembali ke si client. Secara umum, loop (perulangan) for ini bakal memproses setiap koneksi secara bergantian dan menghasilkan serangkaian streams buat kita tanganin.

Buat sekarang, cara penanganan kita terhadap stream ini adalah dengan memanggil unwrap buat menghentikan (terminate) program kita kalau ternyata stream tersebut punya error apa pun; kalau tidak ada error sama sekali, programnya bakal mencetak sebuah pesan. Kita bakal nambahin fungsionalitas yang lebih buat kasus di mana program sukses (success case) di listing berikutnya. Alasan kenapa kita mungkin nerima error dari method incoming pas seorang client nyambung ke server adalah karena kita itu sebenarnya bukan beriterasi melewati koneksi-koneksi (connections). Sebaliknya, kita itu lagi beriterasi ngelewatin percobaan-percobaan koneksi (connection attempts). Koneksinya bisa aja tidak sukses karena banyak alasan, yang mana kebanyakan dari alasan itu spesifik sama sistem operasi (operating system specific) masing-masing. Misalnya, banyak sistem operasi yang punya batas seberapa banyak jumlah koneksi terbuka simultan (berbarengan) yang bisa mereka dukung; percobaan koneksi baru yang ngelebihi jumlah tersebut bakal ngehasilin error sampai ada beberapa koneksi yang udah kebuka tadi itu pada ditutupin dulu.

Mari kita cobain buat ngejalanin kode ini! Panggil cargo run di terminal dan lalu buka (load) 127.0.0.1:7878 di sebuah web browser. Web browser-nya seharusnya nampilin pesan error kayak “Connection reset” karena emang si server-nya saat ini belum ngirim balik data apa pun. Tapi pas kita ngelihat ke terminal kita, kita harusnya bisa ngelihat beberapa pesan yang tadi dicetak pas si browser ini nyambung ke servernya!

     Running `target/debug/hello`
Connection established!
Connection established!
Connection established!

Kadang-kadang kita bakal ngelihat banyak pesan yang dicetak cuma buat satu request dari browser; alasannya mungkin adalah karena si browser itu lagi ngebikin request buat halaman utamanya sekaligus ngebikin request juga buat resources (sumber daya) lainnya, kayak misalnya icon favicon.ico yang suka muncul di tab browser itu.

Bisa juga karena si browser ini lagi mencoba buat nyambung ke server berkali-kali karena si server tidak ngasih respons data apa-apa. Saat stream keluar dari scope dan di-drop (dibuang) di akhir perulangannya, koneksinya secara otomatis ditutup (closed) sebagai bagian dari implementasi dari method drop tersebut. Browser kadang-kadang nanganin koneksi yang ditutup ini dengan cara mencoba ulang (retrying), karena ya mungkin masalahnya itu cuma sementara.

Browser juga kadang-kadang ngebuka koneksi yang banyak ke sebuah server tanpa ngirim permintaan apa-apa, jadi kalau nanti mereka memang ngirim request, request-nya itu bisa kejadian lebih cepet. Pas ini kejadian, server kita bakal bisa ngelihat koneksi tersebut, terlepas dari apakah ada request apa enggak yang dikirim liwat koneksi itu. Versi-versi dari browser berbasis Chrome misalnya banyak yang ngelakuin ini; kita bisa menonaktifkan optimasi ini dengan cara memakai mode private browsing (samaran) atau dengan memakai web browser yang beda.

Faktor yang penting adalah kita udah berhasil dapetin sebuah pegangan (handle) ke sebuah koneksi TCP!

Inget ya buat ngestop (stop) programnya dengan cara neken ctrl-C pas kita udah selesai ngejalanin suatu versi kode tertentu. Terus nyalain ulang programnya dengan cara manggil perintah cargo run setiap kali habis ngebikin rangkaian perubahan kode (code changes) buat mastiin kalau kita emang ngejalanin kodenya yang paling baru.

Membaca Request (Permintaan)

Mari kita implementasikan fungsionalitas buat membaca request yang asalnya dari browser! Buat misahin urusan (concerns) dari yang awalnya dapet koneksi terlebih dahulu lalu setelah itu baru ngambil beberapa tindakan tertentu sama koneksi tersebut, kita bakal bikin sebuah fungsi baru yang khusus buat memproses koneksi (processing connections). Di dalam fungsi handle_connection yang baru ini, kita bakal membaca data yang asalnya dari TCP stream tersebut dan lalu mencetaknya supaya kita bisa ngelihat data apa yang lagi dikirim sama si browser. Ubah kodenya supaya kelihatan kayak yang ada di Listing 21-2.

Filename: src/main.rs
use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    println!("Request: {http_request:#?}");
}
Listing 21-2: Membaca dari TcpStream dan mencetak data tersebut

Kita ngebawa (bring) std::io::prelude dan std::io::BufReader ke dalam scope buat dapetin akses ke trait-trait dan tipe-tipe yang membiarkan kita buat membaca dari dan nulis ke stream tersebut. Di dalam loop for yang ada di fungsi main, ketimbang kita sekadar nyetak pesan yang bilang kalau kita udah dapat koneksi, sekarang kita memanggil fungsi handle_connection yang baru lalu mengoper stream tersebut ke dalamnya.

Di dalam fungsi handle_connection, kita ngebikin sebuah instance BufReader yang membungkus referensi ke si stream tersebut. BufReader ini nambahin fitur buffering (penyangga) dengan cara mengatur (managing) pemanggilan-pemanggilan ke method-method dari trait std::io::Read secara otomatis buat kita.

Kita ngebikin sebuah variabel bernama http_request buat ngumpulin (collect) baris-baris dari request yang dikirim sama si browser ke server kita. Kita mengindikasikan kalau kita mau ngumpulin baris-baris tersebut ke dalam sebuah vector dengan cara nambahin anotasi tipe Vec<_>.

BufReader mengimplementasikan trait std::io::BufRead, yang mana menyediakan method lines. Method lines ini mengembalikan sebuah iterator dari tipe Result<String, std::io::Error> dengan cara ngebelah-belah (splitting) aliran datanya (stream of data) setiap kali dia ngelihat sebuah byte newline (baris baru). Buat bisa dapat tiap String-nya, kita memakai map dan unwrap pada masing-masing Result. Tipe Result ini mungkin aja berisi sebuah error kalau datanya ternyata bukan UTF-8 yang valid atau kalau sekiranya ada masalah pas membaca dari stream tersebut. Sekali lagi, di program level production kita seharusnya nanganin error-error kayak gini dengan jauh lebih cakep (gracefully), tapi kita lebih milih buat ngestop aja programnya di kasus error ini demi menyederhanakan contoh.

Si browser nandain akhir (end) dari sebuah request HTTP dengan cara ngirimin dua karakter baris baru (newline) secara berurutan (in a row), jadi supaya kita bisa dapet satu request dari si stream, kita ngambil barisnya terus-terusan sampai kita dapet baris yang mana itu adalah string yang kosong. Setelah kita ngumpulin semua barisnya ke dalam vector, kita nyetak mereka pakai pretty debug formatting (format debug cantik yang gampang dibaca) supaya kita bisa lihat sendiri instruksi-instruksi apa aja yang lagi dikirim sama si web browser ke server kita.

Mari kita cobain kode ini! Jalanin programnya dan coba lakuin request (ngunjungin alamat) pakai web browser lagi. Perhatikan kalau kita bakal tetep dapet halaman error di web browsernya ya, tapi sekarang output dari program kita yang ada di dalam terminal bakal kelihatan kira-kira kayak gini:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/hello`
Request: [
    "GET / HTTP/1.1",
    "Host: 127.0.0.1:7878",
    "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0",
    "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
    "Accept-Language: en-US,en;q=0.5",
    "Accept-Encoding: gzip, deflate, br",
    "DNT: 1",
    "Connection: keep-alive",
    "Upgrade-Insecure-Requests: 1",
    "Sec-Fetch-Dest: document",
    "Sec-Fetch-Mode: navigate",
    "Sec-Fetch-Site: none",
    "Sec-Fetch-User: ?1",
    "Cache-Control: max-age=0",
]

Tergantung dari browser apa yang kita pake, kita mungkin dapat output yang agak sedikit beda. Sekarang setelah kita udah nyetak isi dari request datanya, kita bisa paham kan alasan kenapa kita dapat koneksi berkali-kali dari satu request web browser kalau kita ngelihat path (jalur) yang ada setelah kata GET di baris paling pertama dari request tersebut. Kalau koneksi-koneksi yang berulang (repeated connections) itu semuanya lagi nge-request /, kita jadi tahu kalau browser-nya itu lagi nyoba buat ngambil (fetch) / berkali-kali karena dia tidak dapat respons apa-apa dari program kita.

Mari kita bedah dan perinci (break down) data request ini supaya kita benar-benar ngerti apa yang lagi diminta (asking of) sama si browser dari program kita ini.

Ngelihat Lebih Dekat pada Request HTTP

HTTP itu adalah protokol yang berbasis teks (text-based protocol), dan sebuah request (permintaan) itu ngebentuk format kayak gini:

Method Request-URI HTTP-Version CRLF
headers CRLF
message-body

Baris yang paling pertama itu disebut dengan request line (baris permintaan) yang menampung informasi tentang apa yang lagi di-request sama si client. Bagian pertama dari si request line ini mengindikasikan method (metode) apa yang lagi dipakai, kayak misalnya GET atau POST, yang mana mendeskripsikan gimana caranya si client ini melakukan request tersebut. Client kita (yakni si web browser tadi) itu memakai sebuah request GET, yang berarti dia itu lagi minta dikasihin suatu informasi.

Bagian yang selanjutnya di request line tersebut adalah /, yang mana mengindikasikan uniform resource identifier (URI) yang lagi di-request sama client tersebut: sebuah URI itu tuh hampir sekali, tapi tidak sepenuhnya sama persis, dengan sebuah uniform resource locator (URL). Perbedaan antara URI dan URL ini tidaklah penting buat tujuan pembelajaran kita di bab ini, tapi spesifikasi HTTP (HTTP spec) memakai istilah URI, jadi kita bisa dalam hati aja men-substitusikan (menggantikan) URL jadi URI di sini.

Bagian terakhirnya adalah versi HTTP yang lagi dipakai sama si client, dan kemudian si request line tersebut diakhiri pakai urutan CRLF (CRLF sequence). (CRLF singkatan dari carriage return dan line feed, yang mana ini adalah istilah yang asalnya dari jaman mesin tik lho!) Urutan CRLF ini juga bisa ditulis sebagai \r\n, di mana \r itu adalah si carriage return dan \n itu adalah si line feed (baris baru). Urutan CRLF ini memisahkan bagian request line dari sisa (rest) data request yang lainnya. Perhatikan kalau pas CRLF ini dicetak, kita ngelihatnya kayak dimulainya baris baru kan ketimbang tulisan \r\n.

Ngelihat ke data request line yang kita dapet dari hasil ngejalanin program kita sejauh ini, kita ngelihat kalau GET itu adalah method-nya, / itu adalah request URI-nya, dan HTTP/1.1 itu adalah versinya.

Setelah baris pertama (request line) tadi, baris-baris tersisa yang diawali dengan kata Host: dan seterusnya itu semuanya adalah bagian headers. Requests tipe GET itu sama sekali tidak punya body (badan/isi pesan).

Coba deh bikin sebuah request (permintaan) dari browser yang beda atau coba minta sebuah alamat yang berbeda, kayak misalnya 127.0.0.1:7878/test, dan perhatikan aja gimana isi dari data request-nya itu berubah.

Nah, sekarang karena kita udah paham apa yang sebenarnya lagi diminta sama si browser, mari kita coba buat ngirim balik beberapa data!

Nulis Sebuah Response (Respons)

Kita bakal mengimplementasikan cara mengirim data sebagai sebuah response (respons/balasan) terhadap request yang dibikin client (client request). Responses itu punya bentuk format kayak gini:

HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body

Baris pertama itu disebut status line (baris status) yang mengandung informasi versi HTTP yang dipakai di dalam response ini, sebuah kode status berupa angka (numeric status code) yang nge-ringkas (summarizes) apa hasil akhir dari request-nya, dan juga reason phrase (frasa alasan) yang menyediakan deskripsi teks dari kode status tersebut. Setelah urutan CRLF pertama adalah headers (kalau ada), dan diikuti oleh satu urutan CRLF lagi, dan barulah kemudian body (isi badan) dari si response tersebut.

Berikut ini adalah sebuah contoh response yang memakai versi HTTP 1.1, punya kode status 200, beserta reason phrase OK, tidak punya headers, dan tidak punya body:

HTTP/1.1 200 OK\r\n\r\n

Kode status 200 itu adalah respons standar buat bilang sukses (success response). Teks barusan adalah sebuah response HTTP sukses yang ukurannya sekecil mungkin. Mari kita tulis ini ke dalam stream (aliran data) kita sebagai response ke request yang sukses! Dari dalam fungsi handle_connection tadi, silakan hapus kode println! yang fungsinya buat nyetak data request tadi dan terus ganti pakai kode yang ada di Listing 21-3.

Filename: src/main.rs
use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let response = "HTTP/1.1 200 OK\r\n\r\n";

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-3: Menulis sebuah response HTTP sukses yang imut (tiny) ke dalam stream

Baris baru yang pertama mendefinisikan variabel response yang bakal menyimpan data pesan sukses kita. Terus kita panggil method as_bytes pada variabel response kita ini buat mengkonversi data string tadi jadi kumpulan bytes. Method write_all pada variabel stream itu menerima nilai tipe &[u8] (array slice of bytes) dan dia bakal ngirim bytes tersebut secara langsung nyusurin (down) koneksi tersebut. Karena operasi write_all ini berpotensi gagal, kita memakai unwrap pada segala result error kayak sebelumnya. Sekali lagi ya, di aplikasi yang rill (real application) kita seharusnya nambahin error handling (penanganan error) di sini.

Dengan adanya perubahan-perubahan ini, mari kita jalanin kode kita lalu kita bikin sebuah request lewat browser. Kita udah tidak lagi mencetak data apa pun ke terminal ya, jadi kita tidak bakal ngelihat output apa-apa selain output dari Cargo. Pas kita memuat (load) alamat 127.0.0.1:7878 di sebuah web browser, kita seharusnya ngedapetin halaman putih (blank page) ketimbang halaman error. Kita baru aja melakukan hardcode (kode manual) buat menerima request HTTP lalu mengirimkan sebuah response secara utuh!

Mengembalikan HTML yang Asli (Real HTML)

Mari kita implementasikan fungsionalitas buat ngembaliin lebih dari sekadar halaman kosong (blank page). Silakan bikin sebuah file baru bernama hello.html di directory utama (root) dari project kita, inget ya bukan di dalem folder src. Kita bisa naruh (input) kode HTML apa aja yang kita mau kok; Listing 21-4 nunjukin salah satu kemungkinan isinya.

Filename: hello.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Hello!</h1>
    <p>Hi from Rust</p>
  </body>
</html>
Listing 21-4: Sebuah contoh file HTML sampel buat di-return (dikembalikan) di dalam sebuah response

Ini adalah sebuah dokumen HTML5 yang sangat minimal yang cuma ada heading (judul) dan sedikit teks doang. Buat ngembaliin kode ini dari server saat ada request yang diterima, kita bakal memodifikasi handle_connection seperti yang ditunjukin di Listing 21-5 supaya dia ngebaca file HTML tersebut, menambahkannya ke dalam response kita sebagai isi dari body, lalu mengirimkannya.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};
// --snip--

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let status_line = "HTTP/1.1 200 OK";
    let contents = fs::read_to_string("hello.html").unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-5: Mengirim isi konten (contents) dari hello.html sebagai body (isi pesan) dari response tersebut

Kita udah menambahkan fs ke dalam statement use buat membawa filesystem module (modul file sistem) kepunyaan standard library masuk ke dalam scope. Kode buat membaca isi dari sebuah file ke dalam sebuah string seharusnya udah kelihatan familier; kita sempat memakainya pas kita lagi membaca isi konten dari sebuah file buat project I/O kita balik pas di Listing 12-4.

Selanjutnya, kita memakai format! buat menambahkan konten file tersebut sebagai body dari response sukses kita tadi. Supaya pasti kalau ini adalah response HTTP yang benar-benar valid, kita juga menambahkan header Content-Length yang mana diatur isinya supaya ngepas sama ukuran (size) dari body dari response kita, di kasus ini berarti ukurannya sama dengan ukuran file hello.html.

Coba jalanin kode ini pakai cargo run terus muat (load) 127.0.0.1:7878 di browser kita; kita harusnya bisa ngelihat HTML kita dimuat (rendered)!

Saat ini, kita emang lagi mengabaikan data request yang ada di http_request dan kita cuma tanpa syarat (unconditionally) mengirimkan kembali isi (contents) dari file HTML tersebut. Itu artinya kalau kita mencoba nge-request halaman 127.0.0.1:7878/something-else (apa-aja-lainnya) di browser kita, kita bakal tetep dapet balasan response HTML yang ini-ini juga. Saat ini, server kita ini sifatnya sangat terbatas (very limited) dan masih belum berbuat apa yang mayoritas web server benar-benar lakuin. Kita mau mengkustomisasi responses kita supaya bergantung pada si request tersebut lalu cuma mengirimkan balik file HTML itu untuk request / yang formasinya bener (well-formed request).

Memvalidasi Request dan Merespons Secara Selektif

Sekarang ini, web server kita ini bakal selalu nge-return (ngembaliin) file HTML kita ini tidak peduli apa pun yang diminta sama client-nya. Mari kita tambahin fungsionalitas buat mengecek apakah browser ini benar-benar lagi nge-request rute / sebelum nge-return si file HTML, dan terus dia bakal mengembalikan pesan error kalau browser tersebut mencoba minta apa pun yang lainnya. Buat ngelakuin ini, kita perlu memodifikasi fungsi handle_connection, kayak yang ditunjukin di Listing 21-6. Kode yang baru ini bakal mengecek konten dari request yang baru diterima tersebut dan membandingkannya (against) terhadap rupa dari request GET buat path (rute) / yang kita ketahui (know), lalu nambahin blok if dan else buat memperlakukan (treat) requests itu dengan cara yang berbeda-beda.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}
// --snip--

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    } else {
        // some other request
    }
}
Listing 21-6: Menangani requests buat rute / secara berbeda dari requests yang lain

Kita emang cuma bakal ngelihat baris pertama (first line) doang dari request HTTP-nya, jadi ketimbang baca keseluruhan request-nya dan dimasukin ke dalam sebuah vector, kita cuma manggil method next buat dapat item paling pertama (first item) dari sang iterator. Method unwrap yang pertama ngurusin nilai Option-nya dan langsung ngestop program kalau si iterator tidak punya item apa-apa. Terus unwrap yang kedua menangani nilai Result-nya yang mana efeknya persis sama kayak unwrap yang sempat ada di dalam method map pas di Listing 21-2.

Berikutnya, kita ngecek nilai request_line buat ngebuktiin apakah dia itu sama dengan request line milik sebuah request GET buat path /. Kalau emang sama (it does), blok if tersebut bakal mengembalikan konten dari file HTML kita.

Kalau nilai request_line itu tidak sama (not equal) dengan GET request ke path /, itu artinya kita udah nerima request untuk hal lain. Kita bakal nambahin kode ke dalam blok else sebentar lagi buat membalas segala macam requests lain yang masuk.

Silakan jalankan kode ini sekarang dan terus coba request alamat 127.0.0.1:7878; kita seharusnya dapetin si HTML di dalam hello.html tersebut. Kalau kita bikin request apa pun yang lainnya, kayak misalnya 127.0.0.1:7878/something-else, kita bakal dapet error koneksi kayak yang kita temui pas ngejalanin kode di Listing 21-1 dan Listing 21-2.

Sekarang mari kita tambahin kode yang ada di Listing 21-7 ke dalam blok else tersebut buat mengembalikan sebuah response (balasan) yang mana punya status kode 404, yang mengindikasikan (signals) kalau konten buat request tersebut tidak ditemukan (not found). Kita juga bakal mengembalikan (return) sebuah file HTML buat halaman (page) yang bakal ditampilin (render) ke dalam browser yang mana bakal mengindikasikan kepada sang end user (pengguna akhir) apa sebenarnya response-nya.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    // --snip--
    } else {
        let status_line = "HTTP/1.1 404 NOT FOUND";
        let contents = fs::read_to_string("404.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    }
}
Listing 21-7: Membalas dengan status kode 404 dan sebuah halaman error kalau apa pun selain rute / yang di-request

Di sini, response kita punya status line (baris status) dengan status kode 404 dan reason phrase NOT FOUND. Isi (body) dari response ini bakal menjadi konten HTML dari sebuah file bernama 404.html. Kita perlu ngebikin file 404.html ini di sebelah (next to) file hello.html kita tadi buat halaman error ini; sekali lagi ya, silakan pake HTML apa aja yang kita mau secara bebas (feel free to use), atau kita juga bisa pakai HTML contoh (example HTML) yang ada di Listing 21-8.

Filename: 404.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Oops!</h1>
    <p>Sorry, I don't know what you're asking for.</p>
  </body>
</html>
Listing 21-8: Isi sampel konten buat halaman yang bakal dikirim balik buat semua response 404

Dengan perubahan-perubahan ini, jalanin server kita lagi. Nge-request 127.0.0.1:7878 seharusnya mengembalikan isi konten dari hello.html, dan nyoba request apa pun yang lainnya, kayak 127.0.0.1:7878/foo, seharusnya nge-return halaman HTML error dari 404.html.

Sentuhan Kecil Refactoring (Merombak Kode)

Saat ini, di blok if dan else ada sangat banyak kode yang ngulang (repetition): mereka berdua sama-sama lagi ngebaca file (reading files) lalu sama-sama nulis (writing) isi konten file tersebut ke dalam stream. Satu- satunya perbedaan ada di bagian status line dan nama filenya (filename). Mari kita bikin kode ini jadi jauh lebih ringkas (concise) dengan menarik keluar (pulling out) perbedaannya ke dalem baris-baris if dan else yang dipisah yang mana bakal nge-assign (ngasih) nilai-nilai dari status line dan nama filenya ke dalam variabel; kita lalu bisa memakai variabel-variabel tersebut secara mutlak (unconditionally) di dalam kodenya buat membaca file dan nulis (write) balasannya. Listing 21-9 nunjukin kode hasil (resultant code) sesudah kita nggantiin (replacing) blok if dan else yang sangat besar (large) tadi.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}
// --snip--

fn handle_connection(mut stream: TcpStream) {
    // --snip--
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
        ("HTTP/1.1 200 OK", "hello.html")
    } else {
        ("HTTP/1.1 404 NOT FOUND", "404.html")
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-9: Merombak ulang (refactoring) blok if dan else supaya cuma berisi kode yang emang beda di antara kedua kasus tersebut

Sekarang blok if dan else tersebut cuma bakal nge-return nilai yang paling pas buat status line dan nama file (filename) di dalam sebuah tuple; terus kita memakai destructuring (pemecahan) buat nge-assign kedua nilai ini ke variabel status_line dan filename memakai pattern (pola) di dalam statement let, seperti yang udah di bahas di Bab 19.

Kode yang tadinya menduplikasi (duplicated) sekarang udah ditaruh di luar (outside) blok if dan else lalu dia memakai variabel status_line dan filename tadi. Hal ini ngebikin perbedaan di antara kedua kasus ini (two cases) jadi jauh lebih gampang buat dilihat, dan ini juga berarti kita cuma perlu ngubah kode (update the code) di satu tempat aja kalau kita pengen ngubah cara kerja gimana si proses baca file (file reading) dan proses tulis balasan (response writing) ini berjalan. Perilaku kode (behavior) di Listing 21-9 ini bakal tetap persis sama kayak yang ada di Listing 21-7.

Keren sekali! (Awesome!) Nah sekarang kita udah punya sebuah web server sederhana cuma dalam waktu lebih kurang (approximately) 40 baris kode Rust yang mana merespons sebuah request tertentu dengan halaman berisi konten (page of content) dan membalas (responds to) semua requests lainnya pakai response 404.

Saat ini, server kita masih jalan di dalem single thread (satu utas/utas tunggal), yang artinya dia cuma bisa melayani (serve) satu request dalam satu waktu tertentu (at a time). Mari kita telusuri dan menguji (examine) gimana cara kerja kayak gini bisa mendatangkan sebuah masalah dengan menyimulasikan beberapa requests yang jalannya pelan (slow requests). Terus setelah itu kita bakal benerin itu supaya server kita bisa nanganin (handle) banyak requests secara sekaligus (at once).

Mengubah Server Single-Threaded Kita Menjadi Server Multithreaded

Mengubah Server Single-Threaded Kita Menjadi Server Multithreaded

Saat ini, server kita memproses setiap request secara bergantian (in turn), yang berarti dia tidak bakal memproses koneksi yang kedua sebelum proses untuk koneksi yang pertama selesai (finished processing). Kalau server ini nerima makin banyak requests, eksekusi secara berurutan (serial execution) kayak gini bakal jadi makin kurang optimal. Kalau server ini nerima sebuah request yang makan waktu lama sekali buat diproses, requests yang masuk berikutnya (subsequent requests) bakal terpaksa harus nungguin sampai request lama itu selesai, biarpun requests yang baru ini sebenernya bisa aja diproses dengan cepat. Kita perlu benerin ini nih, tapi pertama-tama mari kita ngelihat aksinya masalah ini secara langsung.

Menyimulasikan Sebuah Request yang Pelan (Slow Request)

Kita bakal ngelihat gimana sebuah request yang pemrosesannya lambat bisa berdampak sama requests lainnya yang dilakuin ke implementasi server kita saat ini. Listing 21-10 mengimplementasikan penanganan sebuah request ke path /sleep dengan menyimulasikan (simulated) sebuah balasan yang pelan yang mana bakal ngebikin server kita ini sleep (tidur/berhenti sejenak) selama lima detik sebelum dia ngasih balasan (responding).

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};
// --snip--

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    // --snip--

    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    // --snip--

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-10: Menyimulasikan sebuah request yang pelan dengan cara menidurkan server selama lima detik

Kita beralih (switched) dari yang asalnya pakai if jadi match karena sekarang kita punya tiga kemungkinan kasus (cases). Kita perlu secara eksplisit memakai pencocokan (match) pada sebuah slice dari request_line buat mencocokkan pattern (pattern-match) dengan nilai-nilai string literal tersebut; match itu tidak secara otomatis ngelakuin referencing dan dereferencing kayak yang dilakuin sama method equality (pemeriksaan kesamaan ==).

Arm (lengan) pertama ini bunyinya sama kayak isi dari blok if yang ada di Listing 21-9. Arm kedua cocok dengan sebuah request ke path /sleep. Pas request ini diterima, server kita ini bakal tidur (sleep) selama lima detik sebelum dia nge-render halaman HTML yang isinya sukses tersebut. Arm yang ketiga bunyinya sama persis kayak blok else dari Listing 21-9.

Kita bisa ngelihat sendiri betapa primitifnya (primitive) server kita ini: libraries sungguhan (real libraries) itu biasanya nanganin proses rekognisi (pengenalan) banyak requests dengan cara yang jauh tidak lebih verbose (kepanjangan nulisnya) dari ini!

Jalanin servernya memakai cargo run. Terus buka dua jendela (windows) browser: satu buat http://127.0.0.1:7878 dan satu lagi buat http://127.0.0.1:7878/sleep. Kalau kita masukin URI / beberapa kali kayak sebelumnya, kita bakal ngelihat kalau dia nge-responsnya cepet sekali. Tapi kalau kita masukin /sleep dan kemudian muat (load) / di tab lain, kita bakal ngelihat kalau / ini terpaksa harus nungguin (waits) sampai si sleep tadi selesai tidur selama durasi penuh lima detiknya sebelum halamannya bisa dimuat.

Ada banyak teknik yang bisa kita pakai buat ngehindarin situasi di mana requests ini pada numpuk (backing up) di belakang sebuah request yang pelan, termasuk salah satunya dengan memakai async kayak yang udah kita lakuin di Bab 17; sementara yang bakal kita implementasikan di sini adalah sebuah thread pool (kumpulan utas).

Meningkatkan Throughput dengan Sebuah Thread Pool

Sebuah thread pool itu adalah sekelompok threads yang udah ditelurkan (spawned) yang lagi bersiap-siap dan standby nungguin buat nanganin sebuah tugas (task). Saat program tersebut nerima tugas yang baru, dia bakal ngasih salah satu threads yang ada di dalam kolam (pool) ini buat ngerjain tugas tersebut, dan thread itulah yang bakal memprosesnya. Sisa threads lainnya yang ada di dalam pool ini tetep tersedia (available) buat nanganin tugas-tugas lain yang masuk saat thread yang pertama tadi lagi sibuk memproses. Pas thread pertama udah beres ngerjain tugasnya, dia dikembaliin (returned) lagi ke dalam pool yang isinya threads nganggur (idle threads), terus dia siap (ready) buat nanganin tugas baru lagi. Sebuah thread pool memungkinkan kita buat memproses banyak koneksi secara konkuren (bersamaan), ningkatin throughput (kemampuan nangani permintaan) dari server kita.

Kita bakal membatasi (limit) jumlah threads yang ada di dalam pool ini menjadi angka yang kecil buat melindungi (protect) kita dari serangan DoS (Denial of Service); kalau kita ngebikin program kita buat netasin (create) thread baru buat setiap kali ada request yang masuk, seseorang yang ngebikin 10 juta requests ke server kita bisa-bisa bikin kekacauan parah dengan cara ngabisin semua sumber daya (resources) server kita lalu bikin semua pemrosesan requests jadi mandek total (grinding to a halt).

Jadi ketimbang menelurkan threads tanpa batas (unlimited threads), kita bakal punya jumlah threads yang tetap (fixed number) yang pada standby nungguin di dalam pool tersebut. Requests yang masuk bakal dikirimin ke dalam pool ini buat diproses. Pool ini bakal memelihara sebuah antrean (queue) yang isinya requests yang baru masuk. Masing-masing dari threads yang ada di dalam pool ini bakal mengambil (pop off) satu request dari antrean ini, menangani request tersebut, dan lalu minta satu request lagi ke antrean tersebut. Pakai desain kayak gini, kita bisa memproses maksimal N requests secara konkuren, di mana N itu adalah jumlah threads yang ada. Kalau setiap thread itu lagi sibuk merespons ke requests yang jalan lama sekali, requests yang masuk berikutnya emang masih tetap bisa pada numpuk di dalem antreannya, tapi kita udah ningkatin seberapa banyak jumlah requests yang jalannya lama sekali yang sanggup kita tangani sebelum kita nyampe ke titik jenuh tersebut.

Teknik ini itu hanyalah salah satu dari sekian banyak cara yang ada buat ningkatin throughput dari sebuah web server. Opsi-opsi lain yang mungkin bisa kita eksplorasi adalah model fork/join, model single-threaded async I/O, sama model multithreaded async I/O. Kalau kita tertarik sama topik ini, kita bisa ngebaca lebih lanjut soal solusi-solusi lainnya dan nyobain mengimplementasikan mereka; dengan bahasa pemrograman tingkat rendah (low-level) kayak Rust ini, semua opsi ini sangat mungkin sekali buat dikerjain (possible).

Sebelum kita mulai mengimplementasikan sebuah thread pool, mari kita obrolin kayak gimana rupa dari memakai si pool ini nantinya (what using the pool should look like). Pas kita lagi mencoba mendesain (design) sebuah kode, menulis interface client-nya terlebih dahulu bisa ngebantu memandu jalannya desain kita. Tulis API dari kodenya sehingga strukturnya itu udah sesuai dengan cara kita manggil dia nantinya; baru deh setelah itu implementasikan fungsionalitasnya di dalam struktur tersebut ketimbang mikirin fungsionalitasnya duluan baru mikirin desain API public-nya belakangan.

Mirip dengan gimana kita memakai test-driven development (pengembangan berbasis pengujian) di dalam project kita pas Bab 12 kemarin, kita bakal memakai compiler-driven development (pengembangan berbasis compiler) di sini. Kita bakal nulis kode yang manggil fungsi-fungsi yang pengen kita panggil, lalu baru deh kita ngelihat ke error-error yang dikasih sama compiler buat nentuin apa yang harus kita ubah berikutnya supaya kodenya bisa benar-benar jalan. Tapi sebelum kita melakukan itu, kita bakal nyelidikin teknik yang tidak bakal kita pakai dulu sebagai titik mulai kita.

Menelurkan (Spawning) Sebuah Thread Buat Setiap Request

Pertama-tama, mari kita eksplorasi kira-kira kayak gimana kelihatannya kode kita ini kalau seandainya dia benar-benar ngebikin thread baru buat setiap koneksi yang masuk. Seperti yang udah kita sebutin sebelumnya, ini itu bukan rencana akhir kita gara-gara ada masalah yang mana kita berpotensi bakal netasin threads dalam jumlah yang tidak terbatas, tapi cara ini adalah titik pijak (starting point) yang oke buat ngebikin supaya server multithreaded kita ini bisa jalan dulu. Nanti barulah kita tambahin thread pool sebagai sebuah perbaikan (improvement), dan jadinya membandingkan (contrasting) kedua buah solusi ini bakal jadi lebih gampang.

Listing 21-11 nunjukin beberapa perubahan yang perlu dibikin di dalam fungsi main supaya dia menelurkan (spawn) sebuah thread baru buat nanganin masing-masing stream yang ada di dalam loop for tersebut.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        thread::spawn(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-11: Menelurkan sebuah thread baru buat masing-masing stream

Kayak yang udah kita pelajarin di Bab 16, thread::spawn itu bakal ngebikin thread baru lalu dia bakal ngejalanin kode yang ada di dalam closure tersebut di dalem si thread yang baru ini. Kalau kita jalanin kode ini dan memuat /sleep di browser kita, lalu buka / di dua tab browser yang lain, kita benar-benar bakal ngelihat kalau requests ke / itu tidak perlu lagi nungguin si /sleep sampai selesai beres (finish). Namun, seperti yang tadi udah kita sebutin, cara ini pada akhirnya bakal bikin sistemnya kewalahan (overwhelm the system) karena kita bakal terus-terusan ngebikin threads baru tanpa ada batas sama sekali.

Kita juga mungkin masih inget dari Bab 17 kalau ini itu adalah tipe-tipe situasi persis yang mana async dan await bakal benar-benar bersinar! Simpan pikiran itu di kepala kita ya selagi kita ngebangun thread pool ini dan coba renungkan (think about) gimana situasinya bakal kelihatan berbeda atau malah sama aja kalau kita pakai async.

Ngebikin Sejumlah Threads dalam Jumlah Terbatas (Finite Number of Threads)

Kita mau supaya thread pool kita ini bekerja dengan cara yang mirip-mirip dan kerasa familier supaya pindah (switching) dari threads biasa ke thread pool ini tidak butuh perubahan gede-gedean pada kode-kode yang memakai API kita. Listing 21-12 nunjukin antarmuka bayangan (hypothetical interface) buat sebuah struct ThreadPool yang pengen kita pakai ketimbang thread::spawn.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-12: Antarmuka (interface) ThreadPool ideal milik kita

Kita memakai ThreadPool::new buat ngebikin sebuah thread pool baru dengan jumlah threads yang bisa dikonfigurasi, yang di kasus ini yaitu empat biji. Terus, di dalam loop for, si pool.execute ini punya antarmuka (interface) yang mirip sekali sama thread::spawn karena dia juga nerima sebuah closure yang mana seharusnya bakal dijalanin sama si pool tersebut buat setiap stream yang masuk. Kita perlu mengimplementasikan pool.execute ini sedemikian rupa sehingga dia bakal ngambil closure yang diterimanya itu terus ngasihin itu ke salah satu thread yang ada di dalam pool buat dijalanin. Kode ini jelas masih belum bisa di-compile, tapi kita bakal nyoba men-compile-nya supaya si compiler bisa memandu (guide) kita soal gimana caranya ngeberesin ini.

Ngebangun ThreadPool Memakai Compiler-Driven Development (Pengembangan Berbasis Compiler)

Silakan bikin perubahan-perubahan yang ada di Listing 21-12 ke file src/main.rs kita, dan lalu mari kita pakai pesan-pesan error compiler yang asalnya dari cargo check buat mengarahkan jalan (drive) dari proses development kita. Ini dia error pertama yang kita dapet:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0433]: failed to resolve: use of undeclared type `ThreadPool`
  --> src/main.rs:11:16
   |
11 |     let pool = ThreadPool::new(4);
   |                ^^^^^^^^^^ use of undeclared type `ThreadPool`

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

Sip sekali! (Great!) Error ini ngasih tahu kita kalau kita ini butuh punya tipe atau modul ThreadPool, jadi kita bakal ngebangunnya sekarang juga. Implementasi ThreadPool kita ini sifatnya bakal independen (independent) dan tidak peduli apa jenis kerjaan yang lagi dilakuin sama web server kita ini. Jadi mari kita alihkan (switch) crate hello kita ini dari yang tadinya sebuah binary crate menjadi sebuah library crate buat nampung kode implementasi ThreadPool kita ini. Setelah kita ngubah dia jadi library crate, kita juga jadi bisa lho memakai library thread pool yang udah terpisah ini buat sekiranya ada pekerjaan apa pun lainnya yang mau kita lakuin pakai sebuah thread pool, bukannya cuma khusus buat ngelayanin (serving) web requests doang.

Bikin sebuah file src/lib.rs yang isinya mengandung yang berikut ini, yang mana ini adalah definisi paling simpel yang bisa kita punya buat sebuah struct ThreadPool buat saat ini:

Filename: src/lib.rs
pub struct ThreadPool;

Terus edit file main.rs kita buat ngebawa (bring) ThreadPool tersebut masuk ke dalam scope yang asalnya dari library crate dengan nambahin kode berikut ke bagian paling atas (top) dari src/main.rs:

Filename: src/main.rs
use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

Kode ini tentu masih belum bisa jalan ya, tapi mari kita cek (check) kodenya lagi buat dapetin pesan error selanjutnya yang perlu kita beresin (address):

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0599]: no function or associated item named `new` found for struct `ThreadPool` in the current scope
  --> src/main.rs:12:28
   |
12 |     let pool = ThreadPool::new(4);
   |                            ^^^ function or associated item not found in `ThreadPool`

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

Error ini mengindikasikan kalau langkah selanjutnya yang perlu kita lakuin adalah ngebikin sebuah fungsi associated bernama new untuk si ThreadPool ini. Kita juga tahu kalau new ini butuh satu parameter yang mana bisa menerima angka 4 sebagai argumen dan harus ngembaliin sebuah instance ThreadPool. Mari kita implementasikan fungsi new yang paling simpel yang punya karakteristik kayak gitu:

Filename: src/lib.rs
pub struct ThreadPool;

impl ThreadPool {
    pub fn new(size: usize) -> ThreadPool {
        ThreadPool
    }
}

Kita milih usize sebagai tipe buat parameter size karena kita tahu kalau ngebikin threads dengan jumlah minus (negative number) itu emang kedengerannya tidak masuk akal (doesn’t make sense). Kita juga tahu kalau kita bakal makek angka 4 ini sebagai ukuran jumlah elemen di dalam sebuah koleksi (collection) yang isinya threads, yang mana itu adalah tujuan asli kenapa tipe usize dibikin, kayak yang udah kita obrolin di “Tipe-tipe Angka Bulat (Integer Types)” di Bab 3.

Mari kita cek kodenya lagi:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0599]: no method named `execute` found for struct `ThreadPool` in the current scope
  --> src/main.rs:17:14
   |
17 |         pool.execute(|| {
   |         -----^^^^^^^ method not found in `ThreadPool`

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

Sekarang errornya kejadian gara-gara kita tidak punya method execute pada struct ThreadPool kita. Ingat kembali materi dari “Ngebikin Sejumlah Threads dalam Jumlah Terbatas (Finite Number of Threads)” tadi di mana kita memutuskan kalau thread pool kita ini seharusnya punya interface (antarmuka) yang mirip sama thread::spawn. Selain itu, kita bakal mengimplementasikan fungsi execute ini sedemikian rupa sehingga dia nerima closure yang udah dikasih ke dia lalu mengopernya ke sebuah thread yang lagi nganggur (idle thread) di dalam si pool tersebut buat dijalanin.

Kita bakal mendefinisikan method execute pada ThreadPool ini supaya dia menerima sebuah closure sebagai parameter. Ingat kembali dari “Mengoper Nilai yang Ditangkap Keluar dari Closure dan Trait Fn di Bab 13 kalau kita bisa nerima closures sebagai parameter yang memakai tiga jenis trait yang berbeda: yaitu Fn, FnMut, dan FnOnce. Kita harus memutuskan trait closure mana yang mau dipakai di sini. Kita tahu kalau ujung-ujungnya kita ini bakal ngerjain hal yang mirip-mirip sama yang dilakuin oleh implementasi thread::spawn punya standard library, jadi kita bisa nyontek (look at) batasan-batasan (bounds) apa aja yang dipunyai sama signature si thread::spawn ini pada parameternya. Dokumentasi di sana ngasih tahu kita hal berikut:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    where
        F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static,

Parameter bertipe F inilah yang lagi jadi fokus (concerned with) kita di sini; sedangkan parameter bertipe T itu ada kaitannya sama nilai kembalian (return value) dari fungsi itu, yang mana itu tidak jadi masalah buat kita. Kita bisa ngelihat kalau si spawn ini memakai FnOnce sebagai trait bound (batasan trait) buat F-nya. Ini juga merupakan trait yang kemungkinan besar kita inginkan, karena nantinya argumen closure yang kita dapat di dalam execute ini juga pada akhirnya bakal kita operin (pass) ke dalam spawn. Kita bisa makin yakin kalau FnOnce itu adalah trait yang benar-benar pengen kita pake soalnya thread yang dijalanin buat nanganin satu request itu emang cuma bakal mengeksekusi closure buat request tersebut sebanyak satu kali doang, yang mana ya cocok persis (matches) sama embel-embel kata Once (sekali) di dalam trait FnOnce.

Parameter bertipe F itu juga punya trait bound Send dan lifetime bound (batasan rentang hidup) 'static, yang mana emang sangat berguna buat situasi kita saat ini: kita butuh trait Send ini buat mindahin (transfer) si closure ini dari satu thread ke thread yang lainnya dan 'static ini gara-gara kita tidak tahu seberapa lama waktu yang dibutuhkan sama si thread tersebut buat selesai melakukan eksekusi kodenya. Mari kita bikin method execute pada ThreadPool yang bakal menerima parameter generik (generic parameter) bertipe F dengan bounds berikut:

Filename: src/lib.rs
pub struct ThreadPool;

impl ThreadPool {
    // --snip--
    pub fn new(size: usize) -> ThreadPool {
        ThreadPool
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

Kita tetep pake tambahan () setelah trait FnOnce tersebut karena si FnOnce ini merepresentasikan sebuah closure yang mana dia tidak menerima parameter apa-apa dan dia juga mengembalikan unit type (tipe unit kosong) (). Sama kayak halnya definisi fungsi (function definitions), tipe return-nya bisa aja disingkirkan (omitted) dari signature-nya, tapi meskipun kita tidak nerima parameter apa-apa di dalam closure-nya, kita tetep wajib nulisin tanda kurung yang kosong tersebut (parentheses).

Sekali lagi, ini adalah sekadar implementasi paling simpel (simplest) dari method execute: dia sama sekali tidak ngelakuin apa-apa, tapi kan tujuan kita cuma mau ngebikin kode kita sukses di-compile doang buat saat ini. Mari kita cek (check) lagi deh:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.24s

Kompilasi sukses! (It compiles!) Tapi perlu dicatet nih kalau seandainya kita mencoba ngejalanin pake cargo run dan terus nyoba ngasih sebuah request dari browser, kita bakal ngelihat lagi error-error di browser tadi yang sempat kita lihat di bagian awal bab ini. Library kita ini masih belum secara harfiah (actually) memanggil closure yang dioper ke dalem fungsi execute lho ya!

Catatan: Pepatah yang mungkin sering kita dengar soal bahasa-bahasa pemrograman yang compiler-nya rewel (strict compilers), kayak Haskell dan Rust, adalah “kalau kodenya berhasil di-compile, berarti kodenya jalan.” Tapi pepatah ini itu tidak selalu bener kok. Project kita ini sukses di-compile kan, padahal dia itu bener-bener tidak ngelakuin apa-apa sama sekali! Kalau seandainya kita ini lagi ngebangun project sungguhan yang lengkap, ini adalah saat-saat yang paling pas buat mulai nulisin unit tests buat ngetes (check) apakah kodenya sukses di-compile sekaligus punya perilaku yang emang kita mau atau tidak.

Renungkan ini: kira-kira apa yang bakal berbeda di sini kalau seandainya kita ini lagi mau ngejalanin sebuah future ketimbang sebuah closure?

Memvalidasi Jumlah Threads yang Ada di new

Kita sama sekali tidak ngelakuin tindakan apa-apa lho sama parameter-parameter yang ada di fungsi new dan execute ini. Mari kita implementasikan body (isi) dari fungsi-fungsi ini supaya punya perilaku yang emang kita inginkan. Buat memulai, mari kita pikirin soal fungsi new. Sebelumnya kita udah milih (chose) tipe tanpa tanda (unsigned type) buat parameter size karena ngebikin sebuah pool dengan jumlah threads yang negatif itu emang tidak masuk akal (makes no sense). Tapi ya, sebuah pool dengan angka nol threads juga tidak kalah tidak masuk akalnya dong, padahal angka nol itu adalah angka yang sah-sah aja (perfectly valid) di tipe usize. Kita bakal tambahin barisan kode buat ngecek (check) kalau variabel size itu harus lebih gede dari angka nol sebelum kita mengembalikan sebuah instance ThreadPool lalu ngebikin programnya jadi panic dengan memakai macro assert! kalau ternyata kita dikasih angka nol, kayak yang kelihatan di Listing 21-13.

Filename: src/lib.rs
pub struct ThreadPool;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        ThreadPool
    }

    // --snip--
    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}
Listing 21-13: Mengimplementasikan ThreadPool::new supaya dia panic kalau size-nya nol

Kita juga udah nambahin secuil dokumentasi buat ThreadPool kita memakai komentar dok (doc comments). Perhatikan kalau kita udah mengikuti (followed) kaidah penulisan dokumentasi yang bagus (good documentation practices) dengan cara nambahin sebuah seksi yang mana membeberkan kasus-kasus (call out the situations) di mana fungsi kita ini bisa jadi panic, kayak yang dibahas di Bab 14. Silakan cobain jalanin cargo doc --open lalu klik struct ThreadPool tersebut buat ngelihat kayak apa rupa dari docs yang udah di-generate buat method new tersebut!

Ketimbang nambahin macro assert! kayak yang baru aja kita lakuin di sini, kita sebenernya bisa aja kok ngerubah method new ini jadi build dan terus ngembaliin (return) sebuah tipe Result persis kayak apa yang udah kita lakuin sama fungsi Config::build di dalem project I/O kita di Listing 12-9. Tapi kita udah memutuskan kalau di kasus kali ini usaha buat ngebikin sebuah thread pool tanpa punya satupun threads di dalamnya (without any threads) itu seharusnya dijadikan sebuah error yang tidak bisa dipulihkan (unrecoverable error). Kalau kita lagi ngerasa ambisius hari ini, cobain deh buat nulis sebuah fungsi bernama build dengan signature berikut buat ngebandingin hasilnya dengan fungsi new ini:

pub fn build(size: usize) -> Result<ThreadPool, PoolCreationError> {

Menyiapkan Tempat Buat Menyimpan Para Threads

Sekarang karena kita udah punya cara yang valid buat mengetahui (know) jumlah threads yang harus disimpan di dalam pool, kita akhirnya bisa ngebikin threads tersebut lalu menyimpan mereka di dalam struct ThreadPool sebelum kita ngembaliin si struct itu. Tapi gimana ya caranya kita “menyimpan” sebuah thread? Mari kita ngelirik balik (take another look) ke signature dari thread::spawn:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    where
        F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static,

Fungsi spawn ini ngembaliin (returns) sebuah JoinHandle<T>, di mana T itu adalah tipe balasan (return type) dari closure tersebut. Mari kita cobain pakai JoinHandle juga deh dan lihat apa yang bakal kejadian. Di kasus yang kita kerjain sekarang, closures yang kita oper masuk ke dalem thread pool ini emang tugasnya buat nanganin (handle) koneksi dan bukannya buat ngembaliin data apa pun juga, jadi si T ini nilainya bakal berupa unit type ().

Kode yang ada di Listing 21-14 ini bakal berhasil di-compile tapi masih belum ngebikin satu pun threads. Kita udah ngubah (changed) definisi dari ThreadPool supaya dia menyimpan (hold) sebuah vector yang isinya berupa instances dari thread::JoinHandle<()>, menginisialisasi vector tersebut supaya punya kapasitas memori sejumlah size (with a capacity of size), nyiapin (set up) loop for yang nantinya bakal njalanin beberapa kode buat ngebikin threads tersebut, dan baru deh membalikkan (returned) sebuah instance ThreadPool yang ngandung mereka semua.

Filename: src/lib.rs
use std::thread;

pub struct ThreadPool {
    threads: Vec<thread::JoinHandle<()>>,
}

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut threads = Vec::with_capacity(size);

        for _ in 0..size {
            // create some threads and store them in the vector
        }

        ThreadPool { threads }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}
Listing 21-14: Membikin vector buat ThreadPool buat menampung para threads yang ada

Kita udah ngebawa (brought) std::thread masuk ke dalam scope di dalam library crate kita gara-gara kita lagi mau makek thread::JoinHandle sebagai tipe buat item-item yang bakal masuk ke dalam vector milik si ThreadPool.

Setelah angka size yang valid itu diterima (received), ThreadPool kita ini ngebikin sebuah vector baru yang mana sanggup nampung sejumlah size item. Fungsi with_capacity ini ngelakuin tugas yang sama persis kayak fungsi Vec::new tapi dengan satu perbedaan krusial (important difference): dia udah mengalokasikan memori lebih dulu (pre-allocates space) ke dalam vector tersebut. Karena kita tahu pasti (we know) kalau kita ini perlu menyimpan size elemen di dalam vector ini, ngelakuin proses alokasi (allocation) memori dari awal kayak gini itu slightly (sedikit) lebih efisien dan cepet ketimbang makek fungsi Vec::new yang mana dia itu bakal mengubah ukurannya sendiri (resizes itself) setiap kali ada elemen baru yang dimasukin ke dalam situ.

Pas kita jalanin perintah cargo check lagi, dia harusnya berhasil (succeed).

Ngirimin Kode dari Dalam ThreadPool ke Sebuah Thread

Kita ninggalin (left) sebuah komentar di dalam loop for tadi yang ada di Listing 21-14 mengenai (regarding) urusan pembuatan threads. Di sini, kita bakal ngelihat gimana sebenarnya langkah yang kita tempuh buat ngebikin threads tersebut. Standard library menyediakan fungsi thread::spawn sebagai cara buat bikin threads baru, dan si thread::spawn ini ngeharepin buat langsung dikasih beberapa kode yang harus dijalanin seketika (as soon as) pas thread tersebut selesai dibikin. Padahal, di kasus yang kita punya, kita pengennya (want to) ngebikin threads tersebut terus ngebikin mereka buat menunggu (wait) kode-kode (tasks) yang bakal kita kirimin nanti (later). Implementasi (implementation) dari threads bawaan (standard library) ini sama sekali tidak punya opsi (way) buat ngelakuin hal semacam itu; jadinya kita harus mengimplementasikannya secara manual (manually).

Kita bakal mengimplementasikan perilaku (behavior) ini dengan cara memperkenalkan sebuah struktur data baru (new data structure) di antara si ThreadPool tersebut dan threads ini yang mana struktur data ini bakal bertugas mengelola (manage) tingkah laku yang baru ini. Kita bakal namain struktur data ini Worker (Pekerja), yang mana merupakan sebuah istilah lazim (common term) yang suka dipakai di dalam berbagai implementasi model pemusatan (pooling). Sang Worker ini tugasnya ngambilin (picks up) kode-kode (tasks) yang harus dijalanin terus menjalanin kode-kode itu di dalam thread miliknya.

Coba bayangin (think of) kayak orang-orang yang lagi kerja di dalem dapur sebuah restoran: para pekerja dapur (workers) ini pada nungguin sampai pesanan (orders) datang dari para pelanggan (customers), dan terus mereka jadinya bertanggung jawab (responsible) buat nerima (taking) pesanan-pesanan tersebut terus menuhin (filling) pesanan itu (masak makanannya).

Ketimbang nyimpen vector yang isinya sekumpulan instances JoinHandle<()> di dalam thread pool ini, kita sebaliknya bakal nyimpen instances dari struct Worker. Masing-masing Worker ini bakal nge-store (menyimpan) sebuah instance JoinHandle<()> tunggal. Kemudian kita bakal mengimplementasikan sebuah method pada si Worker ini yang mana method itu nerima sebuah closure berisi kode yang harus dijalanin terus method itu bakal ngirimin closure itu ke thread yang emang udah lagi pada nyala (already running) supaya bisa tereksekusi. Kita juga bakal ngasih setiap Worker ini sebuah id (identifikasi/identitas) supaya kita gampang mbedain di antara berbagai macam instances Worker yang ada di dalam pool tersebut saat kita lagi nyatet log (logging) atau debugging.

Berikut ini adalah gambaran proses baru yang bakal berlangsung (happen) pas kita ngebikin sebuah ThreadPool. Kita bakal mengimplementasikan kode yang tugasnya ngirimin si closure tersebut ke thread itu setelah kita selesai nge-setup si Worker ini memakai cara di bawah ini:

  1. Definisikan sebuah struct Worker yang menampung (holds) sebuah id dan sebuah JoinHandle<()>.
  2. Ubah ThreadPool supaya dia itu sekarang malah nampung sebuah vector berisi instances dari Worker.
  3. Definisikan sebuah fungsi Worker::new yang nerima sebuah angka (number) buat jadi id terus dia ngembaliin sebuah instance Worker yang nampung si id itu beserta sebuah thread baru yang ditetaskan (spawned) memakai sebuah closure yang kosong.
  4. Di dalam ThreadPool::new, pakailah angka penghitung (counter) yang asalnya dari loop for itu buat di-generate (dijadiin) sebuah id, bikin sebuah Worker baru pakai id tadi, terus masukin dan simpan si Worker baru itu ke dalam vector-nya.

Kalau kita ngerasa pengen nyari tantangan, coba deh kerjain sendiri perubahan-perubahan ini (implementing these changes on your own) sebelum kita ngelihat ke kodenya di Listing 21-15.

Udah siap (Ready)? Ini dia Listing 21-15 yang berisi salah satu cara buat ngebikin (make) serangkaian modifikasi-modifikasi sebelumnya.

Filename: src/lib.rs
use std::thread;

pub struct ThreadPool {
    workers: Vec<Worker>,
}

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool { workers }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker { id, thread }
    }
}
Listing 21-15: Memodifikasi ThreadPool supaya dia menampung instances Worker ketimbang menampung threads secara langsung

Kita udah mengganti nama field di ThreadPool dari asalnya threads menjadi workers karena emang sekarang dia jadinya malah nyimpen instances Worker ketimbang instances dari JoinHandle<()>. Kita pakai angka hitungan (counter) dari loop for tersebut sebagai argumen buat Worker::new, lalu menyimpan (store) tiap Worker yang baru dibikin ke dalam vector yang namanya workers.

Kode-kode luar (external code, seperti server kita yang ada di src/main.rs) tidak perlu tahu detail-detail spesifik implementasinya sehubungan sama pemakaian struct Worker di dalam sebuah ThreadPool, makanya kita membiarkan struct Worker dan fungsi new-nya itu bernilai (sifatnya) private. Fungsi Worker::new tersebut memakai id yang udah kita kasih terus menyimpan sebuah instance dari JoinHandle<()> yang mana instance ini dibikin dengan cara menelurkan sebuah thread baru memakai closure kosong.

Catatan: Kalau sistem operasinya (OS) tidak mampu buat membikin sebuah thread gara-gara kekurangan sumber daya sistem (aren’t enough system resources), fungsi thread::spawn itu jadinya bakal meledak (panic). Hal ini bakal ngebikin seluruh server kita jadi ikutan panik, sekalipun pembuatan dari beberapa threads yang lain itu sebenarnya berhasil dengan lancar (might succeed). Demi urusan kemudahan buat dipelajari (simplicity’s sake), membiarkan kelakuan ini terjadi itu sah-sah saja kok (is fine), tapi kalau di kasus implementasi thread pool tipe tingkat produksi (production), kita bakal jauh lebih direkomendasiin (likely want to) buat memakai std::thread::Builder barengan sama method spawn-nya karena method tersebut nge-return (ngembaliin) tipe Result sebagai gantinya.

Kode kita ini bakal bisa di-compile dan bakal berhasil menyimpan jumlah dari instances Worker sebanyak yang udah kita spesifikasikan sebagai argumen waktu manggil ThreadPool::new. Tapi kita ini masih juga belum memproses (processing) closure yang kita dapetin dari pemanggilan execute ya. Mari kita ngelihat gimana caranya supaya kita bisa ngerjain langkah yang itu sekarang.

Mengirim Requests ke Dalem Threads Melalui Channels (Saluran Komunikasi)

Permasalahan (problem) berikutnya yang harus segera kita tangani adalah fakta kalau closures yang dikasihin ke thread::spawn itu bener-bener nyatanya tidak berbuat apa-apa (do absolutely nothing). Saat ini, kita ngedapetin closure yang mana pengen kita jalanin tersebut (execute) lewat method execute. Tapi kita ini perlu bisa ngasih si fungsi thread::spawn tadi sebuah closure untuk dijalankan ketika (when) kita lagi repot-repotnya membikin setiap Worker tersebut saat fase-fase (during) pembentukan (creation) dari ThreadPool itu.

Kita pengennya supaya struct-struct Worker yang baru aja kita bikin ini bisa ngambilin (fetch) kode yang mau mereka jalanin tersebut yang dapetnya dari sebuah antrean (queue) yang ditampung (held) di dalam ThreadPool terus ngirimin kode (task) tersebut ke thread miliknya buat dijalankan.

Saluran komunikasi (channels) yang sempat kita pelajarin di Bab 16—sebuah cara yang simpel buat berkomunikasi di antara dua buah threads—itu bakal jadi pilihan (candidate) yang luar biasa pas sekali (perfect) buat menangani skenario (use case) ini. Kita bakal memakai sebuah channel supaya dia bisa bertindak (function) sebagai antrean pekerjaan (queue of jobs) tersebut, dan method execute bakal mengirimkan (send) sebuah pekerjaan (job) dari dalam ThreadPool menuju instances Worker, yang mana kemudian bakal ngirimin si job tersebut menuju thread miliknya. Ini dia rancangannya (plan):

  1. ThreadPool bakal membikin (create) sebuah channel (saluran) dan berpegang erat (hold on to) pada bagian ujung pengirimnya (sender).
  2. Masing-masing Worker bakal berpegangan (hold on to) pada bagian penerimanya (receiver).
  3. Kita bakal membikin sebuah struct Job baru yang mana tugasnya buat nampung (hold) closures yang pengen kita kirimkan masuk menyusuri (down) si channel tersebut.
  4. Method execute bakal mengirim (send) si job (pekerjaan) yang mau dia eksekusi (execute) tersebut melalui si sender (pengirim) ini.
  5. Di dalam thread masing-masing, si Worker ini bakal muter berulang-ulang (loop over) menanyai receiver-nya (penerimanya) lalu mengeksekusi closures yang asalnya dari semua jobs apa pun yang mana dia terima (receive).

Mari kita mulai (start by) dengan ngebikin sebuah channel di dalam ThreadPool::new dan menyimpan (holding) si pengirim (sender) itu di dalam instance ThreadPool kita ini, persis kayak apa yang ditunjukin di Listing 21-16. Struct Job ini sendiri belum nampung benda apa pun sih buat saat ini tapi si Job inilah yang bakal jadi tipe dari item yang lagi mau kita kirim menyusuri (down) ke dalam channel (saluran) ini.

Filename: src/lib.rs
use std::{sync::mpsc, thread};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool { workers, sender }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker { id, thread }
    }
}
Listing 21-16: Memodifikasi ThreadPool buat menyimpan sender dari sebuah channel yang mentransmisikan instances Job

Di dalam ThreadPool::new, kita membikin channel yang baru terus nyuruh si pool tersebut buat menyimpen (hold) ujung si pengirimnya (sender). Kode ini bakal berhasil sukses di-compile dengan mulus.

Mari kita cobain buat ngoper masuk sebuah ujung penerima (receiver) dari si channel ini ke masing-masing Worker seiringan saat si thread pool lagi ngebikin si channel tersebut. Kita tahu (know) kan kalau kita itu pengen makek bagian receiver (penerima) ini dari dalam thread yang mana ditetaskan (spawn) oleh tiap instances si Worker tadi, jadi kita bakal memasukkan referensi (reference) parameter si receiver ini di dalam closure yang lagi kita buat itu. Sayangnya (won’t quite), kode yang ada di Listing 21-17 ini ternyata masih belum bisa di-compile dengan sukses nih buat saat ini.

Filename: src/lib.rs
use std::{sync::mpsc, thread};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, receiver));
        }

        ThreadPool { workers, sender }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

// --snip--


struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}
Listing 21-17: Mengoper receiver ke masing-masing Worker

Kita udah membuat sedikit perubahan kecil dan gampang (straightforward): kita oper si receiver masuk ke dalam Worker::new, terus kita memakai si receiver itu di dalam closure-nya.

Pas kita mencoba buat ngecek kode ini (check this code), kita langsung kejedot sama error ini nih:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0382]: use of moved value: `receiver`
  --> src/lib.rs:26:42
   |
21 |         let (sender, receiver) = mpsc::channel();
   |                      -------- move occurs because `receiver` has type `std::sync::mpsc::Receiver<Job>`, which does not implement the `Copy` trait
...
25 |         for id in 0..size {
   |         ----------------- inside of this loop
26 |             workers.push(Worker::new(id, receiver));
   |                                          ^^^^^^^^ value moved here, in previous iteration of loop
   |
note: consider changing this parameter type in method `new` to borrow instead if owning the value isn't necessary
  --> src/lib.rs:47:33
   |
47 |     fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
   |        --- in this method       ^^^^^^^^^^^^^^^^^^^ this parameter takes ownership of the value
help: consider moving the expression out of the loop so it is only moved once
   |
25 ~         let mut value = Worker::new(id, receiver);
26 ~         for id in 0..size {
27 ~             workers.push(value);
   |

For more information about this error, try `rustc --explain E0382`.
error: could not compile `hello` (lib) due to 1 previous error

Kodenya ini lagi berusaha (trying to) ngoper nilai receiver yang cuma satu ini ke banyak instances Worker secara bebarengan. Ini jelas tidak bisa jalan (won’t work), seperti yang pasti kita masih ingat (recall) dari memori kita pas lagi ngebaca Bab 16: bentuk implementasi channel yang disediain sama Rust itu formatnya adalah sistem banyak pengirim (multiple producer), tapi cuma satu penerima (single consumer). Ini bermakna kalau kita ini tidak bisa lho sekadar nge-clone (kloning) si bagian consumer (pengkonsumsi) dari saluran komunikasi ini buat mbetulin kode yang lagi eror ini. Lagian, kita emang sebenernya juga tidak mau kok (don’t want to) buat ngirimin pesan yang itu-itu lagi berkali-kali nuju ke bermacam consumers (penerima pesan); kita sebaliknya pengen ngirimin satu list panjang isinya pesan ke banyak (multiple) instances Worker tapi dengan harapan bahwa masing-masing pesannya itu cuma berhak (gets processed) bakal diproses sekali doang secara giliran.

Sebagai tambahan (additionally), tindakan ngambil (taking) satu job pekerjaan ngelepasin dari queue (antrian) saluran tersebut itu emang pastinya bakal mengubah wujud (mutating) si receiver-nya ini, gara-gara hal ini makanya threads yang ada itu sangat perlu punya sebuah mekanisme (way) yang dirasa aman (safe) supaya mereka bisa bagi-bagi (share) dan ngubah (modify) isi receiver ini secara bebarengan; kalau tidak, kita malah bisa-bisa ngejeblos dapet race conditions (perlombaan data) yang bikin program rusak (seperti yang udah dibahas babak belur di Bab 16 kemaren).

Ingat balik soal tipe smart pointers (pointer cerdas) yang udah terjamin aman buat di dalam thread (thread-safe) yang barusan kita obrolin di Bab 16 kemaren: buat membagikan (share) hak kepemilikan (ownership) melintasi (across) banyak threads yang berbeda dan secara bebarengan ngebolehin para threads tersebut buat ngubah (mutate) nilai datanya bareng-bareng, kita sangat butuh pake Arc<Mutex<T>>. Tipe Arc ini yang bakal ngebolehin kalau banyak instances Worker buat bisa sama-sama ngantongin (own) si receiver, dan tipe Mutex ini yang bakal mastiin ke kita kalau di satu titik waktu tertentu (at a time) itu cuma ada bener-bener satu doang dari sekian banyak Worker yang berhak ngambil sebuah job (kerjaan) langsung dari si receiver (penerimanya) tersebut. Listing 21-18 di bawah ini mendemonstrasikan perubahan macam apa yang wajib kita lakuin ini.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};
// --snip--

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

// --snip--

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        // --snip--
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}
Listing 21-18: Membagikan (sharing) isi receiver ini di antara para instances Worker tersebut dengan cara makek Arc dan Mutex

Di dalam fungsi ThreadPool::new, kita ngeletakkin (put) si receiver tersebut ke dalam balutan sebuah Arc dan sebuah Mutex. Buat masing-masing Worker yang baru dibikin (new), kita meng-clone (bikin kloningan) si Arc ini tujuannya supaya dia bakal nge-bump (nambah) reference count-nya (hitungan referensinya) sedemikian rupa sehingga keseluruhan instances dari Worker ini pada akhirnya bisa saling ngebagi-bagi hak kepemilikannya bareng (share ownership) buat si penerima (receiver) tersebut.

Dengan berbekal semua perubahan ini, kodenya akhirnya bisa sukses di-compile! Kita udah hampir nyampe (getting there) ke tujuan akhir kita ini loh!

Mengimplementasikan Method execute

Mari kita benar-benar akhirnya mulai ngerjain dan mengimplementasikan method execute pada ThreadPool tersebut secara tuntas. Kita juga bakal ngubah tipe Job ini dari asalnya sebuah struct menjadi sebuah type alias (alias buat sebuah tipe) aja buat nampung si trait object (objek trait) yang mana benar-benar bakal nampung (hold) secara bener tipe dari closure asli yang mana si execute ini tadi lagi nerima. Kayak yang barusan kelar dibahas di “Membikin Sinonim Tipe dengan Type Aliases” di dalem Bab 20 kemaren, fitur type aliases ini ngebolehin kita buat mempersingkat (make shorter) nama dari tipe-tipe yang kelihatannya sumpek kepanjangan supaya nanti mereka itu jadi jauh lebih enak plus lebih gampang (ease) buat dipakek sehari-hari. Coba tengok dan perhatikan Listing 21-19 ini deh.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

// --snip--

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

// --snip--

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}
Listing 21-19: Ngebikin type alias buat si Job supaya nampung sebuah Box yang aslinya berisi masing-masing closure dan terus ngirimin si job ini menelusuri ke dalem channelnya

Sehabis kita selesai kelar bikin (creating) instance dari sebuah Job yang baru yang berbekalkan closure yang kebetulan kita raup (get) dan kita panen dari parameter di fungsi execute ini, kita akhirnya ngirimin si job ini merosot lurus menuju saluran (channel) komunikasi nglewatin sending end (ujung buat ngirim) si sender ini. Di situ kita itu emang manggil unwrap pas lagi manggil method send buat antisipasi seandainya pengirimannya itu entah kenapa jadi gagal (fails). Hal macam error ini sebenernya emang masih ada peluang kejadian (might happen), apalagi kalau misalnya contohnya, kita itu nyetop secara paksa (stop) biar semua threads kita ini berhenti melakukan semua pekerjaan dan ngejalanin eksekusinya, yang mana artinya si ujung penerimanya (receiving end) tersebut udah pasti juga ikutan mandeg (stopped) alias udah tidak nerima pesen (messages) baru lagi. Sampai dengan menit saat ini sih, emang jujurnya kita ini masih belum nyiapin fitur apa pun buat bisa berhentiin (stop) segerombolan threads ini dari ngejalanin eksekusi programnya: threads yang udah jalan milik kita ini masih bakal terus melenggang jalan narik ngegas pol gas terus beroperasi (continue executing) sekuat lama umur (as long as) pool milik kita ini juga masih dibiarin buat tetep exist. Satu-satunya alasan kenapa kita dengan berani masang (use) fitur unwrap di situ adalah karena berbekal jaminan (know) dari diri kita sendiri kalau skenario kemungkinan gagalnya (failure case) ini sebenernya emang benar-benar secara harfiah tidak bakal pernah terwujud kejadian sama sekali, tapi ya sayangnya si compiler ini mana ngerti kalau di kenyataannya kelakuan ini itu tidak bakal pernah berbuat salah macam itu (doesn’t know that).

Tapi kita ini juga belum benar-benar beres juga nih kerjaannya! Di dalam bagian Worker itu, si closure kepunyaan kita ini yang tadinya dioper ke dalem thread::spawn kan emang tugas utamanya dia itu cuma lagi ngerujuk (meminjam referensi/references) doang kan ke si ujung penerima (receiving end) milik si saluran itu. Padahal yang bener-bener kita harapkan di sini adalah, kita ngebutuhin sekali supaya si closure ini benar-benar bisa berputar dan berkeliling di dalam loop buat selama-lamanya (forever), nanyain dan malakin (asking) si ujung penerima channel ini mulu nanyain apaan dia ini lagi dapet kerjaan job sambil setelahnya dia benar-benar ngejalanin isi dari si pekerjaan (running the job) ini langsung abis dia kebetulan berhasil ngedapetin satu job. Makanya, mari kita lakuin rombakan perubahan barusan yang ada kelihatan nyempil di Listing 21-20 tersebut ke dalam dalem fungsi Worker::new ini.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listing 21-20: Menerima dan juga sekaligus ngejalanin berbagai rupa jobs di dalam isi dari thread instance Worker tersebut

Di sini, hal pertama yang kita lakuin duluan itu adalah manggil lock dari si receiver tadi tujuannya buat mengakuisisi atau merebut dan narik status si mutex ini, dan baru abis narik status aman itu barulah kita berani manggil unwrap biar nantinya kodenya bisa otomatis njerit panik (panic) atas segala bentukan macam rupa kesalahan (errors) yang mungkin keluar. Merebut (Acquiring) status kunci (lock) itu pada aslinya sangat mungkin berpeluang buat jadi gagal berantakan kalau ternyata di balik bayangan layar, kondisi status (state) dari dalem the mutex-nya ini udah keburu kejebak berubah menjadi beracun keracunan (poisoned state). Kondisi beracun ini bisa terjadi kalau ternyata ada utas (thread) yang lainnya yang entah salah apa dari mana dia tiba-tiba malahan udah milih panik meledak berantakan (panicked) tapi saat itu dia ini masih ngantongin status kuncinya (holding the lock) ini alih-alih melepaskan kunci tersebut (releasing the lock) sebelum matinya. Kalau kita lagi apes kejebak situasi (situation) yang semacam begini, maka tindakan manggil method unwrap biar si utasan (thread) kita yang ini juga ikut-ikutan njerit panik ikutan hancur adalah langkah jalur yang emang diakui emang udah bener (correct action) buat dikerjain. Silakan santai aja luangin bebasin dan rombak sendiri ini semua ngubah (change) bentuk unwrap ini biar jadinya makek format dari expect yang disisipin makek pesan galat kesalahan (error message) yang mungkin agak lebih masuk kerasa ngena bunyinya gampang dimengerti sama kuping (meaningful to you) sendiri aja tidak masalah.

Kalau ternyata kita mulus-mulus aja sukses mulus kebagian ngunci gembok (got the lock) ke gembok mutex ini, maka baru di saat itu kita bisa ikutan berani nekat manggil si perintah recv biar bisa nangkep nerima (receive) satu balok Job (Pekerjaan) dari si channel saluran komunikasi pipa tersebut. Sebuah sematan paku tempelan unwrap yang nangkring di bagian pucuk paling terakhir ini bener-bener membantu kita berjalan tegar dan melewati (moves past) nerjang segala bentuk halangan segala kelakuan aneh error macam apa aja yang kebetulan mungkin aja tetiba nongol dari sini, error model macem ginian bisa aja pada nongol mencuat (occur) umpamanya di mana si utas thread yang mana lagi sibuk-sibuknya naruh (holding) megangin ujung sender-nya ini ternyata tetiba udah keburu modar matikan paksa nutup jalan (shut down) ngedahuluin si penerimanya. Yang ini sebenernya jalan nalar kerjanya emang nyerempet mirip-mirip (similar) nian loh percis dengan cara fungsi metode send yang mana bakal pasti berontak ngebalik (returns) pesan Err semisal si utas sang receiver-nya yang ini malah mati berantakan nutup mendadak dari depan.

Panggilan metode lurus menuju si baris recv ini sifatnya membikin laju proses berhentinya kodenya jadi terblokir nunggu (blocks), makanya ini menimbulkan efek di mana kalau andai kata aja ternyata eh emang belum nongol dateng kerjaan job satu biji doang pun di depan matanya saat itu (no job yet), maka di ujung akhir-akhirnya ini sih utas *thread yang lagi kerja saat ini (current thread) jadinya ya mau gimanapun bakal terus bengong nganggur nungguin nge-drop nunggu nunggu anteng (wait) sampe sebuah job bener-bener kelar udah nyampe hadir keluar di depannya. Struct si Mutex<T> pada intinya inilah si sosok kuncian yang menjamin mastiin pasti (ensures) ke semuanya ke kita kalau emang disisihkan pada sebuah jeda satu waktu yang spesifik tertentu (at a time) bener-bener bakal pasti cuma bakal disidang dan diberika akses ke cuma satu doang thread punya Worker (only one Worker thread) yang dikasih dan dibolehin ijin buat nyoba manggil-manggil mau request nanyain apaan ada job (pekerjaan) atau gaknya ke channel tadi.

Tadaa, selamat loh! Thread pool bikinan kita pada detik jaman tulisan ini dibikin sebenernya udah pada posisi sukses jalan ngacir lanjay ngebut dengan mulus (working state)! Majuin cobain aja suruh tes dengan nge-running cargo run sekalian lu buat dikit requests ngetes jalannya juga ya:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
warning: field `workers` is never read
 --> src/lib.rs:7:5
  |
6 | pub struct ThreadPool {
  |            ---------- field in this struct
7 |     workers: Vec<Worker>,
  |     ^^^^^^^
  |
  = note: `#[warn(dead_code)]` on by default

warning: fields `id` and `thread` are never read
  --> src/lib.rs:48:5
   |
47 | struct Worker {
   |        ------ fields in this struct
48 |     id: usize,
   |     ^^
49 |     thread: thread::JoinHandle<()>,
   |     ^^^^^^

warning: `hello` (lib) generated 2 warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.91s
     Running `target/debug/hello`
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.

Sukses mulus (Success)! Kita sekarang ngenalin kalau kita emang akhirnya sukses benar-benar punyain sebuah bentuk sejati dari sebuah thread pool murni yang mengeksekusi koneksi-koneksi yang singgah dari depan ini ngejalanin proses eksekusinya berbarengan asinkron tidak usah gantian satu-satu lgi secara sinkron (asynchronously). Emang kenyataannya kagak bakal pernah (never) ada riwayat kejadian di mana lebih dari jumlah sekian empet (four) potong threads ini di-lahirkan (created) pas lagi ngejalanin kode di depannya ini. Jaminan ini ngegaransi ke seisi segenap bangsa sistem (our system) kita ke semuanya tidak bakal pernah sekalipun ikutan panik mabuk jadi kepanasan (overloaded) bahkan bilamana semisal jikalau aja mesin server kita itu dikeroyok dikirim hujan dideras rentetan gelombang panah ngerima request super bertubi-tubi seabreg rupa yang pada berjejer dateng masuk (receives a lot of requests). Andaikata emang bener lho kita sampe sengaja jahil iseng-iseng kita nge-request secara spesifik (make a request) ke alamat lintasan jalur (path) tujuan kita ini di /sleep, maka server kita yang satu ini emang bakal teteupan tetep tegak berdiri bisa santai terus dengan gampangnya bisa ngelayanin ngeresponi (serve) panggilan para rentetan requests yang lainnya gara-gara dia tinggal nyuruh thread Worker lain yang emang dari kemaren emang nganggur buat sekalian ikutan terjun ngerjain bantu manggil fungsi tasks tersebut.

Catatan: Andai kata lu maksa nekat ngebuka path rute link URL tujuan yang ditaruh di dalem rute khusus jebakan si /sleep ini di dalem banyakan (multiple) jajaran tumpukan sekian puluhan (multiple) browser windows secara bareng-bareng langsung sekaligus jebred dalam waktu sekerdipan mata bebarengan (simultaneously), kita mungkina aja kelewatan bingung kepancing ngerasa panik dan nyangka kok kelihatannya dia ngeladenin ngememuat nge-load-nya ganti-gantian dengan selang pelan durasi santai (time intervals) per five-second (selang lima deik) sekali padahal kita udah makek pool multithread. Aslinya benar-benar sebagian (some) tipe dari program jenis peramban web browsers jaman di luar sekarang (today) emang emang punya perilaku ngeksekusi kelakuan ganjil ngerapihin nge-barisin numpuk antrian deretan dari satu deretan requests-nya yang asalnya identik dan modelnya bener-bener punya endpoint rute kembar sama persis ganjil secara sengaja satu per satu berurut bergantian (sequentially), ya murni semata-mata itu gara-gara (reasons) sekadar buat tujuan urusan nge-caching. Intinya pembatasan kebodohan macem (limitation) begini ini mah samsek seutuhnya tidak diakibatin bersumber murni disebabkan (not caused by) ulah dari kelakuan kinerja web server yang kita lagi pada bikin ini.

Saat-saat momen sekarang yang ini emang kelihatannya pas rasanya emang waktu jeda yang enak (good time) buat minggir mingser ambil napas ngaso sebentar ngecoba ngebayangin nyimak (pause and consider) ngebandingin gimanakah jeroannya raut muka model rancangan tatanan bentukan kode-kodean barisan yang pada nangkring mentereng sedari di dalem bingkai bingkisan kotak Listings 21-18, 21-19, dan ujungnya juga di selipan 21-20 ini seumpamanya bentuk mereka itu dibikin pada agak berbeda sedikit andaikata kta beralih ngeganti jalan pikir buat emang lebih memilih (using) tatanan asinkron bernama futures ketimbang bertahan kukuh cuma memakai fungsional model closure doang buat emang membedah ngerjain mengeksekusi semua rupa tugas (work) tersebut. Bagian tipe-tipe spesifik manakah (what types) di sana yang pada bakal ngalamin ubah ganti baju (would change)? Kayak manakah (how) emangnya signatures aslinya dari method (method signatures)-nya ini kudu ikutan berubah drastis diganti-ganti mingser ke sana-kemari, seandainya emang emang perlu perombakan bener-bener (if at all)? Sisi pinggiran belah pecahan (parts of the code) manakah sih yang bakal tetep dibiarin aman bertahan (stay) anteng kagak perlu ikutan ngalamin diobok-obok perubahan (the same)?

Abis panjang lebar berpuas-puas dirimu beres nuntasin masa masa ngulik ngebaca bahan di pelajaran soal tata krama kelakuan dari jenis gubahan struktur kelakuan loop dari bentuk model while let ini yang kita kulik balik pas ngerampungin bab-bab awal di antara Bab 17 dan sembari selip di tengah-tengahnya Bab 19 pula, otak di kepala dirimu barangkali mulai berbisik kepikiran lari membatin (you might be wondering) kenapa yak pantesan kok kita nggak langsung main sabet milih aja buat mutusin secara kilat nekat naruh masang serangkaian urutan kode eksekusi milik utas thread khusus si Worker kita (Worker thread code) yang seolah nampak rupa cantiknya kayak yang sempet ngintip kepajang di dalem pajangan galeri Listing 21-21.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}
// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            while let Ok(job) = receiver.lock().unwrap().recv() {
                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listing 21-21: Alternatif rancangan rupa implementasi buat kode eksekusi dari si Worker::new yang coba-coba ngandelin while let

Jujur emang barisan rombongan susunan set perabot alat tempur kode-kodean ini mah emang aslinya (this code compiles and runs) pantes sukses bisa di-compile lalu berhasil sukses diajak buat running nyala-nyala aja aslinya asalkan dipanggil mah tanpa cacat, akan tetapi (but) sayangnya kelakuannya dia tidak bakal sanggup membikin lalu menyetorkan membuahkan asilnya tingkah prilaku eksekusi pola ngantri tata perlintasan multi threading yang aslinya sembenernya emang jujur jadi inceran idam-idaman (desired threading behavior) sasaran awal milik hajat (desired) diri kita: gara-gara ujung-ujungnya ya ntar sebuah request yang super lelet lambat (a slow request) jalannya bakal cuman ngehasilkan requests yang berentet di antrean lain di belakangnya yang tetep ngadat mandek mandeg antri merana kudu terpaksa harus disuruh wait menunggu dan antri ngurut memanjang barisan kebagian jatah jadwal biar mereka bisa dieksekusi dikelarin dibereskan dan diproses (processed). Perkara alasan dibalik reason kejanggalan ginian ini tergolong ada sedari kelakuan halus subtle: bahwa sesungguhnya susunan wujud si struct Mutex ini emang takditakdirkan emang tidak dikasih public method (metode publik) khusus sengaja dikasih nama panggilan buat fitur nge-unlock karena nyatanya status penguasa kunci miliknya kepemilikan jatah buat kuncian rahasia tersebut ini aslinya udah emang based on terikat mutlak dikunci ditentuin dari seberapa panjang seberapa bentar siklus riwayat hidup umur lifetime umurnya si benda keramat bertipe perlindungan yang menyandang identitas MutexGuard<T> yang mana sosok tersebut disisipkan rapi di perut selimut isi LockResult<MutexGuard> yang emang udah jadi kodrat nasib jatah tugas buat metode dari fitur bernama lock buat dia setorkan ngeluarin dia (returns) pas selesai beres dia dipanggil beroperasi jalan. Menjelang masa kompilasi (at compile time), sosok mandor tukang sensor pinjeman borrow checker inilah yang selanjutnya ganti berjaga bakal dengan telitinya bisa memelototi lalu memberlakukan narapain menegakkan menembakkan enforce the rule maklumat seputar tata kaidah yang ngegarisbawahi kalau setiap butir selongsong resource (sumber daya) yang tadinya ketat diselimuti diborgol ketat (guarded) diawasi pelindungan di belakang benteng pelindung sebuah palang gembok berjenis Mutex itu niscaya haram statusnya diharamkan (cannot be accessed) sama sekali dilarang terjamah alias tidak bakal mempan dibolehin buat bisa diutak-atik (diakses) oleh siapapun pun kalau seumpamanya status kita saat di kejadian eksekusi saat itu kondisinya lagi belom emang kita megang lalu punya si lock stempel kuncinya ini di tangan (unless we hold the lock). Masalahnya adalah, penerapan (implementation) kode yang bergaya ngasal semacam ini berisiko nimbul-nimbulin bahaya efek di mana wujud kuncian (lock) dari yang mestinya diserahin kembali ini ternyata masih dipaksa kudu bertahan kepeluk tertahan kepegang erat-erat tertahan di genggaman (being held) jauh memakan durasi yang kepanjangan sangat jauh di luar dari maksud jadwal normal target awal selesainya tugas aslinya seandainya (intended if we aren’t mindful) andaikata otak pikiran kita belom ngerasa gih hati-hati nyadari seputaran mindful ngeh dan perduli buat memandang mikirin masalah masa waktu jeda lifetime (lama waktu) dari MutexGuard tersebut.

Sepetak bentuk kodingan susunan naskah dari balok kode di balikan dalem ruang gubahan Listing 21-20 yang menumpukan tumpuannya di dalam bentuk susunan gaya pemakaian dari format pemanggilan dari perkenalan lajur baris berupa panggilan let job = receiver.lock().unwrap().recv().unwrap(); benar-benar jalan bekerja karena aslinya dengan membiasakan manggil (with let), rupa jenis semua ragam dari kumpulan serpihan serentetan aneka serabut serangkaian sosok rentetan sekian harga biji temporary values nilai-nilai angka dadakan instan bayangan yang kebetulan aja sementara disisipin (temporary values) dipakai sengaja dipakai di daleman seonggok kumpulan bongkahan expression tersebut yang ada bertumpuk mojok mentereng ngendon mangkal terparkir mejeng di perbatasan tapal batas perlintasan di ruas sisi lajur paling right-hand side pojokan sebelah barisan pinggiran sisi seberang belahan sisi arah sebelah kanannya dari batasan silang palang lintasan lambang sama dengan (the equal sign) bakal emang nasibnya dengan sadar seketika cepatnya dalam detik waktu itu juga (immediately dropped) dimusnahin diputus di-drop hilang tak bersisa ditiadakan pas waktu (when) persis saat barisan deretan panggilan milik sang let statement ini nemuin garis ajalnya dan kelar nyampe berakhir tamat ngakhirin garis ends nasib eksekusinya. Beda malang apes nasib halnya, si konstruksi kelakuan si panggilan buat while let (dan tak pelak kelakuan yang kembar sama juga melekat pada panggilan buat deretan panggilan di struktur perlintasan buat konstruksi barisan kelakuan if let dan juga konstruksi yang ngerujuk seputaran kelakuan match) sifat kodrat aslinya pantang dan sejatinya anti dan juga (does not) tidak pernah sekalipun membuang secara sepihak memusnahkan (drop) barang bawaannya beruba wujud barang rakitan nilai-nilai aneka rentetan sosok figur temporary values angka naskah serpihan bawaan serba sementaranya (temporary values) sisaan tersebut sebelum rentetan eksekusinya ini benar-benar merayap tuntas tiba berjalan sampai ngejejak sukses nutup berhasil (until the end of) merambat tiba menapaki penghujung purna tutup gawang palang blok kodenya (the associated block). Merujuk narasi skenario paparan kejadian malang perbandingan percontohan yang tergambar pas nongkrong mojok di sela daleman sketsa yang terbingkai rapi di sela Listing 21-21 ini tadi, sosok batang kuncian yang ditugasin gembok gerbang (lock) tetep ajeg kepaksa bakal awet mangkal terkunci (remains held) nangkring mengunci selagi sepanjang (for the duration of) di sepanjang rentetan durasi berlangsungnya kelakuan waktu (the call to) lamanya rentang periode pas fungsi tugas sang job() tersebut lagi ngejalanin rutinitas gawang di dalam masa rutinitasnya buat menunaikan (call to) ngejalanin seisi jeroan badan pemanggilan tugas kerjanya tsb., niscaya memunculkan satu hal arti rupa artian yang maknanya adalah emang pada saat detik-detik nasib gembok lagi kehalang nutup rupa begini maka segenap sekompi pasukan komplotan deretan gerombolan Worker instances yang other (para punggawa lainnya) bakal pastinya pada terpojok tak kan sanggup alias buntu dilarang masuk narik cannot receive jobs alias tidak ada satu pun mereka yang bakal sukses nerima narik sisa gilir antrian tugas sisa deretan job sisanya tsb.

Graceful Shutdown (Mati Secara Anggun) dan Cleanup (Bersih-bersih)

Graceful Shutdown (Mati Secara Anggun) dan Cleanup (Bersih-bersih)

Kode di Listing 21-20 itu benar-benar udah merespons ke requests secara asinkron (asynchronously) melalui penggunaan thread pool, sesuai sama apa yang kita rencanakan (intended). Tapi kita ngedapetin beberapa pesan peringatan (warnings) soal fields workers, id, dan thread yang nampaknya tidak kita pakai secara langsung, yang mana ngingetin (reminds) kita kalau kita ini belum ngelakuin acara bersih-bersih (cleaning up) apa pun. Pas kita memakai metode pencet tombol ctrl-C yang agak bar-bar (less elegant) buat ngehentiin (halt) eksekusi si main thread, semua threads lain di dalamnya juga bakal dihentiin seketika itu juga (immediately), biarpun mereka posisinya saat itu lagi di tengah-tengah nugas (in the middle of) ngelayanin sebuah request.

Maka dari itu selanjutnya, kita bakal mengimplementasikan trait Drop buat manggil join pada masing-masing threads di dalam pool tersebut supaya mereka bisa nyelesaiin dulu (finish) requests yang lagi mereka kerjain sebelum akhirnya bener-bener ditutup (closing). Baru deh setelah itu kita bakal mengimplementasikan sebuah cara buat ngasih tahu para threads kalau mereka seharusnya berhenti nerima requests baru lalu mematikan diri (shut down). Buat ngelihat aksi langsung dari kode ini, kita bakal memodifikasi server kita supaya dia cuma nerima maksimal dua buah requests aja sebelum akhirnya secara anggun (gracefully) mematikan semua proses di thread pool-nya.

Satu hal yang perlu diperhatiin (to notice) seiringan kita jalan: tidak ada satu pun dari proses ini yang bakal berdampak (affects) sama bagian-bagian kode yang tugasnya mengeksekusi (executing) closures, jadi segala hal yang ada di sini bakal tetep sama persis andaikata kita ini lagi memakai sebuah thread pool buat sebuah runtime async.

Mengimplementasikan Trait Drop pada ThreadPool

Mari kita mulai dengan mengimplementasikan Drop pada thread pool kita. Pas si pool tersebut di-drop, seharusnya semua threads kita ini di-join (digabungin) buat ngebuktiin dan mastiin (make sure) kalau mereka semua udah beres (finish) ngerjain kerjaan mereka. Listing 21-22 nunjukin percobaan perdana (first attempt) buat bikin implementasi Drop ini; tapi kode ini masih belum bakal jalan lho ya.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listing 21-22: Manggil join ke masing-masing thread pas si thread pool itu keluar dari scope

Pertama-tama kita melakukan perulangan (loop) ngelewatin masing-masing elemen di workers kepunyaan si thread pool. Kita memakai referensi &mut di sini karena self itu sendiri kan emang sebuah referensi mutable (bisa diubah), dan kita juga perlu bisa ngubah (mutate) si variabel worker. Buat masing-masing worker, kita mencetak sebuah pesan yang ngasih tahu kalau instance spesifik Worker ini tuh lagi bersiap dimatikan (shutting down), dan baru setelahnya kita manggil join pada nilai thread yang dimiliki instance Worker tersebut. Kalau pemanggilan ke join ini ternyata gagal (fails), kita memakai unwrap buat maksa Rust jadi panic dan masuk ke fase mati secara tidak anggun (ungraceful shutdown).

Ini dia error yang bakal kita dapat pas kita mencoba buat men-compile kode ini:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0507]: cannot move out of `worker.thread` which is behind a mutable reference
  --> src/lib.rs:52:13
   |
52 |             worker.thread.join().unwrap();
   |             ^^^^^^^^^^^^^ ------ `worker.thread` moved due to this method call
   |             |
   |             move occurs because `worker.thread` has type `JoinHandle<()>`, which does not implement the `Copy` trait
   |
note: `JoinHandle::<T>::join` takes ownership of the receiver `self`, which moves `worker.thread`
  --> /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/std/src/thread/mod.rs:1921:17

For more information about this error, try `rustc --explain E0507`.
error: could not compile `hello` (lib) due to 1 previous error

Pesan error ini ngasih tahu kita kalau kita itu tidak bisa manggil join gara-gara kita cuma punya sebuah pinjaman mutable (mutable borrow) dari masing-masing worker sedangkan si join ini mengambil hak kepemilikan (ownership) dari argumennya. Buat memecahkan (solve) isu ini, kita perlu buat mindahin (move) si nilai thread tersebut keluar dari instance Worker yang tadinya nge-hak miliki (owns) si thread itu supaya si join bisa narik memakan (consume) thread tersebut. Salah satu cara buat melakukan ini adalah dengan ngambil pendekatan (approach) yang sama kayak yang pernah kita lakuin di Listing 18-15. Kalau si Worker ini tadinya nampung Option<thread::JoinHandle<()>>, kita bisa aja manggil method take pada nilai Option tersebut buat mindahin (move) nilai aslinya dari varian Some lalu ninggalin sebuah varian None buat menempati posisinya (in its place). Dengan kata lain, sebuah Worker yang posisinya lagi jalan (running) bakal punya sebuah varian Some di dalam thread, dan pas kita pengen ngebersihin (clean up) sebuah Worker, kita bakal ngeganti si Some jadi None sehingga si Worker ini tidak bakal punya thread buat dijalanin.

Namun masalahnya, momen di mana proses ini itu dibutuhkan (come up) satu-satunya cuma terjadi saat kita lagi nge-drop si Worker aja kan. Konsekuensinya sebagai ganti rugi (in exchange), kita jadinya mesti terus-terusan berurusan (deal with) sama tipe Option<thread::JoinHandle<()>> di mana pun kita lagi nyoba ngakses worker.thread. Penulisan bahasa Rust yang idiomatik itu emang cukup sering sekali (quite a bit) memakai Option, tapi pas kita mulai nemuin diri kita lagi asik ngebungkusin (wrapping) sesuatu yang mana padahal kita udah tahu (know) nilai itu tuh selalu ada dan eksis (present) ke dalam sebuah Option cuma murni dijadiin sekadar jalan pintas buat ngakalin (workaround) hal macem gini, maka ini merupakan tanda ide yang bagus buat mulai nyari pendekatan alternatif (alternative approaches) supaya bisa ngebikin kode kita lebih rapi (cleaner) dan tidak gampang rawan error (less error-prone).

Di kasus yang ini, sebenernya ada jalan alternatif yang lebih oke (better alternative): yaitu method Vec::drain. Method ini menerima sebuah parameter jarak (range parameter) buat menentukan items mana aja yang mau dihilangkan dari dalam vector tersebut lalu dia bakal mengembalikan (returns) sebuah iterator dari item-item yang ditarik itu. Dengan memberikan sintaks range .. kita bakal mengosongkan dan menghilangkan semua (every) nilai dari vector tersebut.

Jadi kita perlu meng-update implementasi drop untuk ThreadPool supaya jadi kayak gini:

Filename: src/lib.rs
#![allow(unused)]
fn main() {
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
}

Kode ini ngeberesin dan nyelesein pesan error compiler tadi tanpa perlu adanya (require) perubahan lain lagi ke dalem kode kita. Perhatikan kalau, karena drop bisa aja dipanggil pas lagi masa-masa terjadi kepanikan (panicking), si unwrap ini juga bisa aja tiba-tiba ikutan panic yang mana akhirnya nyebabin kepanikan tumpuk ganda (double panic), yang mana bakal langsung membikin programnya crash (rusak) dan secara terpaksa ngakhirin semua proses pembersihan (cleanup) yang lagi berjalan. Buat ukuran program contoh kayak gini, tindakan panic ini sah-sah aja (fine), tapi hal kayak gini ya tidak disarankan (isn’t recommended) lho buat kode di level produksi.

Mengirim Sinyal ke Threads Supaya Berhenti Mendengarkan Jobs (Tugas) Baru

Dengan serangkaian perubahan yang udah kita bikin barusan, kode kita sekarang udah bisa sukses di-compile tanpa ngeluarin peringatan apa-apa. Namun, kabar buruknya adalah kalau kode ini itu masih belum (doesn’t function) berjalan pakai cara yang kita harepin. Kunci permasalahannya ada di logika yang ada di dalem closures yang lagi dijalanin (run by) sama threads kepunyaan instances si Worker: saat ini, kita emang udah manggil join, tapi hal itu tidak bakal bisa nutup mematikan (shut down) si threads ini lho, soalnya mereka itu pada asyik berputar (loop) nyari kerjaan (looking for jobs) buat selama-lamanya (forever). Kalau kita coba buat nge-drop si ThreadPool kita ini pakai implementasi drop kita yang sekarang, main thread-nya bakal malah ikutan mandek keblokir (block forever), diam selamanya nungguin thread yang pertama itu kelar (finish) yang mana tidak bakal bisa kelar.

Buat mbetulin (fix) masalah ini, kita perlu ngebikin satu ubahan (change) di dalam implementasi drop buat ThreadPool dan abis itu juga butuh satu ubahan di dalem perulangan (loop) si Worker.

Pertama-tama kita bakal ngubah implementasi drop pada ThreadPool supaya dia secara eksplisit nge-drop si sender (pengirim) ini sebelum dia nungguin para threads-nya pada kelar jalan. Listing 21-23 nunjukin rupa ubahan-ubahan ke ThreadPool tersebut buat secara eksplisit nge-drop sender. Tidak kayak yang terjadi di thread, di sini kita emang benar-benar butuh (do need) memakai Option biar kita bisa memindahkan (move) variabel sender keluar dari ThreadPool dengan memanggil Option::take.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}
// --snip--

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        // --snip--

        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listing 21-23: Secara eksplisit nge-drop sender sebelum nge-join para threads Worker

Tindakan men-drop (dropping) sender ini otomatis bakal nutup (closes) channel-nya, yang mana mengindikasikan kalau tidak bakal ada lagi pesan (messages) baru yang bakal dikirimin. Saat momen itu benar-benar terjadi, semua pemanggilan (calls) ke recv yang lagi dikerjain sama instances si Worker di dalam infinite loop (perulangan tiada henti)-nya bakal otomatis nge-return sebuah pesan error. Di Listing 21-24, kita ngubah bagian perulangan Worker supaya dia mau keluar (exit the loop) dengan anggun (gracefully) di dalam skenario kayak gitu (in that case), yang berarti threads-nya ini akhirnya benar-benar bisa kelar (finish) pas implementasi drop milik ThreadPool manggil join ke mereka-mereka semua.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let message = receiver.lock().unwrap().recv();

                match message {
                    Ok(job) => {
                        println!("Worker {id} got a job; executing.");

                        job();
                    }
                    Err(_) => {
                        println!("Worker {id} disconnected; shutting down.");
                        break;
                    }
                }
            }
        });

        Worker { id, thread }
    }
}
Listing 21-24: Secara eksplisit mecah kabur (breaking out) dari loop pas fungsi recv nge-return sebuah error

Buat bisa ngelihat sendiri aksi kode (code in action) ini benar-benar berjalan, mari kita modifikasi (modify) main supaya dia ini cuma nerima batas (accept only) dua requests aja sebelum akhirnya dia nutup (shutting down) si server-nya dengan anggun (gracefully), kayak yang ditunjukin di Listing 21-25.

Filename: src/main.rs
use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }

    println!("Shutting down.");
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-25: Menutup server setelah ngelayanin dua requests aja dengan cara ngelewatin/keluar dari loop-nya

kita pastinya sangat tidak mau ngarep (wouldn’t want) kalau sebuah web server betulan di dunia nyata tiba-tiba mati nutup sendiri (shut down) sesudah cuma ngelayanin dua buah requests doang. Tapi kan kode ini itu cuma murni (just demonstrates) dibikin buat pamer ngedemoin kalau kemampuan matikan proses secara anggun (graceful shutdown) dan fitur bersih-bersihnya (cleanup) emang benar-benar udah bisa kerja lancar beroperasi (in working order).

Method take ini aslinya udah didefinisikan secara bawaan di dalem trait Iterator yang mana dia berfungsi ngebatesin (limits) iteration tersebut supaya paling banter (at most) dia itu cuma muter buat ngerjain dua item pertama (first two items) doang. Terus, si ThreadPool ini jadinya bakal terpaksa dihempaskan keluar dari scope (go out of scope) pada momen titik paling ujung batas akhir dari fungsi main, dan otomatis implementasi fungsi drop miliknya bakal segera dioperasikan (will run).

Silakan nyalakan kembali (start) si server ini pakai instruksi cargo run, terus cobain bikin tiga (three) buah requests masuk ke sana. Pemanggilan request yang ketiga ini harusnya langsung ngebentur pesan error (should error), dan terus di dalem layar terminal kita itu kita kudu ngelihat tulisan keluaran (output) yang lumayan kelihatan persis mirip-mirip (similar) kayak ini:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/hello`
Worker 0 got a job; executing.
Shutting down.
Shutting down worker 0
Worker 3 got a job; executing.
Worker 1 disconnected; shutting down.
Worker 2 disconnected; shutting down.
Worker 3 disconnected; shutting down.
Worker 0 disconnected; shutting down.
Shutting down worker 1
Shutting down worker 2
Shutting down worker 3

kita emang sangat mungkin ngelihat kalau rupa dari urutan-urutan (ordering) barisan teks Worker ID dan tulisan pesannya yang nampil ke layar ini agak beda-beda tatanannya. Kita bisa perhatiin dengan jelas (can see) gimana dalemnya proses cara kerja kode ini dari bacaan pesannya (messages) tersebut: Instances si Worker urutan 0 dan 3 ternyata beruntung dapet nyaplok ngerjain (got) kedua belah requests rentetan awal. Terus si server ini mulai ngadat berhenti dan ogah ngerima (stopped accepting) adanya sambungan koneksi (connections) yang baru pas tepat habis selesai ngurus koneksi yang ke-dua tadi, dan si implementasi metode Drop yang nempel pada si ThreadPool pun langsung gas tancap jalan (starts executing) duluan padahal faktanya si Worker 3 aja malahan belom kelar nyalain (even starts) pekerjaannya. Kejadian di-drop-nya (dropping) sender ini langsung tanpa tedeng aling-aling melepaskan putus (disconnects) sambungan jembatan tali komunikasi ke semua penjuru instances si Worker lalu lantang nyuruh ngomando ngasih tahu (tells) mereka biar pada mingser bubar barisan nutup layanan (shut down). Tiap instances Worker masing-masing terus nyaut nge-print sebuah message saat tali mereka terputus dilepas (disconnect), dan abis beres itu semua, si thread pool tadi gantian secara mandiri bergegas manggil method join lurus dengan satu-satunya niat mau diem duduk setia menunggu (wait) supaya setiap barisan satu per satu dari Worker thread kelar (finish) ngerampungin pekerjaannya.

Coba teliti merhatikan (notice) adanya satu aspek detail perlakuan (aspect) yang lumayan kerasa asik unik (interesting) di balik serangkaian rentetan rupa spesifik proses eksekusi (execution) satu ini: di mana si ThreadPool udah keburu kelar nge-drop si sender, dan bahkan sebelum sempet ada instances Worker mana aja yang nangkep ngerima kode error (received an error), kita malahan udah ngebikin status program kita ini buat maksa nyoba nggabung (join) ke Worker urutan 0. Waktu momen itu ya, si Worker 0 benar-benar belum ada dapetin sinyal kabar tangkepan error apa-apa (not yet gotten an error) yang mana berasal dari tarikan recv, jadinya ya benar-benar wajar aja sih kalau si otak pusat jalan main thread-nya ini terpaksa mandeg macet diem nge-blok di jalan (blocked), ngaso setia sembari nungguin si Worker 0 supaya ngerampungin pekerjaannya sampai tuntas (finish). Eh pas lagi asik nunggu itu (In the meantime), si Worker 3 yang udah ketiban ngerima jatah mandat narik dapet (received) satu butir kerjaan (a job) yang selanjutnya diikuti oleh riwayat sisa-sisa para threads sekaliannya yang ikutan kebagian serentak seragam dapet nerima cipratan pesan eror (error). Terus nah pas begitu si Worker 0 kelar tuntas ngerjain jatah lapaknya (finished), si otak tengahnya (main thread) ini lantas lanjut ngecoba nungguin antrean jejeran gerbong serombongan sisa komplotan (the rest) punggawa instances Worker ini biar pada cepetan (to finish) juga ikutan kelar nutup lapaknya. Pada pas tibanya waktu masa (at that point) tersebut, eh ternyata semuanya tanpa basa-basi emang udah benar-benar pada tuntas cabut ngeluarin diri (exited) berhamburan dari daleman siklus lingkar putaran (loops) rute hidup mereka itu lalu udah berhenti total dari segala aktivitas hidup (stopped).

Selamat ya (Congrats)! Kita bener-bener udah sanggup nyampe di titik purna ngerampungin project ini secara utuh (completed); kita sekarang ini udah sah punyain (have) sepenggal program web server murni kelas cetek mendasar (basic) yang aslinya juga udah bisa jalan gagah memanfaatkan pengerahan tenaga tatanan sekelompok balok (thread pool) demi sanggup ngeresponi secara sigap lalu melayani sambutan balik secara asinkron tak serentak (asynchronously). Kita udah sangat terbukti mampu benar-benar melancarkan (perform) atraksi sebuah tarian purna pemutusan nafas nyawa graceful shutdown terhadap si komponen server tersebut, yang mana benar-benar menuntaskan lalu ngebersihin angkat tuntas ngurus sisaan barang bongkaran kotoran riwayat sampah kelakuan (cleans up) riwayat para seantero rentetan threads di ruang bilik pool ini.

Nih ditaruh rupa segenap rincian barisan kode final seutuhnya (full code) secara utuh komplit di sini sebagai referensi pedoman (reference) ya:

Filename: src/main.rs
use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }

    println!("Shutting down.");
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            if let Some(thread) = worker.thread.take() {
                thread.join().unwrap();
            }
        }
    }
}

struct Worker {
    id: usize,
    thread: Option<thread::JoinHandle<()>>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let message = receiver.lock().unwrap().recv();

                match message {
                    Ok(job) => {
                        println!("Worker {id} got a job; executing.");

                        job();
                    }
                    Err(_) => {
                        println!("Worker {id} disconnected; shutting down.");
                        break;
                    }
                }
            }
        });

        Worker {
            id,
            thread: Some(thread),
        }
    }
}

Kita emang nyatanya masih sangat bisa sih buat ngerjain (could do more) lebih jauh dan memodifikasi lebih heboh hal-hal lainnya lagi di tempat ini lho! Kalau seumpamanya emang hasrat di hati emang kita kepengen (want to) buat sudi terus merutinkan niat maju ngelanjut (continue) nambahin memoles (enhancing) gubahan kerangka arsitektur project ini jadi makin keren lagi mantep ke depannya, ini di bawah disediain list segelintir barisan rupa corak deretan bayangan pencerahan curhatan ide (some ideas) racikan kreasi mantap:

  • Lengkapin dan imbuhin rentetan sekelumit tambalan isi dari dokumentasi tambahan (more documentation) buat struct ThreadPool beserta metode-metode rentetan method public miliknya juga ya.
  • Coba isengin rakit bikinin sisipin sederet rentetan program tes asinkron (tests) buat nguji kemantapan jalan kelakuan isi fungsionalitas (functionality) jeroan library-nya ini.
  • Ganti rombak dan obrak-abrik rentetan pemanggilan wujud rupa unwrap yang kelewat asal nabrak paksa njerit ini supaya berubah format menjadi bentukan rentetan taktik kelakuan pengurusan error (error handling) yang karakternya terasa sifat jauh lumayan lebih kokoh perkasa pantang rontok (robust).
  • Manfaatin fitur keberadaan balok struktur ThreadPool ini untuk sengaja nyobain ngelaksanain rentetan panggilan tasks rutinitas yang bentukannya ini murni lain di luar formatnya sekadar menugas melayani (serving) serapan requests dari kancah rupa lalu-lalang aliran arus penjelajahan halaman dunia maya (web requests).
  • Sisihkan cari lirik bongkar-bongkar iseng nyari (Find) bentukan tipe produk paket rak buku kreasi buatan anak lain dari komunitas (thread pool crate) bertebaran liar pating mencelat lepas terpajang bebas melenggang kangkung rilis tebar nongol tersedia gratis pampang mejeng tayang di galeri pustaka etalase repositori perpustakaan lapak kerdus bebas gratis wadah pasar kumpulan crates.io sono terus pasang dan terapkan ngrakit lalu implement rupa susunan formasi wujud web server yang gaya alur kelakuan pola lagaknya miripin sebelas-dua-belas percis plek kayak (similar) gubahan hasil rancangan ini dengan murni nekat sekadar sepenuhnya murni berpatokan menunggang makek library produk kreasi bikinan orang laen the crate instead tersebut doang. Barulah (Then) setelah tuntasnya praktek pembuktian tsb. kelar disituasi tersebut silahkan monggo silakan (compare) coba banding-banding rupa rentetan kerangka rute API miliknya library itu sama wujud tingkat derajat kekuatan ketahanan banting kokoh (robustness) ketangguhan tahan uji banting tangguhnya punya dia dengan diukur diselidik secara adil membedakan dan aduin lurus dibandingin langsung dipajang sejajar teliti diseret ke hadapan dan disejajarkan ngebentur ke thread pool rakikan tangan pribadi asli mandiri punya yang baru kemaren udah sempet kita implement bikin bangun praktekin dari kemaren itu.

Ringkasan (Summary)

Mantap (Well done)! Kita udah berhasil nyampe tuntas tembus tamat nyentuh ujung akhir pucuk paling buntut sampul ujung (end) dari seri halaman buku ini! Kita semua dari diri kami di sini ini secara pribadi kepengen bener sekali pingin ngelempar sepatah kata berterima kasih sedalam-dalamnya buat ngaturin wujud hormat salam terima kasih tulus buat panjenengan-panjenengan semua yang udah ikhlas (thank you for) ngabisin waktu ngikut jalan jejer iring bersamai (joining) kita-kita nyusurin merambat rute panjang pelesiran perjalanan (tour) petualangan liburan berburu pesona Rust ini sedari titik garis pinggir batas mula dulu. Kini emang pastinya seutuhnya (now) kita ini udah sepenuhnya dirasa matang dan bener-bener dirasa dipastikan udah sangat siap sanggup sedia (ready to) langsung nge-gass terjang nerapin ngimplementasiin sendiri kreasi murni orisinil pribadi gagasan wujud gubahan deretan gagasan tatanan project rintisan (projects) program rill asli bikinan sendiri punya kita yang berbasis ngusung bendera teknologi sistem Rust lalu sekalian sudi dan rela rela buat ngeringanin naruh bantu campur turut menaruh campur tenaga ulur ngasih derma tangan ngebantu nambahin andil campur gotong berderma (help with) berkontribusi di gubahan wujud program garapan rintisan projects sumbangsih kreasi racikan buatan anak punggawa temen sejawat people’s projects (orang-orang) pahlawan tetangga sanak rupa saudara kita di sekitar sana. Harus dimasukin paksa (Keep in mind) diranap di simpen lekat ingatan tanam patri ke dalem nalar benak kepala ingat-ingat simpan resapin ya di ingatan (mind) dalem bawah sadar otak memori paten benak kepalamu ini andaikata (that) ini tuh sesungguhnya nyata terang bersinar nyata terang benderang niscaya terbentang terbentang mekar membentang subur emang masih menyisa (there is a) terhuni rupa sepetak jajaran luas gerombolan wadah kerumunan kelompok perhimpunan welcoming community (komunitas yang sangat terbuka nyambut ramah dan sedia hangat menjamu rupa tangan senyum terbuka gembira lebar dada tulus nyambut seneng asri hangat peluk) persaudaraan ikatan erat rupa serumpun bangsa perkumpulan perserikatan komplotan jejaring other Rustaceans (kalangan para pengabdi programmer pemuja aliran seiman setia pejuang pendekar kode penyuka aliran kepercayaan kasta Rust ksatria lain-lainnya sesama punggawa yang sealiran) lainnya di pelosok buana pelosok sana luar sekitar pojokan pelosok dunia sana ini yang tak ada bosannya tak kan bakal pernah nyerah sudi ikhlas niscaya aslinya emang would love to (pada seneng rela hati bakal cinta mati gemar nyenengin cinta ngebet pengen gemar dan hobi pake cinta bahagia hepi benar-benar girang tulus girang rela asyik seru sumringah demen kepingin riang berhati tulus bakal doyan bakal suka bakal sayang dan sangat girang sekali teramat rindu sangat ingin mau rela) ikhlas turun nolong bantu menyambut nyuapin nyuapin kasih (help you with) membina mandu memayungi mendampingin mandorin nyodor tangan mbopong mbimbing nolong kita-kita pada ikhlas terjun dan andil nimbrung bantuin mberesin dan nyikat beresin mengurus sedia ngadepin dan ngeberesin (any challenges) seberapa parah gawatnya semua aneka segudang rupa kendala aral ragam himpitan halang aneka lika rupa jurang duri kesulitan ujian badai cobaan halangan ragam perkara problema jerat tikungan batu cadas tantangan rintang terjalan problem himpitan perkara ganjalan segenap segala seberat sedempet serepot rupa kendala seisi sebentang onak seisi segala segenap (any) ragam masalah kendala benturan apa jua sekalipun belaka apa pun bentuknya rupa aja yang di jalan nanti kebetulan emang rupa nyata riil fakta nyatanya you encounter (kita tabrak kepentok dapati tabrak papasi bersua sandung hantam ketatap kebentur derita alami jumpa dapet tebas terjang lalui pergokin tempuh alami libas gilas temui tabrak lewati hantam rasakan langgar cicipin terjang tatap derita lintasi rasai cicip rasain hadapi cicip deritain hadapi hadapi) ke-tubruk kepentok bersua melintang terlintas nyandung hadap temui hadapi sandung kita di belantara sepanjang masa-masa pelayaran peruntungan jalan pendakian jalur petualang perjalanan lintas jalur rute jelajah ngeluyur (journey) pengembaraan karir kiprah rekam pelesiran perjalanan Rust (kancah rute perjalanan karir Rust) asuhan panjenengan kita seiring maju langkah laju menapaki jejak berjejak kita maju panjang menjejak merentas jauh berjalan ini di waktu ke detik harinya nanti menjejak (your Rust journey).

Lampiran

Bagian-bagian berikut berisi materi referensi yang mungkin berguna bagi Kita dalam perjalanan Rust Kita.

A - Kata Kunci (Keywords)

Lampiran A: Kata Kunci (Keywords)

Daftar berikut ini berisi keywords (kata kunci) yang direservasi buat penggunaan saat ini maupun penggunaan di masa depan oleh bahasa Rust. Oleh karena itu, kata-kata ini tidak bisa dipakai sebagai identifiers (pengidentifikasi) (terkecuali dipakai sebagai raw identifiers, seperti yang bakal kita obrolin di bagian “Raw Identifiers”). Identifiers adalah nama-nama fungsi, variabel, parameter, field struct, modul, crates, konstanta, macros, nilai statis, atribut, tipe, traits, atau lifetimes.

Keywords yang Saat Ini Dipakai

Berikut adalah daftar keywords yang saat ini sedang dipakai, beserta penjelasan fungsionalitasnya.

  • as: melakukan casting (pengubahan tipe) primitif, menghilangkan ambiguitas trait spesifik yang menampung sebuah item, atau mengganti nama item di dalam statement use
  • async: mengembalikan sebuah Future ketimbang memblokir thread saat ini
  • await: menahan eksekusi sampai hasil dari sebuah Future sudah siap
  • break: keluar dari sebuah perulangan (loop) secara langsung
  • const: mendefinisikan item konstanta atau raw pointers konstanta
  • continue: lanjut ke iterasi perulangan berikutnya
  • crate: di dalam module path, ini merujuk ke akar crate (crate root)
  • dyn: penyaluran dinamis (dynamic dispatch) ke sebuah trait object
  • else: jalan alternatif (fallback) untuk struktur control flow if dan if let
  • enum: mendefinisikan sebuah enumerasi
  • extern: menautkan (link) sebuah fungsi atau variabel eksternal
  • false: nilai literal salah (false) pada Boolean
  • fn: mendefinisikan sebuah fungsi atau tipe dari function pointer
  • for: perulangan (loop) melewati item-item dari sebuah iterator, mengimplementasikan sebuah trait, atau menentukan higher-ranked lifetime
  • if: percabangan berdasarkan hasil dari ekspresi kondisional
  • impl: mengimplementasikan fungsionalitas bawaan (inherent) atau fungsionalitas trait
  • in: bagian dari sintaks perulangan for
  • let: mengikat (bind) sebuah variabel
  • loop: perulangan (loop) tanpa syarat
  • match: mencocokkan sebuah nilai terhadap patterns (pola-pola)
  • mod: mendefinisikan sebuah modul
  • move: membuat closure mengambil alih kepemilikan (ownership) atas semua nilai yang ditangkapnya (captures)
  • mut: menandakan mutabilitas pada referensi, raw pointers, atau pattern bindings
  • pub: menandakan visibilitas publik pada field struct, blok impl, atau modul
  • ref: mengikat berdasarkan referensi
  • return: mengembalikan nilai dari fungsi
  • Self: type alias untuk tipe yang sedang kita definisikan atau implementasikan
  • self: subjek dari method atau modul saat ini
  • static: variabel global atau lifetime yang berlangsung selama keseluruhan eksekusi program
  • struct: mendefinisikan sebuah struktur
  • super: modul induk (parent module) dari modul saat ini
  • trait: mendefinisikan sebuah trait
  • true: nilai literal benar (true) pada Boolean
  • type: mendefinisikan type alias atau associated type
  • union: mendefinisikan union; hanya menjadi keyword saat dipakai di dalam deklarasi union
  • unsafe: menandakan kode, fungsi, trait, atau implementasi yang tidak aman
  • use: membawa symbols ke dalam scope; menentukan tangkapan pasti (precise captures) untuk batasan generic dan lifetime
  • where: menandakan klausa yang membatasi (constrain) sebuah tipe
  • while: perulangan bersyarat berdasarkan hasil dari sebuah ekspresi

Keywords yang Direservasi buat Penggunaan di Masa Depan

Keywords berikut ini belum punya fungsionalitas apa pun, tapi sudah direservasi oleh Rust buat potensi pemakaian di masa depan:

  • abstract
  • become
  • box
  • do
  • final
  • gen
  • macro
  • override
  • priv
  • try
  • typeof
  • unsized
  • virtual
  • yield

Raw Identifiers

Raw identifiers adalah sintaks yang memungkinkan kita memakai keywords di tempat-tempat di mana mereka biasanya tidak diperbolehkan. Kita bisa memakai raw identifier dengan menambahkan awalan r# pada sebuah keyword.

Misalnya, match itu adalah sebuah keyword. Kalau kita mencoba men-compile fungsi berikut yang memakai match sebagai namanya:

Nama File: src/main.rs

fn match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}

kita bakal mendapat error ini:

error: expected identifier, found keyword `match`
 --> src/main.rs:4:4
  |
4 | fn match(needle: &str, haystack: &str) -> bool {
  |    ^^^^^ expected identifier, found keyword

Error tersebut menunjukkan kalau kita tidak bisa memakai keyword match sebagai identifikasi fungsi. Supaya bisa memakai match sebagai nama fungsi, kita perlu memakai sintaks raw identifier, kayak gini:

Nama File: src/main.rs

fn r#match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}

fn main() {
    assert!(r#match("foo", "foobar"));
}

Kode ini bakal berhasil di-compile tanpa error. Perhatikan awalan r# pada nama fungsi di bagian definisinya sekaligus di tempat fungsi tersebut dipanggil di dalam main.

Raw identifiers membiarkan kita memakai kata apa pun sebagai identifier, bahkan jika kata tersebut kebetulan adalah keyword yang direservasi. Hal ini ngasih kita kebebasan lebih dalam memilih nama identifier, sekaligus memungkinkan kita berintegrasi dengan program yang ditulis di bahasa lain di mana kata-kata tersebut bukanlah keywords. Selain itu, raw identifiers juga memungkinkan kita memakai libraries yang ditulis dalam edisi (edition) Rust yang berbeda dari yang dipakai oleh crate kita. Misalnya, try bukanlah keyword di edisi 2015, tapi dia menjadi keyword di edisi 2018, 2021, dan 2024. Kalau kita bergantung pada sebuah library yang ditulis pakai edisi 2015 dan punya fungsi try, kita harus memakai sintaks raw identifier, yaitu r#try di kasus ini, buat memanggil fungsi tersebut dari kode kita di edisi-edisi yang lebih baru. Lihat Lampiran E untuk informasi lebih lanjut tentang editions.

B - Operator dan Simbol

Lampiran B: Operator dan Simbol

Lampiran ini berisi glosarium (kamus ringkas) tentang sintaks Rust, termasuk operator dan simbol-simbol lain yang muncul sendirian maupun di dalam konteks seperti paths, generics, trait bounds, macros, atribut, komentar, tuples, dan tanda kurung.

Operator

Tabel B-1 berisi daftar operator di Rust, contoh gimana operator tersebut muncul di dalam konteksnya, penjelasan singkat, dan apakah operator tersebut bisa di-overload (overloadable) atau tidak. Kalau sebuah operator bisa di-overload, trait relevan yang dipakai buat nge-overload operator tersebut juga dicantumkan.

Tabel B-1: Operator

OperatorContohPenjelasanBisa Di-overload?
!ident!(...), ident!{...}, ident![...]Ekspansi macro
!!exprBitwise atau logical complement (komplemen logika)Not
!=expr != exprPerbandingan ketidaksamaan (nonequality)PartialEq
%expr % exprSisa hasil bagi (remainder) aritmatikaRem
%=var %= exprSisa hasil bagi aritmatika dan assignment (penugasan)RemAssign
&&expr, &mut exprMeminjam (Borrow)
&&type, &mut type, &'a type, &'a mut typeTipe pointer pinjaman (Borrowed pointer type)
&expr & exprBitwise ANDBitAnd
&=var &= exprBitwise AND dan assignmentBitAndAssign
&&expr && exprLogical AND (hubungan singkat/short-circuiting)
*expr * exprPerkalian aritmatikaMul
*=var *= exprPerkalian aritmatika dan assignmentMulAssign
**exprDereference (Membuka rujukan)Deref
**const type, *mut typeRaw pointer
+trait + trait, 'a + traitBatasan tipe gabungan (Compound type constraint)
+expr + exprPenjumlahan aritmatikaAdd
+=var += exprPenjumlahan aritmatika dan assignmentAddAssign
,expr, exprPemisah argumen dan elemen
-- exprNegasi aritmatikaNeg
-expr - exprPengurangan aritmatikaSub
-=var -= exprPengurangan aritmatika dan assignmentSubAssign
->fn(...) -> type, |…| -> typeTipe balasan (return type) fungsi dan closure
.expr.identAkses ke field
.`expr.ident(expr, …+trait + trait, 'a + trait
+expr + exprPenjumlahan aritmatikaAdd
+=var += exprPenjumlahan aritmatika dan assignmentAddAssign
,expr, exprPemisah argumen dan elemen
-- exprNegasi aritmatikaNeg
-expr - exprPengurangan aritmatikaSub
-=var -= exprPengurangan aritmatika dan assignmentSubAssign
->fn(...) -> type, |…| -> typeTipe balasan (return type) fungsi dan closure
.expr.identAkses ke field
.`expr.ident(expr, …
^expr ^ exprBitwise exclusive ORBitXor
^=var ^= exprBitwise exclusive OR dan assignmentBitXorAssign
|pat | patAlternatif di pattern
|expr | exprBitwise ORBitOr
|=var |= exprBitwise OR dan assignmentBitOrAssign
||expr || exprLogical OR (hubungan singkat/short-circuiting)
?expr?Propagasi error

Simbol Non-Operator

Tabel-tabel berikut ini berisi semua simbol yang tidak berfungsi sebagai operator; yang artinya, mereka tidak berperilaku seperti pemanggilan fungsi atau method.

Tabel B-2 menunjukkan simbol-simbol yang muncul sendirian dan valid dipakai di berbagai macam tempat.

Tabel B-2: Sintaks Berdiri Sendiri (Stand-Alone Syntax)

SimbolPenjelasan
`’

Tabel B-3 nunjukin simbol-simbol yang muncul di dalam konteks penulisan jalur (path) melewati hierarki modul menuju sebuah item.

Tabel B-3: Sintaks Terkait Path

Tabel B-4 nunjukin simbol-simbol yang muncul di konteks pemakaian parameter tipe generic.

Tabel B-4: Generics

Tabel B-5 menunjukkan simbol-simbol yang muncul di dalam konteks untuk membatasi (constraining) parameter tipe generik dengan trait bounds (batasan trait).

Tabel B-5: Batasan Trait Bound

SimbolPenjelasan
T: UParameter generik T dibatasi pada tipe-tipe yang mengimplementasikan U
T: 'aTipe generik T wajib berumur lebih panjang (outlive) dari lifetime 'a (artinya tipe tersebut tidak boleh secara transitif menampung referensi yang punya lifetime lebih pendek dari 'a)
T: 'staticTipe generik T tidak punya referensi pinjaman (borrowed references) selain referensi yang bersifat 'static
'b: 'aLifetime generik 'b wajib berumur lebih panjang (outlive) dari lifetime 'a
T: ?SizedMengizinkan parameter tipe generik untuk bisa berupa tipe yang berukuran dinamis (dynamically sized type)
'a + trait, trait + traitBatasan tipe gabungan (Compound type constraint)

Tabel B-6 menunjukkan simbol-simbol yang muncul di konteks untuk memanggil atau mendefinisikan macros dan nentuin atribut buat sebuah item.

Tabel B-6: Macros dan Atribut

SimbolPenjelasan
#[meta]Atribut luar (Outer attribute)
#![meta]Atribut dalam (Inner attribute)
$identSubstitusi (penggantian) macro
$ident:kindMetavariabel macro
$(...)...Pengulangan macro (Macro repetition)
ident!(...), ident!{...}, ident![...]Pemanggilan (invocation) macro

Tabel B-7 menunjukkan simbol-simbol yang berfungsi buat ngebikin komentar.

Tabel B-7: Komentar

SimbolPenjelasan
//Komentar baris
//!Komentar dokumentasi baris bagian dalam (Inner line doc comment)
///Komentar dokumentasi baris bagian luar (Outer line doc comment)
/*...*/Komentar blok (Block comment)
/*!...*/Komentar dokumentasi blok bagian dalam (Inner block doc comment)
/**...*/Komentar dokumentasi blok bagian luar (Outer block doc comment)

Tabel B-8 nunjukin konteks di mana tanda kurung biasa (parentheses) dipakai.

Tabel B-8: Tanda Kurung Biasa (Parentheses)

SimbolPenjelasan
()Tuple kosong (dikenal juga dengan sebutan unit), baik literal maupun tipenya
(expr)Ekspresi yang dibungkus tanda kurung
(expr,)Ekspresi tuple dengan satu elemen tunggal
(type,)Tipe tuple dengan satu elemen tunggal
(expr, ...)Ekspresi tuple
(type, ...)Tipe tuple
expr(expr, ...)Ekspresi pemanggilan fungsi; juga dipakai buat menginisialisasi tuple structs dan varian dari tuple enum

Tabel B-9 nunjukin konteks di mana kurung kurawal (curly brackets) dipakai.

Tabel B-9: Kurung Kurawal (Curly Brackets)

KonteksPenjelasan
{...}Ekspresi blok
Type {...}Literal struct

Tabel B-10 nunjukin konteks di mana kurung siku (square brackets) dipakai.

Tabel B-10: Kurung Siku (Square Brackets)

KonteksPenjelasan

C - Trait yang Bisa Di-derive

Lampiran C: Derivable Traits (Trait yang Bisa Di-derive)

Di berbagai tempat di buku ini, kita udah ngebahas atribut derive, yang mana bisa kita sematkan ke definisi struct atau enum. Atribut derive ini bakal menghasilkan kode yang mengimplementasikan (implement) sebuah trait lengkap dengan implementasi default (bawaan)-nya pada tipe yang udah kita anotasi (annotated) memakai sintaks derive tersebut.

Di lampiran ini, kita menyediakan referensi dari semua traits di standard library yang mana bisa kita pakai dengan atribut derive. Masing-masing bagian mencakup:

  • Operator dan method apa saja yang bakal difungsikan (enable) dengan nge-derive trait ini
  • Apa saja yang dilakukan sama implementasi trait yang disediain oleh derive tersebut
  • Apa makna (signifies) dari mengimplementasikan trait tersebut bagi tipe kita
  • Persyaratan dan kondisi di mana kita dibolehkan atau tidak dibolehkan buat mengimplementasikan trait ini
  • Contoh-contoh operasi yang mewajibkan adanya trait ini

Kalau kita pengen mendapatkan perilaku yang berbeda dari yang disediain sama atribut derive, silakan cek dokumentasi standard library buat setiap trait demi mendapatkan detail soal gimana caranya buat mengimplementasikan mereka secara manual.

Trait-trait yang terdaftar di sini adalah satu-satunya trait yang didefinisikan sama standard library yang bisa diimplementasikan ke tipe kita memakai derive. Trait lain yang didefinisikan di standard library itu tidak punya perilaku bawaan (default) yang masuk akal (sensible), jadinya itu semua tergantung kita buat mengimplementasikannya pakai cara yang paling masuk akal sejalan dengan apa yang mau kita capai.

Contoh dari sebuah trait yang tidak bisa di-derive adalah Display, yang mana bertugas menangani format teks buat para pengguna akhir (end users). Kita harus selalu mempertimbangkan cara yang paling pantas buat menampilkan sebuah tipe ke end user. Bagian mana aja dari tipe tersebut yang boleh dilihat sama end user? Bagian mana aja yang bakal mereka anggap relevan? Format data kayak gimana yang bakal paling gampang dimengerti sama mereka? Compiler Rust tidak punya wawasan (insight) kayak gini, jadinya dia tidak bisa menyediakan perilaku default yang pantas buat kita.

Daftar derivable traits (trait yang bisa di-derive) yang disediain di lampiran ini itu tidaklah komprehensif: libraries lain bisa aja ngimplementasiin derive buat trait mereka sendiri, ngebikin daftar trait yang bisa kita pakai dengan derive itu bener-bener jadi tanpa batas (open-ended). Mengimplementasikan derive ini melibatkan pemakaian procedural macro, yang mana udah dibahas di bagian “Custom derive Macros” di Bab 20.

Debug Buat Output Programmer

Trait Debug memfungsikan (enables) pemformatan debug di dalem format strings, yang mana bisa kita indikasikan dengan nambahin sisipan :? ke dalem kurung kurawal {} (placeholders).

Trait Debug ngasih kita kebebasan buat mencetak instances dari sebuah tipe buat tujuan debugging (pemeriksaan error), supaya kita dan programmer lainnya yang lagi makek tipe kita tersebut bisa menginspeksi instance tersebut pas ada di satu titik tertentu di program pas lagi dieksekusi.

Trait Debug diwajibkan, misalnya, saat kita menggunakan macro assert_eq!. Macro ini akan mencetak nilai-nilai dari instances yang diberikan kepadanya sebagai argumen jika equality assertion gagal sehingga para programmer bisa melihat dengan jelas alasan mengapa kedua instance tersebut tidak sama.

PartialEq dan Eq Buat Perbandingan Kesamaan (Equality Comparisons)

Trait PartialEq ngasih kita kemungkinan buat ngebandingin instances dari sebuah tipe buat mengecek apakah mereka itu sama atau tidak, dan juga memfungsikan pemakaian operator == dan !=.

Nge-derive PartialEq bakal mengimplementasikan method eq. Pas PartialEq di-derive pada structs, dua instances dianggap sama hanya jika semua fields-nya sama, dan kedua instances itu bakal dianggap tidak sama kalau ada field apa pun yang tidak sama. Saat di-derive pada enums, masing-masing varian bakal dianggap sama dengan dirinya sendiri dan tidak sama dengan varian lainnya.

Trait PartialEq diwajibkan, misalnya, pas lagi memakai macro assert_eq!, yang mana dia perlu bisa ngebandingin dua buah instances dari suatu tipe buat ngelihat apakah mereka sama (equality).

Trait Eq tidak punya method apa-apa. Tujuannya adalah sekadar buat ngasih tanda (signal) bahwa untuk setiap nilai dari tipe yang dianotasi, nilai itu dijamin pasti sama dengan dirinya sendiri. Trait Eq ini cuma bisa diterapin (applied) ke tipe-tipe yang juga mengimplementasikan PartialEq, walaupun tidak semua tipe yang mengimplementasikan PartialEq itu otomatis bisa mengimplementasikan Eq. Salah satu contohnya adalah pada tipe floating point number: implementasi perbandingan buat angka floating point menyatakan kalau dua instances dari nilai not-a-number (NaN) itu tidaklah sama (not equal) dengan satu sama lain.

Salah satu contoh kasus di mana Eq diwajibkan adalah untuk keys (kunci) di dalem sebuah HashMap<K, V> supaya si HashMap<K, V> ini bisa ngebedain apakah dua keys yang ada itu benar-benar sama (same) atau tidak.

PartialOrd dan Ord Buat Perbandingan Pengurutan (Ordering Comparisons)

Trait PartialOrd memungkinkan kita buat membandingkan instances dari suatu tipe buat keperluan sorting (pengurutan). Sebuah tipe yang mengimplementasikan PartialOrd bisa dipakai dengan operator <, >, <=, dan >=. Kita cuma bisa memakai atribut trait PartialOrd ini ke tipe-tipe yang juga udah mengimplementasikan PartialEq.

Nge-derive PartialOrd bakal mengimplementasikan method partial_cmp, yang bakal mengembalikan sebuah Option<Ordering> yang mana isinya bakal berupa None kalau nilai-nilai yang dikasih itu ternyata gagal memproduksi sebuah urutan (ordering). Contoh dari sebuah nilai yang gagal memproduksi urutan, sekalipun sebagian besar nilai di tipe tersebut aslinya bisa dibandingin, adalah nilai not-a-number (NaN) pada floating point. Manggil partial_cmp memakai angka floating-point mana pun dicampur dengan nilai NaN floating-point pasti bakal mengembalikan None.

Saat di-derive pada structs, PartialOrd ngebandingin dua buah instances dengan cara ngebandingin setiap nilai di dalam tiap fields-nya berdasarkan dengan urutan kemunculan fields tersebut di saat struct-nya didefinisikan (struct definition). Saat di-derive pada enums, varian-varian enum yang dideklarasikan (muncul) lebih awal di dalam definisi enum bakal dianggap lebih kecil (less than) ketimbang varian-varian yang muncul belakangan.

Trait PartialOrd diwajibkan, misalnya, buat pemakaian method gen_range dari crate rand yang tugasnya menghasilkan nilai acak di dalem jangkauan (range) yang udah dispesifikasikan pakai ekspresi range.

Trait Ord ngasih tahu kita kalau, untuk sembarang dua nilai apa pun dari tipe yang udah dianotasi, pastilah selalu ada sebuah sistem pengurutan yang valid yang bakal eksis (exist). Trait Ord mengimplementasikan method cmp, yang mengembalikan sebuah tipe Ordering dan bukan Option<Ordering> karena sebuah pengurutan yang valid itu bakal selalu dijamin selalu mungkin buat terjadi. Kita cuma boleh naruh atribut trait Ord ke tipe yang mana juga udah mengimplementasikan PartialOrd sekaligus Eq (dan perlu diingat kalau Eq juga mewajibkan adanya PartialEq). Saat di-derive pada structs dan enums, method cmp bakal beroperasi (behaves) pakai cara yang sama persis kayak apa yang dilakuin sama implementasi yang di-derive untuk method partial_cmp yang ada di dalam PartialOrd.

Salah satu contoh pas Ord diwajibkan (required) adalah saat kita mau nyimpen nilai-nilai ke dalam sebuah BTreeSet<T>, yaitu struktur data yang nyimpen data berdasarkan urutan sort (pengurutan) dari nilai-nilai tersebut.

Clone dan Copy Buat Menduplikasi Nilai (Duplicating Values)

Trait Clone membiarkan kita secara eksplisit ngebikin deep copy (salinan mendalam) dari sebuah nilai, dan proses duplikasi ini juga bisa jadi ngelibatin pengeksekusian (running) kode apa pun dan penyalinan data heap. Silakan lihat bagian “Variabel dan Data Berinteraksi dengan Clone” di Bab 4 buat informasi lebih jauh soal Clone.

Nge-derive Clone bakal mengimplementasikan method clone, yang mana saat diimplementasikan buat keseluruhan tipe, dia bakal memanggil clone juga secara beruntun pada masing-masing komponen dari tipe tersebut. Artinya semua fields atau bagian nilai yang ada di tipe tersebut harus mutlak udah mengimplementasikan Clone juga supaya tipe utamanya bisa nge-derive Clone.

Sebuah contoh kapan Clone diwajibkan adalah pas kita lagi manggil method to_vec pada sebuah slice. Si slice ini kan emang tidak ngantongin (doesn’t own) instances dari tipe yang disimpennya, tapi vector yang dikembalikan dari panggilan to_vec itu jelas butuh dan wajib punya hak milik (own) buat instances yang ada di dalemnya, makanya si to_vec ini bakal manggil method clone buat setiap item yang ada. Dengan demikian, tipe yang disimpan di dalam slice tersebut wajib mengimplementasikan Clone.

Trait Copy membiarkan kita buat menduplikasi sebuah nilai cuma dengan cara meng-copy bits yang ada di dalam memori stack aja; jadinya tidak perlu ada pengeksekusian kode tambahan apa-apa di sini. Lihat bagian “Stack-Only Data: Copy” di Bab 4 buat informasi lebih jauh soal Copy.

Trait Copy ini sama sekali tidak mendefinisikan method apa pun karena tujuannya itu buat mencegah (prevent) para programmer dari nge-overload method tersebut dan ngelanggar (violating) asumsi mutlak bahwa tidak ada eksekusi kode acak apa pun yang berjalan selama proses duplikasi ini. Dengan cara kayak gini, semua programmer bisa berasumsi dengan aman (assume) kalau aksi meng-copy sebuah nilai yang punya trait ini bakal kerasa cepet sekali (very fast).

Kita bisa nge-derive Copy pada tipe apa pun yang mana semua elemen komponennya itu udah mengimplementasikan Copy. Tipe yang udah mengimplementasikan Copy juga wajib mutlak mengimplementasikan Clone, karena sebuah tipe yang mengimplementasikan Copy otomatis udah punya implementasi buat Clone yang sepele (trivial implementation) yang menunaikan tugas yang sama persis kayak Copy tersebut.

Trait Copy itu jarang sekali diwajibkan secara eksplisit; tapi tipe-tipe yang mengimplementasikan Copy punya privilese ketersediaan buat dioptimasi (optimizations available), yang artinya kita jadinya tidak perlu rajin-rajin ngetik manggil clone, yang mana ngebikin kodenya jadi lebih padat (concise) dan ringkas.

Segala apa pun yang bisa dicapai (possible) pakai Copy tentu juga bisa kita capai (accomplish) dengan memakai Clone, tapi ya kodenya itu mungkin bisa jadi agak lebih lelet (slower) atau menuntut keharusan buat manggil clone di mana-mana (in places).

Hash Buat Memetakan Nilai ke Nilai Lain Berukuran Tetap (Fixed Size)

Trait Hash ngasih kita kapabilitas buat ngambil instance dari sebuah tipe dengan ukuran yang sembarang (arbitrary size) lalu memetakan (map) instance tersebut menjadi sebuah nilai yang punya ukuran tetap (fixed size) menggunakan sebuah fungsi hash. Nge-derive Hash bakal mengimplementasikan method hash. Implementasi derived dari method hash ini bakal ngegabungin (combines) seluruh hasil dari pemanggilan method hash ke setiap komponen (parts) dari tipe tersebut, yang artinya bahwa semua fields atau nilai yang ada di dalem tipe ini wajib juga udah mengimplementasikan Hash supaya si tipe utamanya ini bisa di-derive sama Hash.

Contoh dari kasus di mana Hash diwajibkan (required) adalah pas kita mau nyimpen keys (kunci) di dalam koleksi HashMap<K, V> buat tujuan menyimpen data secara lebih efisien (efficiently).

Default Buat Pembuatan Nilai Bawaan (Default Values)

Trait Default ngebolehin kita buat ngebikin nilai bawaan (default value) buat sebuah tipe. Nge-derive Default bakal mengimplementasikan fungsi default. Implementasi turunan (derived implementation) dari fungsi default ini bakal ngerjain tugasnya dengan cara ikut-ikutan manggil fungsi default buat setiap komponen/elemen dari tipe tersebut, yang mana tentu artinya semua fields atau bagian-bagian nilai di dalam tipe tersebut juga diwajibkan (must also) buat udah mengimplementasikan Default biar tipe utamanya bisa nge-derive trait Default.

Fungsi Default::default ini umumnya sering dipakai digabungin barengan sama sintaks pembaruan struct (struct update syntax) yang pernah kita obrolin di bagian “Ngebikin Instances dari Instances Lain dengan Struct Update Syntax” di Bab 5. Kita bisa mengkustomisasi beberapa fields tertentu aja dari sebuah struct dan sesudahnya itu mengatur (set) lalu memakai nilai default bawaannya buat ngisi sisa bidang-bidang (rest of the fields) yang belum diisi dengan cara memakai ..Default::default().

Trait Default diwajibkan saat kita memakai method unwrap_or_default pada instances dari tipe Option<T>, contohnya. Kalau nilai Option<T>-nya ternyata adalah None, method unwrap_or_default ini nantinya bakal nge-return (ngembaliin) hasil tebakan tebasan yang asalnya dari panggilan Default::default buat si tipe T yang lagi disimpen di dalam Option<T> tersebut.

D - Alat Bantu Pengembangan yang Berguna

Lampiran D - Alat Bantu (Tools) Pengembangan yang Berguna

Di lampiran ini, kita bakal ngomongin soal beberapa alat bantu pengembangan yang berguna yang mana emang disediain sama project Rust ini. Kita bakal ngelihat alat pemformatan otomatis, cara-cara cepet buat nerapin perbaikan dari pesan peringatan, sebuah linter (pemeriksa kode), dan juga seputar cara integrasi dengan IDEs.

Pemformatan Otomatis dengan rustfmt

Alat rustfmt merombak dan memformat ulang (reformats) kode kita sedemikian rupa sehingga mengikuti gaya kode standar dari komunitas. Banyak proyek kolaboratif memakai rustfmt untuk membantu mencegah terjadinya perdebatan seputar gaya penulisan kode mana yang seharusnya dipakai saat menulis kode Rust: intinya, semua orang memformat kode mereka secara seragam memakai alat ini.

Instalasi Rust secara bawaan (default) sudah menyertakan rustfmt, jadi kita seharusnya saat ini sudah punya program rustfmt dan cargo-fmt di sistem kita. Dua perintah ini pada dasarnya serupa dengan rustc dan cargo di mana rustfmt memberikan opsi pengaturan yang lebih mendetail dan cargo-fmt memahami konvensi dari sebuah proyek yang memakai Cargo. Untuk memformat proyek Cargo apa pun, silakan ketik perintah berikut:

$ cargo fmt

Menjalankan perintah ini otomatis bakal memformat ulang seantero kode Rust yang ada di dalam crate saat ini. Perintah ini murni cuma bakal mengubah gaya kodenya saja, bukan mengubah semantik dari kodenya. Buat dapat info lebih lanjut soal rustfmt, silakan baca dokumentasinya di sini.

Perbaiki Kode kita dengan rustfix

Alat bantu rustfix sudah disertakan bareng instalasi Rust dan bisa secara otomatis membetulkan pesan peringatan compiler yang punya rute perbaikan yang jelas untuk masalah tersebut, yang kemungkinan besar merupakan apa yang kita inginkan. Kita kemungkinan besar sudah pernah melihat pesan peringatan dari compiler sebelumnya. Sebagai contoh, perhatikan kode berikut ini:

Nama File: src/main.rs

fn main() {
    let mut x = 42;
    println!("{x}");
}

Di sini, kita mendefinisikan variabel x sebagai sesuatu yang bisa diubah (mutable), tapi kita tidak pernah mengubahnya. Rust bakal otomatis memberi tahu kita pesan peringatan:

$ cargo build
   Compiling myprogram v0.1.0 (file:///projects/myprogram)
warning: variable does not need to be mutable
 --> src/main.rs:2:9
  |
2 |     let mut x = 0;
  |         ----^
  |         |
  |         help: remove this `mut`
  |
  = note: `#[warn(unused_mut)]` on by default

Pesan peringatan ini menyarankan supaya kita menyingkirkan keyword mut tersebut. Kita bisa secara otomatis menerapkan saran tersebut menggunakan bantuan alat rustfix dengan menjalankan perintah cargo fix:

$ cargo fix
    Checking myprogram v0.1.0 (file:///projects/myprogram)
      Fixing src/main.rs (1 fix)
    Finished dev [unoptimized + debuginfo] target(s) in 0.59s

Saat kita melihat isi file src/main.rs lagi, kita bakal melihat kalau cargo fix telah membetulkan kodenya:

Nama File: src/main.rs

fn main() {
    let x = 42;
    println!("{x}");
}

Sekarang variabel x sudah tidak bisa diubah (immutable), dan pesan peringatan itu sudah tidak muncul lagi.

Kita juga bisa memanfaatkan perintah cargo fix buat ngebantu mengurus masa transisi kode kita di antara edisi-edisi (editions) Rust yang berbeda-beda. Perkara seputar editions ini diulas di Lampiran E.

Lints (Peringatan Tambahan) yang Lebih Beragam Memakai Clippy

Alat Clippy adalah sebuah koleksi lints (aturan pemeriksa kode) yang bertugas menganalisis kode kita sehingga kita bisa menangkap kesalahan umum dan meningkatkan kualitas kode Rust kita. Clippy sudah disertakan di dalam standar instalasi Rust.

Buat menjalankan lints Clippy pada sembarang project Cargo, silakan ketik perintah berikut:

$ cargo clippy

Misalnya, katakanlah kita sedang menulis sebuah program yang memakai estimasi nilai aproksimasi buat sebuah konstanta matematika, seperti pi, seperti yang dilakukan program ini:

Filename: src/main.rs
fn main() {
    let x = 3.1415;
    let r = 8.0;
    println!("the area of the circle is {}", x * r * r);
}

Menjalankan cargo clippy pada project ini bakal menghasilkan pesan error berikut:

error: approximate value of `f{32, 64}::consts::PI` found
 --> src/main.rs:2:13
  |
2 |     let x = 3.1415;
  |             ^^^^^^
  |
  = note: `#[deny(clippy::approx_constant)]` on by default
  = help: consider using the constant directly
  = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#approx_constant

Pesan error ini memberi tahu kita kalau Rust sudah punya konstanta PI yang jauh lebih presisi, dan program kita bakal jadi lebih benar kalau kita memakai konstanta tersebut. Jadinya kita bisa mengubah kode tersebut supaya memakai konstanta PI.

Kode berikut ini tidak bakal memicu munculnya error atau peringatan apa pun dari Clippy:

Filename: src/main.rs
fn main() {
    let x = std::f64::consts::PI;
    let r = 8.0;
    println!("the area of the circle is {}", x * r * r);
}

Untuk informasi lebih rinci tentang Clippy, silakan tuju halaman dokumentasinya.

Integrasi IDE Memakai rust-analyzer

Untuk membantu masalah integrasi IDE, komunitas Rust merekomendasikan penggunaan rust-analyzer. Alat ini adalah sekumpulan utilitas berbasis compiler yang memakai protokol Language Server Protocol (LSP), yang mana merupakan sebuah spesifikasi bagi IDE dan bahasa pemrograman supaya mereka bisa berkomunikasi satu sama lain. Berbagai clients bisa memakai rust-analyzer, termasuk di antaranya plug-in Rust analyzer untuk Visual Studio Code.

Silakan kunjungi home page dari project rust-analyzer untuk mendapatkan petunjuk instalasinya, lalu pasang dukungan language server tersebut ke dalam IDE spesifik kita. IDE kita bakal mendapatkan tambahan kapabilitas istimewa seperti autocompletion (penyelesaian otomatis), jump to definition (loncat ke definisi), dan inline errors (pesan error yang muncul sebaris dengan kode).

E - Edisi

Lampiran E - Edisi (Editions)

Di Bab 1, kita sudah melihat kalau perintah cargo new itu menambahkan sedikit metadata ke dalam file Cargo.toml kita terkait sebuah edition (edisi). Lampiran ini membahas tentang apa arti dari hal tersebut!

Bahasa pemrograman Rust dan compiler-nya punya siklus rilis yang bergulir tiap enam minggu, yang mana berarti para pengguna bakal mendapatkan serangkaian fitur baru secara konstan. Bahasa pemrograman lain biasanya merilis perubahan- perubahan besar dengan frekuensi yang lebih jarang; sedangkan Rust merilis pembaruan-pembaruan kecil lebih sering. Setelah sekian lama, semua perubahan kecil ini pada akhirnya bakal menumpuk. Tapi kalau dilihat dari satu rilis ke rilis yang lain, kadang bisa jadi susah buat menengok ke belakang dan bilang, “Wow, di antara Rust 1.10 dan Rust 1.31, Rust ternyata udah berubah sangat banyak!”

Setiap tiga tahun sekali, tim Rust memproduksi sebuah edition (edisi) baru buat Rust. Setiap edisi merangkul semua fitur yang sudah berhasil mendarat menjadi sebuah paket yang jelas beserta kelengkapan dokumentasi dan peralatan (tooling) yang sudah dimutakhirkan. Edisi-edisi baru ini dikirim sebagai bagian dari rute proses rilis reguler enam mingguan.

Edisi-edisi ini memiliki tujuan yang berbeda-beda bagi kelompok orang yang berbeda:

  • Buat para pengguna aktif Rust, sebuah edisi baru menggabungkan perubahan-perubahan tambahan tersebut menjadi sebuah paket yang mudah dipahami.
  • Buat orang-orang yang belum memakai Rust, sebuah edisi baru menjadi sinyal kalau ada beberapa kemajuan besar yang sudah terealisasi, yang mana barangkali bakal bikin Rust jadi layak buat dilirik lagi.
  • Buat para pengembang yang ikut mengerjakan bahasa Rust, sebuah edisi baru menyediakan sebuah titik kumpul (rallying point) yang memacu semangat proyek ini secara keseluruhan.

Saat buku ini sedang ditulis, ada empat edisi Rust yang tersedia: Rust 2015, Rust 2018, Rust 2021, dan Rust 2024. Buku ini sendiri sepenuhnya ditulis memakai pola dan gaya bahasa (idioms) dari edisi Rust 2024.

Kunci edition yang ada di dalam Cargo.toml menandakan edisi mana yang seharusnya dipakai sama si compiler untuk kode kita. Kalau kuncinya tidak ada, Rust otomatis bakal memakai 2015 sebagai nilai edisinya demi alasan kompabilitas ke belakang (backward compatibility).

Setiap project berhak memilih untuk masuk (opt in) ke sebuah edisi lain selain edisi bawaan 2015. Edisi bisa saja mengandung perubahan-perubahan yang tidak kompatibel (incompatible changes), seperti memasukkan kata kunci (keyword) baru yang mana mungkin berkonflik dengan nama-nama identifiers yang ada di dalam kode. Namun, terkecuali kita secara sadar setuju masuk (opt in) ke perubahan-perubahan tersebut, kode kita bakal dijamin terus bisa di-compile dengan mulus sekalipun kita meng-upgrade versi dari compiler Rust yang kita pakai.

Semua versi compiler Rust menyokong edisi apa pun yang memang udah eksis sebelum versi compiler tersebut dirilis, dan mereka juga sanggup menautkan (link) crates dari sembarang edisi yang disokong untuk jalan bersama-sama. Perubahan edisi itu cuma berdampak pada cara sang compiler mengurai (parses) kode kita pada awalnya. Oleh karena itu, kalau kita lagi memakai Rust 2015 dan salah satu dependency (dependensi) kita memakai Rust 2018, project kita dijamin bakal tetap bisa sukses di-compile dan bisa memakai dependensi tersebut. Dari sisi sebaliknya pun begitu.

Biar lebih jelas: sebagian besar fitur itu bakal tetap tersedia di semua edisi. Para pengembang yang memakai edisi Rust yang mana pun bakal terus bisa melihat adanya perbaikan seiringan dengan dibuatnya rilis-rilis versi stabil yang baru. Namun, di dalam beberapa kasus tertentu, utamanya pas lagi ada keywords baru yang ditambahkan, beberapa fitur baru itu barangkali cuma bakal tersedia di edisi- edisi yang dirilis belakangan. Kita harus berpindah (switch) edisi kalau kita pengen bisa memanfaatkan fitur-fitur semacam itu.

Untuk detail lebih lanjut, buku The Rust Edition Guide adalah buku komplit seputar edisi yang memaparkan semua perbedaan di antara tiap-tiap edisi dan menjelaskan gimana caranya meng-upgrade kode kita secara otomatis ke edisi baru melalui perintah cargo fix.

F - Terjemahan-terjemahan Buku Ini

Lampiran F: Terjemahan-terjemahan Buku Ini

Buat mencari sumber bacaan (resources) dalam bahasa selain bahasa Inggris. Sebagian besar di antaranya masih dalam taraf pengerjaan; silakan cek label Translations andaikata kita ingin membantu atau ingin memberi tahu kami tentang adanya proyek terjemahan baru!

G - Gimana Rust Dibuat dan “Nightly Rust”

Lampiran G - Bagaimana Rust Dibuat dan “Nightly Rust”

Lampiran ini membahas bagaimana Rust dibuat dan pengaruhnya bagi kita sebagai pengembang Rust.

Kestabilan Tanpa Kemandekan

Sebagai sebuah bahasa, Rust sangat peduli pada kestabilan kode kita. Kami ingin Rust menjadi fondasi kokoh untuk membangun program. Jika segalanya terus berubah, hal itu mustahil dilakukan. Di sisi lain, tanpa eksperimen fitur baru, kita tidak akan menemukan kekurangan sebelum fitur dirilis dan tidak bisa diubah lagi.

Solusi kami adalah “kestabilan tanpa kemandekan”. Prinsipnya: kita tidak perlu takut melakukan upgrade ke versi stabil Rust yang baru. Setiap upgrade harusnya mudah, membawa fitur baru, lebih sedikit bug, dan kompilasi lebih cepat.

Saluran Rilis dan Model Kereta

Pengembangan Rust beroperasi dengan jadwal kereta. Semua pengembangan dilakukan di branch master. Rilis mengikuti model kereta rilis perangkat lunak. Ada tiga saluran rilis Rust:

  • Nightly
  • Beta
  • Stable

Kebanyakan pengembang menggunakan saluran stable. Mereka yang ingin mencoba fitur eksperimental bisa menggunakan nightly atau beta.

Setiap enam minggu, rilis baru disiapkan. Branch beta memisahkan diri dari branch master. Jika ditemukan masalah di beta, perbaikan diterapkan di master (nightly) lalu di-backport ke beta. Setelah enam minggu, beta menjadi stable yang baru, dan prosesnya berulang untuk versi berikutnya.

Proses ini memastikan rilis baru selalu bisa diuji terlebih dahulu di saluran beta sebelum menjadi stabil.

Waktu Pemeliharaan

Proyek Rust mendukung versi stabil terbaru. Saat versi baru dirilis, versi lama mencapai akhir masa pakainya (EOL). Setiap versi didukung selama enam minggu.

Fitur Tidak Stabil

Rust menggunakan “feature flags” untuk fitur yang masih dikembangkan. Fitur ini ada di branch master dan nightly di balik flag tersebut. Kita bisa mencobanya dengan menggunakan Rust nightly dan mengaktifkan flag-nya di kode kita.

Fitur semacam ini tidak bisa digunakan di versi beta atau stable. Ini memungkinkan kami menguji fitur baru secara praktis sebelum dinyatakan stabil selamanya. Buku ini hanya membahas fitur stabil.

Rustup dan Peran Rust Nightly

Rustup memudahkan kita berganti antar saluran rilis. Secara default kita menggunakan stable. Untuk menginstal nightly:

$ rustup toolchain install nightly

kita bisa melihat daftar toolchain dengan rustup toolchain list. Kita juga bisa mengatur toolchain tertentu untuk proyek tertentu menggunakan rustup override set nightly di direktori proyek tersebut.

Proses RFC dan Tim

Fitur baru diawali dengan proses Request For Comments (RFC). Siapa pun bisa menulis proposal RFC untuk meningkatkan Rust. Proposal tersebut akan diulas dan didiskusikan oleh tim Rust.

Jika disetujui, issue baru dibuka dan fitur tersebut diimplementasikan. Setelah implementasi siap, fitur tersebut masuk ke branch master di balik feature gate. Setelah diuji oleh pengguna nightly, tim akan memutuskan apakah fitur tersebut layak masuk ke versi stabil atau tidak. Jika ya, feature gate dihapus dan fitur tersebut menjadi stabil.