Macro menerima argumen berupa form yang belum dievaluasi dan mengembalikan form baru yang menggantikan pemanggilannya sebelum evaluasi berjalan. Topik ini membahas penulisan macro dengan defmacro, pembacaan ekspansinya dengan macroexpand-1, serta dua masalah klasik yang dipecahkan gensym, yaitu capture dan evaluasi ganda. Seluruh contoh menggunakan macro berukuran kecil bernama unless dan dua-kali sehingga ekspansinya dapat dibaca utuh.
defmacro: fungsi yang menghasilkan form#
defmacro mendefinisikan macro dengan bentuk yang mirip defn, lengkap dengan vector parameter dan badan berupa form. Perbedaannya terletak pada dua hal. Argumen diterima sebagai form mentah, bukan nilai hasil evaluasi, dan hasil kembalian berupa form yang disisipkan pada posisi pemanggilan sebelum compiler melanjutkan evaluasinya. Form hasil penyisipan itu disebut ekspansi (expansion). Dengan kata lain, macro menulis ulang program pada waktu kompilasi, sedangkan fungsi menghitung nilai pada waktu program berjalan.
(defmacro unless [tes cabang-lalu cabang-selain]
`(if ~tes ~cabang-selain ~cabang-lalu))
(println (unless false "diambil" "dilewati"))
(unless true
(println "cabang lalu dieksekusi")
(println "cabang selain dieksekusi"))diambil
cabang selain dieksekusiPemanggilan pertama mengembalikan string diambil karena tes bernilai false, sehingga cabang yang dieksekusi adalah cabang lalu. Pemanggilan kedua hanya mengeksekusi println pada cabang selain. Perhatikan bahwa form (println "cabang lalu dieksekusi") tidak pernah dihitung terlebih dahulu saat dilewatkan, karena macro menerima bentuknya, bukan hasilnya. Sifat inilah yang tidak mungkin dicapai fungsi biasa dan menjadi alasan utama keberadaan macro.
Membaca ekspansi dengan macroexpand-1#
macroexpand-1 menerima satu form terkutip dan mengembalikan hasil ekspansi satu langkah tanpa mengeksekusi apa pun. Fungsi ini menjadi alat baca utama ketika menulis macro karena memperlihatkan kode yang benar-benar disisipkan pada pemanggil. Tanda kutip diperlukan karena macroexpand-1 merupakan fungsi biasa, sehingga tanpa tanda kutip argumennya dievaluasi lebih dahulu dan yang diperiksa bukan lagi macro-nya.
(defmacro unless [tes cabang-lalu cabang-selain]
`(if ~tes ~cabang-selain ~cabang-lalu))
(prn (macroexpand-1 '(unless false (println "a") (println "b"))))
(prn (macroexpand-1 '(unless true 1 2)))(if false (println "b") (println "a"))
(if true 2 1)Ekspansi pertama memperlihatkan substitusi yang persis: form argumen (println "a") dan (println "b") masuk apa adanya, karena simbol yang berasal dari argumen tidak disentuh badan macro. Simbol if pada hasil tetap ditulis tanpa awalan namespace karena if merupakan special form, yaitu bentuk bahasa yang dievaluasi langsung oleh compiler. Ekspansi kedua menunjukkan pola yang sama pada argumen berupa angka, yaitu angka 2 menempati posisi cabang selain dan angka 1 menempati cabang lalu.
Masalah capture dan evaluasi ganda#
Dua bahaya menanti macro yang ditulis tanpa pengawasan nama. Yang pertama berupa capture, yaitu nama dari badan macro menimpa binding milik pemanggil sehingga kode hasil ekspansi menunjuk variabel yang salah. Yang kedua berupa evaluasi ganda, yaitu form argumen disisipkan di beberapa tempat sehingga dieksekusi lebih dari satu kali. Keduanya tidak menimbulkan pesan error pada definisi macro dan hanya terlihat setelah macro digunakan.
(defmacro ambil-jahat [badan]
(list 'let ['x 999] badan))
(println (let [x 1] (ambil-jahat x)))999Macro ini sengaja dibangun dengan list dan simbol terkutip 'x agar mekanisme timpanya terlihat. Hasil ekspansinya berupa (let [x 999] x), sehingga x milik pemanggil tertimpa dan hasilnya 999, padahal harapan pemanggil adalah 1. Syntax-quote mencegah kejadian ini karena setiap simbolnya diselesaikan penuh, tetapi kebiasaan memeriksa ekspansi tetap diperlukan pada form yang disusun manual.
Masalah kedua diperlihatkan macro yang menyisipkan argumennya dua kali.
(defmacro dua-kali-jahat [ekspresi]
`(+ ~ekspresi ~ekspresi))
(def panggilan (atom 0))
(defn catat [] (swap! panggilan inc) 10)
(println (dua-kali-jahat (catat)))
(println @panggilan)20
2Hasil 20 sudah benar, tetapi penghitung panggilan naik menjadi 2. Ekspansinya berupa (+ (catat) (catat)), sehingga fungsi dengan efek samping dijalankan dua kali. Pada argumen tanpa efek samping, masalah ini tidak terlihat, dan justru itulah yang membuatnya berbahaya, karena error baru muncul ketika argumennya mahal dihitung atau nilainya berubah antarpanggilan.
Higiene dengan gensym dan auto-gensym#
Higiene (hygiene) berarti macro tidak memperkenalkan nama yang dapat berbenturan dengan nama pada kode pemanggil. Alat dasarnya adalah gensym, yaitu fungsi yang menghasilkan simbol baru yang dijamin belum pernah digunakan, seperti hasil_3 atau G__9. Di dalam syntax-quote tersedia bentuk singkatnya yang disebut auto-gensym dan ditulis dengan tanda pagar pada akhir simbol, misalnya v#. Semua kemunculan v# pada satu syntax-quote menunjuk simbol gensym yang sama.
(prn (gensym "hasil_"))
(prn (gensym "hasil_"))
(prn (gensym))hasil_3
hasil_6
G__9Angka pada keluaran berasal dari penghitung internal mesin Clojure, sehingga nilai pastinya bukan bagian dari kontrak dan dapat berbeda antarversi. Yang dijamin hanyalah keunikan setiap simbol yang dihasilkan. Dua macro berikut menggunakan jaminan itu untuk memperbaiki kedua masalah pada bagian sebelumnya.
(defmacro dua-kali [ekspresi]
`(let [v# ~ekspresi]
(+ v# v#)))
(def panggilan (atom 0))
(defn catat [] (swap! panggilan inc) 10)
(prn (macroexpand-1 '(dua-kali (catat))))
(println (dua-kali (catat)))
(println @panggilan)(clojure.core/let [v__1__auto__ (catat)] (clojure.core/+ v__1__auto__ v__1__auto__))
20
1Ekspansi memperlihatkan simbol v__1__auto__, hasil auto-gensym yang sama untuk ketiga kemunculan v#. Ekspresi (catat) kini berada pada posisi ikatan let, sehingga dieksekusi tepat satu kali. Hal itu terbukti dari penghitung panggilan yang berhenti di 1, sementara hasilnya tetap 20.
(defmacro ambil-aman [badan]
(let [sementara (gensym "x_")]
(list 'let [sementara 999] badan)))
(println (let [x 1] (ambil-aman x)))1Nama ikat pada hasil ekspansi kini berupa simbol gensym yang tidak mungkin dituliskan pemanggil, sehingga x milik pemanggil tetap utuh dan hasilnya kembali 1. Untuk macro berbasis syntax-quote, auto-gensym biasanya sudah mencukupi. Pemanggilan gensym eksplisit digunakan ketika form disusun dengan list atau ketika satu nama yang sama dibutuhkan di beberapa form terpisah.
Kesalahan umum#
Kesalahan pertama berupa lupa unquote pada parameter macro. Karena parameter tidak diunquote, simbolnya dianggap menunjuk var biasa, dan nilai yang muncul adalah nilai var dengan nama sama apabila kebetulan ada.
(def nama "Budi")
(defmacro sapa-jahat [nama]
`(str "Halo, " nama))
(println (sapa-jahat "Citra"))Halo, BudiArgumen "Citra" tidak pernah digunakan, sedangkan simbol nama pada badan macro diselesaikan menjadi var global user/nama yang nilainya "Budi". Perbaikannya berupa penulisan ~nama pada posisi tersebut agar nilai argumen yang disuntikkan.
Kesalahan kedua berupa pemanggilan macroexpand-1 tanpa tanda kutip, misalnya (macroexpand-1 (unless true 1 2)). Form di dalamnya dievaluasi lebih dahulu sebelum sempat dibaca, sehingga hasil yang diperiksa bukan lagi ekspansi macro. Kesalahan ketiga berupa pertukaran ' dengan ` secara sembarang, karena perbedaan resolusi simbol di antara keduanya baru terasa ketika macro dipanggil dari namespace yang lain.
Ringkasan#
- defmacro menerima argumen berupa form mentah dan mengembalikan form yang menggantikan pemanggilan.
- macroexpand-1 memperlihatkan ekspansi satu langkah, dan argumennya wajib dikutip.
- Capture terjadi ketika nama badan macro menimpa binding pemanggil, sedangkan evaluasi ganda terjadi ketika form argumen disisipkan lebih dari satu kali.
- gensym dan auto-gensym seperti
v#menghasilkan simbol unik sehingga macro bersifat higienis.
Referensi#
- Macros — Clojure.org — Referensi resmi macro, mencakup defmacro, ekspansi, dan gensym.
- defmacro — ClojureDocs — Dokumentasi defmacro beserta contoh macro unless klasik.
- gensym — ClojureDocs — Dokumentasi gensym untuk menghasilkan simbol unik.
- macroexpand-1 — ClojureDocs — Dokumentasi macroexpand-1 untuk membaca ekspansi satu langkah.