Sejak Chapter pertama kita sudah memakai println! dan vec! tanpa pernah membahas tanda seru di ujung nama keduanya. Tanda seru tersebut menandai macro — fasilitas Rust untuk menulis kode yang pada saat kompilasi menulis kode lain. Fungsi telah menjadi alat utama kita sepanjang Course ini, tetapi ada kelompok kebutuhan yang secara struktural tidak bisa dijawab oleh fungsi: menerima jumlah argumen yang berubah-ubah, menerima bentuk panggilan yang bukan daftar argumen biasa, sampai menghasilkan definisi item baru dari ketikan yang ringkas. Section Macros dibuka dengan pertanyaan paling mendasar: kenapa bahasa yang sudah memiliki fungsi, generics, dan trait masih membutuhkan fasilitas tambahan bernama macro?

Fungsi Menolak Jumlah Argumen yang Berubah #

Setiap fungsi Rust memiliki tepat satu signature: jumlah parameter, tipe tiap parameter, dan tipe kembalian dikunci pada saat definisi. Kompilator kemudian memeriksa setiap pemanggilan terhadap signature tersebut. Perilaku ini menjaga keamanan tipe, tetapi sekaligus menutup pintu bagi antarmuka yang jumlah argumennya lentur. Misalkan kita menulis fungsi pencatat log untuk aplikasi kasir, lalu pada satu tempat pemanggil mengirim dua argumen karena ingin menyertakan angka:

rust
fn catat(pesan: &str) {
    println!("[CATAT] {}", pesan);
}

fn main() {
    catat("Stok kopi tinggal 3 kg", 3);
}

Kompilator menolak program di atas sebelum program sempat berjalan:

text
error[E0061]: this function takes 1 argument but 2 arguments were supplied
 --> main.rs:6:5
  |
6 |     catat("Stok kopi tinggal 3 kg", 3);
  |     ^^^^^                           - unexpected argument #2 of type `{integer}`
  |
note: function defined here
 --> main.rs:1:4
  |
1 | fn catat(pesan: &str) {
  |    ^^^^^
help: remove the extra argument
  |
6 -     catat("Stok kopi tinggal 3 kg", 3);
6 +     catat("Stok kopi tinggal 3 kg");
  |

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0061`.

Pesan E0061 menegaskan aturannya: fungsi catat menerima satu argumen, pemanggil mengirim dua. Bahasa C menjawab kebutuhan semacam ini dengan variadic function seperti printf, tetapi jalur tersebut menanggung risiko nyata — pencocokan antara penentu format (%s, %d) dan argumen hanya diperiksa saat program berjalan, sehingga satu salah ketik berubah menjadi crash di production. Rust tidak menyediakan variadic function untuk kode aplikasi biasa. Jawaban Rust atas kebutuhan jumlah argumen yang lentur adalah macro, dan jaminan pemeriksaannya tetap terjadi saat kompilasi.

Macro Diperluas Sebelum Type Check #

Mekanisme kuncinya sederhana: pemanggilan macro tidak pernah dieksekusi saat program berjalan. Saat kompilasi, kompilator mengganti setiap pemanggilan macro dengan hasil ekspansi — potongan kode Rust biasa — dan langkah penggantian ini terjadi sebelum fase type check. Setelah penggantian selesai, kompilator memeriksa kode hasil ekspansi persis seperti memeriksa kode yang kita tulis dengan tangan. Dua konsekuensi mengalir dari mekanisme ini. Pertama, satu macro bisa menerima pemanggilan dengan bentuk berbeda-beda karena penggantian dilakukan per pemanggilan. Kedua, hasil ekspansi boleh berupa item apa pun — termasuk definisi fungsi yang sebelumnya tidak ada, sesuatu yang mustahil dilakukan fungsi mana pun.

Program berikut mendemonstrasikan kedua konsekuensi tersebut sekaligus:

rust
macro_rules! hello {
    () => {
        println!("Halo, selamat datang di Toko Kopi Nusantara!");
    };
}

macro_rules! buat_fungsi {
    ($nama:ident, $nilai:expr) => {
        fn $nama() -> u32 {
            $nilai
        }
    };
}

buat_fungsi!(stok_awal, 12);
buat_fungsi!(nomor_meja, 7);

fn main() {
    hello!();
    println!("Stok awal hari ini: {} kg", stok_awal());
    println!("Meja favorit pelanggan: nomor {}", nomor_meja());
}

Output yang Dihasilkan:

text
Halo, selamat datang di Toko Kopi Nusantara!
Stok awal hari ini: 12 kg
Meja favorit pelanggan: nomor 7

Karena macro bekerja di atas token, bukan nilai, cara membedahnya pun berbeda dari fungsi — kita bedah polanya:

  1. Baris 1-5 (definisi hello!): macro memiliki satu arm (aturan) dengan pola () — pola kosong berarti pemanggilan hello!() tanpa argumen apa pun. Bagian setelah => adalah ekspansi: kode pengganti yang berupa satu pemanggilan println! dengan teks tetap.
  2. Baris 7-13 (definisi buat_fungsi!): pola $nama:ident, $nilai:expr menangkap dua potongan token dari pemanggilan — sebuah identifier dan sebuah ekspresi. Ekspansinya bukan sekadar statement, melainkan definisi fn utuh yang identifier serta nilainya diisi dari hasil tangkapan pola.
  3. Baris 15-16 (buat_fungsi!(stok_awal, 12);): pemanggilan macro pada posisi item (di luar fungsi). Setelah ekspansi, kompilator melihat dua definisi fungsi biasa: fn stok_awal() -> u32 { 12 } dan fn nomor_meja() -> u32 { 7 } — seolah kita menuliskannya dengan tangan.
  4. Baris 19 (hello!();): pemanggilan dengan nol argumen diganti menjadi pemanggilan println! tetap. Tidak ada fungsi Rust yang bisa dipanggil dengan nol argumen sekaligus banyak argumen, tetapi macro bisa punya beberapa arm untuk bentuk pemanggilan berbeda.
  5. Baris 20-21: stok_awal() dan nomor_meja() dipanggil seperti fungsi biasa — memang fungsi biasa, hasil ekspansi macro yang telah lulus type check.

Alur lengkap dari kode sumber hingga binary dapat dipetakan sebagai berikut:

Diagram tersebut menempatkan macro pada posisi yang tepat: macro adalah tahap pengolahan teks kode yang berjalan paling awal di pipeline kompilasi, sebelum pemeriksaan tipe dan penerjemahan ke kode mesin.

Perbedaan Fundamental dengan Fungsi #

Tabel berikut merangkum garis pemisah antara fungsi dan macro:

AspekFungsiMacro
Saat diprosesSaat program berjalan: pemanggilan melompat ke badan fungsiSaat kompilasi: pemanggilan diganti hasil ekspansi
Jumlah argumenTetap, dikunci oleh satu signatureLentur, ditangani arm dan repetisi
Bentuk argumenDaftar ekspresi dengan tipe tetapUrutan token bebas: identifier, operator, blok, bahkan item
Pemeriksaan tipeTiap argumen dicek terhadap parameterKode hasil ekspansi dicek sebagai kode Rust biasa
KeluaranNilai kembalian dengan satu tipeKode apa pun: statement, fungsi, struct, hingga blok impl

Perlu dicatat bahwa kolom terakhir bukan berarti macro menjadi lubang keamanan. Macro sama sekali tidak menembus type check — kode yang dihasilkan macro harus tetap lolos pemeriksaan tipe sebelum masuk ke binary. Kelebihan macro murni terletak pada kemampuan menerima bentuk pemanggilan yang bervariasi dan kemampuan menghasilkan kode sebelum pemeriksaan tersebut berjalan. Sebaliknya, kelebihan tersebut dibayar dengan kelemahan nyata: pesan galat kadang menunjuk lokasi macro alih-alih kode kita, proses kompilasi memakan waktu lebih lama ketika macro digunakan meluas, dan pembacaan kode menuntut kita membayangkan hasil ekspansi di kepala.

Ringkasan #

Macro menjawab kebutuhan yang secara struktural tertutup bagi fungsi: fungsi mengunci jumlah dan tipe argumen pada satu signature sehingga pemanggilan dengan arity berbeda ditolak lewat E0061, sedangkan macro diperluas menjadi kode Rust biasa pada saat kompilasi — sebelum type check — sehingga satu macro bisa melayani bentuk pemanggilan berbeda dan bahkan melahirkan definisi fungsi baru. Jaminan tipe tidak hilang: hasil ekspansi tetap diperiksa kompilator seperti kode tulisan tangan. Dengan alasan keberadaan macro yang kini jelas, langkah berikutnya adalah membedah anatomi macro_rules! — arm, pola, dan ekspansi — serta melatihnya dengan macro pertama yang kita tulis sendiri.