Kapan Tidak Menulis Macro

Menimbang kapan macro tidak diperlukan dan fungsi biasa menjadi pilihan yang lebih tepat.

Daftar isi

Macro menulis kode yang menghasilkan kode, sehingga terlihat menarik untuk hampir semua masalah, padahal mayoritas kebutuhan sehari-hari sudah ditangani oleh fungsi biasa. Topik ini menimbang keterbatasan macro, memperlihatkan kasus yang cukup ditangani fungsi, lalu merumuskan kriteria ketika macro benar-benar layak ditulis. Hasil akhirnya berupa kebiasaan menimbang biaya dan manfaat sebelum menambahkan macro baru ke dalam kode.

Macro bukan nilai#

Fungsi di Clojure merupakan nilai, sedangkan macro bukan. Nama macro hanya bermakna pada posisi operator, yaitu posisi pertama list pada saat compiler melakukan ekspansi. Akibatnya, macro tidak dapat dipetakan dengan map, diterapkan dengan apply, disimpan di dalam map, atau dilewatkan sebagai argumen. Batasan ini langsung menggugurkan macro dari seluruh pola orde tinggi.

clojure Fungsi berpindah posisi sebagai nilai
(defn kuadrat [n]
  (* n n))

(println (map kuadrat [1 2 3 4]))
(println (apply max [12 7 25 9]))
text Keluaran
(1 4 9 16)
25

Fungsi kuadrat dan max berpindah posisi sebagai nilai tanpa hambatan, karena keduanya memang objek yang dapat dipanggil. Percobaan yang sama pada macro langsung gagal pada waktu kompilasi.

clojure
; tidak-dijalankan
; macro tidak dapat menempati posisi nilai; form berikut gagal pada waktu
; kompilasi dengan pesan "Can't take value of a macro: #'user/unless"
(defmacro unless [tes cabang-lalu cabang-selain]
  `(if ~tes ~cabang-selain ~cabang-lalu))

(println (map unless [1 2 3]))

Pesan error Can't take value of a macro muncul pada waktu kompilasi, bukan pada waktu program berjalan, karena penolakan terjadi pada compiler. Batasan ini bukan kekurangan yang harus diakali, melainkan petunjuk arah. Kemampuan macro ada pada bentuk kode yang dihasilkannya, bukan pada keterpakaian sebagai nilai.

Ekspansi terjadi pada waktu kompilasi#

Ekspansi macro terjadi ketika program dibaca dan dikompilasi, bukan ketika dijalankan. Argumen yang diterimanya berupa form, sehingga keputusan di dalam macro hanya dapat berdasar pada bentuk kode, bukan nilai yang akan dihasilkannya. Konsekuensinya, debug menjadi lebih sulit karena error dan stack trace menunjuk kode hasil ekspansi, bukan kode yang dituliskan pemanggil.

clojure Macro yang bekerja atas bentuk argumennya
(defmacro jumlah-form [e]
  (count e))

(println (jumlah-form (+ 1 2 3)))
(println (jumlah-form [1 2 3]))
text Keluaran
4
3

Angka 4 berasal dari penghitungan elemen form (+ 1 2 3) yang terdiri atas simbol + beserta tiga angka, sedangkan bentuk tersebut tidak pernah dievaluasi. Pemanggilan kedua menghitung elemen vector literal yang juga berupa form. Contoh kecil ini memperlihatkan sifat yang menjadi kekuatan sekaligus keterbatasan macro, yaitu yang dilihat macro hanyalah data, sehingga logika yang bergantung pada nilai runtime tidak dapat dituliskan di dalam badannya.

Konsekuensi praktisnya terasa saat macro didefinisikan di dalam berkas yang sedang dimuat. Badan macro dijalankan ketika definisinya dibaca, sehingga macro tidak dapat membaca variabel lokal maupun state yang baru terbentuk saat program berjalan. Apabila keputusan hanya dapat diambil setelah nilai diketahui, tempatnya ada di fungsi, dan macro cukup menyusun pemanggilan fungsi tersebut.

Transformasi data cukup dengan fungsi#

Kebutuhan yang paling sering muncul berupa pengaliran data melalui beberapa tahap, dan seluruh kebutuhan itu ditangani oleh fungsi orde tinggi. Fungsi dapat disusun dengan comp, dilewatkan ke map, dan diuji secara terpisah, sedangkan macro pada kasus yang sama hanya menambah biaya tanpa kemampuan baru. Contoh berikut menghitung banyaknya nilai yang lulus pada setiap kelas, dengan fungsi yang disusun sekali lalu dipetakan ke seluruh kelas.

clojure Pipeline transformasi data berbasis fungsi
(def nilai-kelas [[80 75 90 60] [70 65] [100 95 85]])

(defn lulus? [n]
  (>= n 75))

(def jumlah-lulus
  (comp count #(filter lulus? %)))

(println (map jumlah-lulus nilai-kelas))
(println (jumlah-lulus [90 60 80]))
text Keluaran
(3 0 3)
2

Pipeline jumlah-lulus berupa komposisi count di atas hasil filter, dan fungsi tersebut dilewatkan ke map tanpa penyesuaian apa pun. Versi macro dari pipeline ini tidak menambah kemampuan apa pun, karena tidak ada argumen yang perlu ditahan evaluasinya dan tidak ada bentuk bahasa yang perlu dibentuk. Transformasi yang bekerja pada nilai yang sudah ada berada sepenuhnya di wilayah fungsi.

Kapan macro benar-benar diperlukan#

Macro layak ditulis apabila kebutuhannya tidak dapat dicapai fungsi. Tanda-tandanya berupa argumen yang harus ditahan evaluasinya, kode yang perlu berupa bentuk bahasa atau kontrol alur baru, atau boilerplate yang tidak terjangkau fungsi dan perlu dipadatkan. Contoh klasiknya berupa perbandingan unless versi fungsi dan versi macro pada cabang yang membawa efek samping.

Kriteria terakhir berupa pemadatan boilerplate. Contohnya macro yang menerima deretan spesifikasi lalu menghasilkan beberapa defn sekaligus, sesuatu yang tidak mungkin ditulis fungsi karena fungsi tidak dapat menghasilkan definisi baru. Pemadatan seperti ini tetap harus diuji dengan pertanyaan yang sama, yaitu apakah hasilnya benar-benar menghapus pengulangan yang panjang atau sekadar memindahkan kerumitan dari satu tempat ke tempat lain.

clojure Perbandingan unless versi fungsi dan versi macro
(defn unless-fn [tes cabang-lalu cabang-selain]
  (if tes cabang-selain cabang-lalu))

(defmacro unless [tes cabang-lalu cabang-selain]
  `(if ~tes ~cabang-selain ~cabang-lalu))

(def jejak (atom []))

(unless-fn false
           (swap! jejak conj "cabang-lalu")
           (swap! jejak conj "cabang-selain"))
(prn @jejak)

(reset! jejak [])

(unless false
        (swap! jejak conj "cabang-lalu")
        (swap! jejak conj "cabang-selain"))
(prn @jejak)
text Keluaran
["cabang-lalu" "cabang-selain"]
["cabang-lalu"]

Versi fungsi menilai kedua argumennya sebelum pemanggilan dimulai, sehingga jejak mencatat dua entri meskipun hanya satu cabang yang dikembalikan. Versi macro menilai satu cabang sesuai hasil tes, dan jejaknya berhenti pada satu entri. Selisih perilaku seperti ini tidak dapat diatasi oleh fungsi apa pun, karena penahanan evaluasi terjadi sebelum fungsi sempat menerima nilainya.

Pemilihan berakhir pada pertimbangan biaya dan manfaat. Biaya macro berupa ekspansi yang tersembunyi dari pembacaan biasa, kesulitan debug, dan ketiadaan status sebagai nilai, sedangkan manfaatnya hanya muncul ketika fungsi tidak sanggup. Kebiasaan yang dijaga berupa satu pertanyaan pada setiap usulan macro, yaitu apakah ada argumen yang harus ditahan evaluasinya atau bentuk bahasa yang harus dibentuk. Apabila tidak ada, fungsi tetap menjadi pilihan. Sebagai patokan, macro mapan di inti Clojure seperti -> ada karena manfaatnya, yaitu menata ulang bentuk kode, dan bukan karena pengolahan nilai.

Ringkasan#

  • Macro bukan nilai, sehingga tidak dapat dipetakan, diterapkan, atau dilewatkan sebagaimana fungsi.
  • Ekspansi berjalan pada waktu kompilasi, sehingga macro hanya melihat bentuk kode, bukan nilai runtime.
  • Transformasi data atas nilai yang sudah ada cukup ditangani fungsi beserta komposisinya.
  • Macro layak ditulis bila argumen harus ditahan evaluasinya, kontrol alur baru perlu dibentuk, atau boilerplate harus dipadatkan.

Referensi#