Kesan pertama memakai Rust

Bahasa Rust digadangkan sebagai bahasa untuk pemrograman sistem dengan performansi yang tinggi dan memiliki jaminan memory safety. Performansi yang tinggi ini didapatkan dengan menggunakan abstraksi yang tidak menambah overhead pada runtime (zero cost abstraction, bandingkan misalnya dengan PHP yang overheadnya sangat tinggi). Memory safety artinya bebas dari berbagai bug yang berhubungan dengan managemen memori (contoh bug memori yang umum: menimpa memori yang masih dipakai, tidak menginisialisasi memori, memakai pointer null, melakukan double free, dsb).

Logo Rust

Bahasa Rust mulai diumumkan ke publik pada 2010, dan baru masuk versi 1.0 pada 2015. Setelah itu masih ada banyak perubahan pada bahasa ini, saat artikel ini ditulis Rust versi stabil adalah 1.56. Dibandingkan banyak bahasa lain, Rust ini masih cukup muda, dan banyak hal masih belum stabil.

Saat ini saya belum memiliki proyek besar yang memakai Rust, tapi selama 25 hari terakhir saya menyelesaikan Advent Of Code (AoC) 2021 menggunakan Rust. Soal AoC ini sangat bervariasi, jadi bisa digunakan untuk menguji banyak fitur bahasa Rust. Kadang soalnya sangat sederhana, jadi bisa diselesaikan dengan cepat dan saya punya waktu mencoba-coba berbagai pendekatan untuk mencoba-coba fitur Rust tertentu.

Rust Edition

Bahasa Rust masih terus dikembangkan, dan tentunya perubahan ini berpotensi membuat program lama tidak berjalan. Untuk memastikan agar hal tersebut tidak terjadi, Rust memiliki edition, edisi yang ada saat ini: Rust 2015, Rust 2018, dan Rust 2021. Ketika mengcompile program, kita bisa menyatakan bahwa kita memakai Rust edisi tertentu, dan compiler akan membatasi agar jika ada perubahan di versi baru, tidak akan mempengaruhi program yang ditargetkan untuk edisi lama.

Baca dulu Buku The Rust Programming Language

Sebelum memakai Rust untuk AoC, saya sudah menyelesaikan membaca buku The Rust Programming Language. Saya sangat menyarankan Anda membaca dulu buku Rust untuk memahami berbagai konsep yang ada, terutama mengenai konsep lifetime dan borrowing. Perbedaan str dan String juga perlu dipahami.

Beberapa topik tingkat lanjut seperti: konkurensi, Smart Pointers, Advanced Features, bisa dibaca sekilas dulu tapi tidak perlu langsung dipahami. Setelah selesai coba baca: Learn Rust With Entirely Too Many Linked Lists. Berbagai konsep advanced akan lebih terlihat kegunaannya jika dicontohkan dengan praktik, dan dalam kasus ini membuat berbagai versi Linked List.

Dari pengalaman saya: membaca mengenai lifetime sangat berbeda dengan mempraktikkannya. Hal-hal kecil yang saya pikir akan berjalan, ternyata tidak berjalan. Contoh kecil: dalam AoC kita perlu membaca input dari file, dan memproses inputnya baris demi baris. Kode seperti ini tidak bisa dicompile karena masalah lifetime.

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    let line: Vec<&str> = std::fs::read_to_string(&args[1]).unwrap().lines().collect();
    println!("{}", line.len());
}

Dengan pesan error:

Compiling playground v0.0.1 (/playground)
error[E0716]: temporary value dropped while borrowed
 --> src/lib.rs:5:27
  |
5 |     let line: Vec<&str> = std::fs::read_to_string(&args[1]).unwrap().lines().collect();
  |                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^                  - temporary value is freed at the end of this statement
  |                           |
  |                           creates a temporary which is freed while still in use
6 |     println!("{}", line.len());
  |                    ---------- borrow later used here
  |
  = note: consider using a `let` binding to create a longer lived value

Tapi jika kita membuat variabel temporer baru, maka ini bisa dicompile:

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    let strline = std::fs::read_to_string(&args[1]).unwrap();
    let line: Vec<&str> = strline.lines().collect();
    println!("{}", line.len());
}

Atau jika tidak mengerti masalah str vs String, maka kode seperti ini tidak bisa dicompile:

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    let line: Vec<String> = std::fs::read_to_string(&args[1]).unwrap().lines().collect();
    println!("{}", line.len());
}

Tapi yang ini bisa (dan tidak butuh temporary variable):

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    let line: Vec<String> = std::fs::read_to_string(&args[1]).unwrap().lines().map(|x| x.to_string()).collect();
    println!("{}", line.len());
}

Perlu diperhatikan bahwa penanganan String di Rust tidak sederhana. Saya tidak akan membahas ini secara dalam, silakan membaca: Why Rust strings seem hard. Intinya: sebagai programmer kita perlu memahami dengan eksak seperti apa representasi string di memori termasuk juga di mana lokasi memori untuk string.

Untuk berbagai persoalan sederhana, masalah lifetime dan borrowing sering kali bisa diselesaikan dengan membuat salinan data (cloning, tidak sharing). Untuk program kompleks, ini akan membuat program Rust bisa menjadi lebih lambat jika dibandingkan dengan bahasa lain.

Contoh yang saya berikan di atas termasuk kasus yang bisa cepat dengan mudah dimengerti, tapi jika sudah memakai lebih banyak fitur Rust, maka kadang beberapa hal menjadi lebih rumit. Di Rust hanya boleh ada satu reference mutable, atau banyak reference immutable (tidak boleh ada reference ke mutable dan immutable dalam satu scope). Kadang saya memakai lambda di sebuah fungsi, dan tidak berjalan sesuai harapan karena ada binding ke variabel lokal, sedangkan jika saya jadikan fungsi terpisah, atau langsung saja dicopy paste tanpa lambda, akan berjalan normal (mirip dengan kasus variabel temporer di atas yang harus ditambahkan, dalam kasus ini lambda tertentu perlu dihindari).

Managemen Memori

Dari versi awal sampai sekarang, managemen memori Rust banyak berubah, tapi inti dari managemen memori pada Rust masih seperti ini:

  • Rust tidak memakai garbage collector, jadi kita tidak bisa sembarangan mengalokasikan memori yang akan dibereskan oleh garbage collector seperti pada banyak bahasa modern lain (seperti Go, Java, Python, Ruby, PHP dsb)
  • Programmer Rust tidak boleh memanggil free secara eksplisit (tidak seperti C). Tiap variable memiliki scope yang jelas, dan akan dibebaskan jika scopenya berakhir
  • Programmer Rust harus memikirkan dan menjelaskan dengan tepat seperti apa requirement untuk tiap memori yang dialokasikan (apakah dialokasikan di Stack/Heap, apakah akan diacu oleh struktur data lain, apakah ownernya hanya satu atau shared, apakah perlu diakses secara atomik oleh banyak thread atau tidak, dsb).

Dalam program yang efisien, kita perlu memikirkan penggunaan memori dengan detail. Di bahasa C, kita memiliki pilihan seperti ini:

struct Bar {
  int ablc;
};
struct Foo1 {
   struct Bar bar; //isi Bar disalin ke sini 
};
struct Foo2 {
   struct Bar *bar; //hanya pointer ke Bar
};

Di C++ ada opsi untuk menggunakan reference (simbol ampersand/ &, dengan batasan bahwa reference perlu diinisialisasi waktu konstruksi). Sedangkan di Rust, opsinya ada banyak (lihat juga: Why Not Rust), misalnya berbagai opsi ini berguna untuk tipe data yang akan dicek memory safetynya ketika proses kompilasi dilakukan.

struct Foo1     { bar: Bar         } //isi 
struct Foo2<'a> { bar: &'a Bar     } //immutable reference ke Bar dengan lifetiem 'a
struct Foo3<'a> { bar: &'a mut Bar } //mutable reference ke Bar dengan lifetiem 'a
struct Foo4     { bar: Box<Bar>    } //Heap allocated (single owner)
struct Foo5     { bar: Rc<Bar>     } //Reference Counted (multi owner)
struct Foo6     { bar: Arc<Bar>    } //Atomic Reference Counted (multi owner)

Selain itu juga ada Cell dan RefCell untuk alokasi dan akses memori yang sifatnya dicek saat runtime. Intinya: kita perlu berpikir dengan jelas seperti apa struktur data yang kita inginkan. Selain berpengaruh pada struktur data, ini juga akan berpengaruh pada deklarasi fungsi yang kita pakai (lifetime juga kadang perlu dispesifikasikan untuk fungsi).

Untuk Anda yang tidak membuat sendiri struktur data rumit, pengetahuan mengenai memori ini ini dibutuhkan untuk memakai berbagai library (istilahnya di Rust: crates) yang ada. Saat ini tidak ada IDE yang benar-benar bagus untuk Rust, jadi jika kita membuat kesalahan dalam menentukan jenis reference, refactoring kode tidak mudah dilakukan.

Rust juga memiliki fitur unsafe, yang tujuannya utamanya adalah: jika programmer mengerti apa yang mereka lakukan, maka compiler bisa membiarkan programmer melakukan apapun. Jika unsafe digunakan, maka tidak ada jaminan memory safety. Adanya unsafe juga memudahkan interoperabilitas dengan bahasa C atau bahasa lain yang tidak strict seperti Rust.

API belum lengkap dan masih berubah

API standard Rust masih terus dikembangkan. Contohnya untuk menyelesaikan salah satu soal, saya terpikir memakai struktur data List. Walau akhirnya tidak jadi memakai List, saya jadi belajar mengenai implementasinya di Rust.

Membuat struktur List sendiri di Rust cukup rumit (secara umum: semua struktur yang rekursif tidak mudah di Rust), jadi saya coba lihat di standard Library Rust. Sudah ada LinkedList, tapi ternyata listnya tidak bisa dimodifikasi dengan mudah tanpa memanfaatkan API Cursor, dan untuk memanfaatkan Cursor perlu Rust Nightly (bukan stable). Padahal niatnya memakai LinkedList adalah supaya mudah menyambung atau memutuskan elemen linked list.

Api Cursor masih ada di Nightly

Dalam kasus tertentu kadang lebih baik bergantung pada library dari third party untuk melakukan hal tertentu. Library dari third party ini kadang menggunakan API nightly, tapi jika ada perubahan, maka biasanya maintainer library akan mengupdate librarynya dengan cepat, dan kita tidak merasakan masalahnya.

Tipe Data

Rust sangat strict dalam hal tipe data (seperti bahasa Ada). Tidak ada konversi otomatis dari satu tipe ke tipe lain, jadi semua perlu dijelaskan dengan detail. Program ini tidak akan bisa dicompile karena variabel b tipenya unsigned integer 8 bit (u8), ditambahkan ke unsigned integer 64 bit (u64).

use std::env;

fn main() {
    let a: u8 = 5;
    let b: u64 = 8;
    let c: u64 = a + b;
    println!("{}", c);
}

Padahal secara logika seharusnya operasi ini sah-sah saja karena berusaha menambahkan tipe yang rangenya lebih kecil ke lebih besar. Tapi dalam Rust, jika ingin agar bisa dicompile, maka kita perlu melakukan casting u8 ke u64.

Dalam mode debug, bahkan setiap operasi integer akan dicek, jadi jika kita memilih memakai u32 (unsigned integer 32 bit) dan jika sebuah operasi (misalnya penjumlahan) membuat nilainya overflow, maka program akan berhenti. Dalam mode release, pemeriksaan overflow tidak akan dilakukan. Tapi jika kita memang benar-benar ingin mengecek di mode release, kita bisa memakai fungsi yang eksplisit (misalnya checked_add).

Bug Logika tetap jadi masalah

Karena Rust sangat strict dalam hal tipe data dan managemen memori, berbagai bug yang berhubungan dengan kedua hal tersebut akan jarang muncul atau akan terdeteksi dengan mudah ketika dijalankan.

Dalam salah satu soal AoC saya memakai tipe 32 bit, dan ternyata overflow. Ini bisa ditemukan oleh Rust dan saya segera bisa memperbaiki programnya. Ketika mengecek Reddit, ternyata banyak peserta lain yang memakai C dan bahasa lain (Java/C#) yang bingung ketika terjadi overflow dan proses debuggingnya lama.

Berbagai bug yang saya temui ketika memprogram di Rust adalah bug logika, dan ketika saya mengabaikan warning dari compiler. Contoh bug logika misalnya jika saya copy paste a[i][j] sedangkan maksudnya ingin membuat a[j][k].

Compiler Rust sangat suka memberikan warning, misalnya variabel tidak terpakai, fungsi yang tidak dipanggil dan sebagainya. Sebagian warning ini terlalu cerewet, sehingga kadang warning yang penting terlewat ketika membaca output compilernya.

Implementasi compiler

Rust memakai LLVM dan merasakan segala macam kelebihan dan kekurangan LLVM. Segala macam optimasi LLVM bisa langsung dinikmati oleh Rust, demikian juga segala macam bugnya. Ini contoh salah satu bug menarik yang pernah saya baca di Reddit: Tail recursion assumed to terminate? Rustc ‘solves’ the Collatz conjecture.

Saya sendiri tidak pernah ketemu bug semacam ini, walau pernah juga compilernya crash ketika berusaha mengcompile program saya, dan ketika diulangi, hasilnya tidak crash. Intinya: ada kemungkinan kecil Anda bisa terkena bug compiler pada kasus yang relatif sederhana.

Saya cukup merasakan bahwa compilernya lambat. Ini sangat terasa ketika memakai Pinebook Pro, ketika mengcompile untuk versi release. Padahal program untuk AoC ini sifatnya sangat kecil, hanya ratusan baris.

Dibandingkan GCC, LLVM masih memiliki jumlah target arsitektur yang terbatas. Misalnya backend arsitektur AVR, Xtensa, dsb masih eksperimental, artinya Rust juga belum bisa menghasilkan kode untuk arsitektur-arsitektur tesebut. Dalam kasus tertentu kita perlu mendownload compiler khusus yang diterbitkan oleh third party. Singkatnya: jika ingin kode yang portabel untuk semua arsitektur prosessor, C masih merupakan bahasa pilihan.

Cargo dan Crates

Ada tool untuk Rust bernama Cargo yang bisa mengotomasi banyak hal di Rust, dan saya sudah memakai ini. Tool ini bisa dipakai untuk membuat project baru (berupa library atau executable). Tool ini juga bisa dipakai untuk membuild sebuah proyek Rust (selain dengan Cargo, kita juga bisa mengkompilasi secara manual menggunakan rustc). Cargo juga bisa digunakan untuk instalasi library pihak ketiga (crate).

Ketika belajar Rust, saya tidak memakai third party library, tujuannya supaya lebih mengenal bahasanya. Jadi saat ini saya belum bisa menilai kematangan berbagai library Rust. Namun dari berbagai proyek Rust yang saya ikuti, ekosistem library Rust sudah cukup baik.

Penutup

Beralih dari satu bahasa ke bahasa lain memiliki tantangan yang berbeda. Beberapa bahasa cukup mirip, sehingga untuk beralih yang harus dipelajari hanya masalah syntax, API dan tools saja (misalnya beralih dari Java ke .NET dan sebaliknya). Beberapa bahasa memiliki paradigma yang cukup berbeda, dan untuk belajar bahasanya perlu mengubah cara berpikir, misalnya dari Java/Python/Ruby yang paradigmanya OOP ke LISP yang fungsional, atau Prolog yang deklaratif, atau Forth yang concatenative. Ketika berubah paradigma, cukup sulit membuat program yang bisa berjalan dengan benar di bahasa baru.

Beralih dari bahasa dengan garbage collector (misanya :Python, Ruby, Java, C#, Go, dsb ) ke bahasa tanpa garbage collector juga cukup sulit bagi banyak programmer. Mereka kemungkinan akan membuat program yang bisa berjalan, tapi penuh dengan bug memori (misalnya: tidak membebaskan memori, double free, dsb) karena kurang paham dengan managemen memori secara manual.

Tingkat kesulitan beralih ke Rust menurut saya berada di tingkat menengah. Rust memiliki paradigma unik dalam managemen memori dan butuh cukup latihan untuk bisa memahaminya. Kebanyakan programer dari bahasa yang high level tidak pernah memikirkan detail memori untuk tiap struktur data yang dipakainya.

Setelah memakainya selama hampir sebulan, saya sendiri cukup menyukai Rust, dan akan berusaha lebih banyak memakai Rust untuk menggantikan program dalam C/C++ jika memungkinkan. Mungkin di masa depan saya akan menuliskan lagi pengalamannya setelah lebih lama memakai Rust.

Untuk Anda yang mau belajar Rust: silakan dipelajari, dan jika tidak cocok, ya jangan dipaksakan untuk proyek Anda. Rust tidak cocok untuk semua jenis aplikasi. Silakan baca juga artikel: Why not Rust? supaya tahu kenapa kadang Rust tidak cocok untuk aplikasi tertentu.

One thought on “Kesan pertama memakai Rust”

Tinggalkan Balasan

Alamat email Anda tidak akan dipublikasikan. Ruas yang wajib ditandai *