Menulis Macro

Menulis macro dengan defmacro, membaca ekspansinya, dan menjaga higiene lewat gensym.

Daftar isi

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.

clojure Macro unless beserta pemanggilannya
(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"))
text Keluaran
diambil
cabang selain dieksekusi

Pemanggilan 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.

clojure Membaca ekspansi unless dengan macroexpand-1
(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)))
text Keluaran
(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.

clojure Macro yang menimpa binding pemanggil
(defmacro ambil-jahat [badan]
  (list 'let ['x 999] badan))

(println (let [x 1] (ambil-jahat x)))
text Keluaran
999

Macro 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.

clojure Macro dengan evaluasi ganda
(defmacro dua-kali-jahat [ekspresi]
  `(+ ~ekspresi ~ekspresi))

(def panggilan (atom 0))
(defn catat [] (swap! panggilan inc) 10)

(println (dua-kali-jahat (catat)))
(println @panggilan)
text Keluaran
20
2

Hasil 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.

clojure Bentuk simbol yang dihasilkan gensym
(prn (gensym "hasil_"))
(prn (gensym "hasil_"))
(prn (gensym))
text Keluaran
hasil_3
hasil_6
G__9

Angka 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.

clojure Macro dua-kali yang menilai ekspresi tepat sekali
(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)
text Keluaran
(clojure.core/let [v__1__auto__ (catat)] (clojure.core/+ v__1__auto__ v__1__auto__))
20
1

Ekspansi 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.

clojure Macro aman dengan gensym eksplisit
(defmacro ambil-aman [badan]
  (let [sementara (gensym "x_")]
    (list 'let [sementara 999] badan)))

(println (let [x 1] (ambil-aman x)))
text Keluaran
1

Nama 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.

clojure Lupa unquote pada parameter macro
(def nama "Budi")

(defmacro sapa-jahat [nama]
  `(str "Halo, " nama))

(println (sapa-jahat "Citra"))
text Keluaran
Halo, Budi

Argumen "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#