Macro pada tingkat kesadaran

Apa yang dilakukan quote/unquote dan kapan macro sebaiknya tidak disentuh.

Daftar isi

Macro adalah kode yang membangkitkan kode lain pada saat kompilasi. quote mengubah kode menjadi data, unquote menyisipkan data kembali ke dalam kode, dan defmacro membungkus keduanya menjadi fungsi yang dijalankan compiler. Topik ini membahas ketiganya pada tingkat kesadaran yang sehat, yaitu memahami apa yang benar-benar terjadi di balik ekspansi, lalu menutup dengan batas kapan macro sebaiknya tidak disentuh.

quote dan bentuk AST#

quote menerima kode apa pun dan mengembalikan bentuk datanya. Bentuk itu disebut abstract syntax tree (AST), yaitu pohon yang merepresentasikan struktur kode, dan inilah yang sesungguhnya dilihat compiler sebelum kode dijalankan. Identifier sederhana menjadi tuple tiga unsur yang berisi nama, metadata, dan konteks variabelnya, sedangkan pemanggilan fungsi atau operator menjadi tuple tiga unsur yang berisi nama bentuk, metadata, dan daftar argumennya:

elixir Contoh: AST dari quote
IO.inspect(quote do: angka)
IO.inspect(quote do: 1 + 2)
IO.puts(Macro.to_string(quote do: 1 + 2))
text Keluaran
{:angka, [], Elixir}
{:+, [context: Elixir, imports: [{1, Kernel}, {2, Kernel}]], [1, 2]}
1 + 2

Tuple pertama menyimpan :angka sebagai nama identifier, list metadata yang kosong, dan konteks variabelnya. Tuple kedua menyimpan operator :+, metadata yang mencatat impor fungsi dari Kernel, serta argumen [1, 2]. Macro.to_string/1 merender AST kembali menjadi teks kode, sehingga 1 + 2 pada baris ketiga keluaran berasal dari AST yang sama dengan baris kedua.

Keterbiasaan membaca AST memang memerlukan waktu, tetapi yang dibutuhkan pembaca hanyalah dua kebiasaan: mengenali bahwa unsur pertama adalah nama bentuknya, dan bahwa unsur ketiga adalah daftar anaknya. Dua aturan itu berlaku untuk seluruh kode Elixir, dari pemanggilan operator sederhana sampai defmodule sekalipun.

unquote menyisipkan nilai#

Di dalam quote, unquote menyisipkan nilai dari luar menjadi bagian pohon kode. Nilai yang disisipkan boleh berupa data sederhana maupun AST hasil quote lain, dan penyisipannya berlangsung saat quote dievaluasi, bukan saat kode hasil ekspansi berjalan:

elixir Contoh: unquote menyisipkan nilai ke pohon kode
nilai = 10
ast = quote do: unquote(nilai) * 3
IO.inspect(ast)
IO.puts(Macro.to_string(ast))
text Keluaran
{:*, [context: Elixir, imports: [{2, Kernel}]], [10, 3]}
10 * 3

Nilai 10 menempati posisi argumen pertama dari operator *, tepat di sebelah 3. Karena penyisipan terjadi pada tingkat pohon, kode yang disisipkan tetap utuh sebagai satu simpul dan tidak terpengaruh presedensi operator teks di sekitarnya.

Karena berupa data biasa, AST dapat disimpan dalam variabel, diperiksa, dan disusun ulang sebelum diserahkan kembali kepada compiler. Sifat inilah yang menjadi fondasi seluruh macro: compiler tidak pernah mengeksekusi teks, melainkan selalu bekerja pada pohon yang sama dengan yang dilihat quote.

Penyisipan nilai ke dalam pohon juga menjadi alasan mengapa quote dan unquote selalu muncul berpasangan pada macro. quote menuliskan kerangka kodenya, sedangkan unquote mengisi bagian yang baru diketahui pada saat macro dijalankan. Tanpa pasangan ini, satu-satunya cara membangun kode dinamis adalah merangkai teks secara manual, dan cara itu rapuh terhadap kesalahan tanda baca.

defmacro dan hasil ekspansinya#

defmacro mendefinisikan fungsi yang dijalankan compiler, bukan program. Argumen macro diterima dalam bentuk AST, dan nilai balikannya berupa AST yang menggantikan pemanggilannya. Sebelum macro milik modul lain dipanggil, modul tersebut harus sudah dikompilasi dan require dinyatakan pada modul pemanggil. Contoh berikut mendefinisikan macro kali_tiga/1, memanggilnya dengan ekspresi 2 + 5, lalu menampilkan kode hasil ekspansinya:

elixir Contoh: defmacro beserta kode hasil ekspansinya
defmodule MacroKu do
  defmacro kali_tiga(ekspresi) do
    quote do: unquote(ekspresi) * 3
  end
end

defmodule Pengguna do
  require MacroKu

  def nilai, do: MacroKu.kali_tiga(2 + 5)

  def kode_hasil do
    ast = quote do: MacroKu.kali_tiga(2 + 5)
    Macro.to_string(Macro.expand_once(ast, __ENV__))
  end
end

IO.inspect(Pengguna.nilai(), label: "kali_tiga(2 + 5)")
IO.puts(Pengguna.kode_hasil())
text Keluaran
kali_tiga(2 + 5): 21
(2 + 5) * 3

Hasil ekspansi menunjukkan seluruh ekspresi 2 + 5 disisipkan sebagai satu simpul, sehingga hasilnya (2 + 5) * 3 dan bukan 2 + 5 * 3. Macro.expand_once/2 memperluas pemanggilan macro tepat satu langkah, dan inilah cara langsung untuk melihat apa yang benar-benar dikompilasi. Pola definisinya juga patut dicatat: macro didefinisikan lebih dahulu pada modul tersendiri, lalu require MacroKu dituliskan pada modul pemanggil.

Perhatikan pula bahwa kode_hasil/0 memanggil Macro.expand_once(ast, __ENV__) dengan __ENV__ sebagai lingkup ekspansinya. Lingkup itu membawa catatan modul dan require yang berlaku, sehingga macro milik MacroKu dapat ditemukan dan diperluas dengan benar.

Skrip utuh: macro kecuali#

Skrip berikut menambahkan macro kedua yang bentuknya lebih dekat dengan penggunaan nyata, yaitu kecuali/2 dengan pemanggilan blok do. Argumen kedua macro semacam ini diterima sebagai daftar kata kunci [do: isi], sehingga isinya diekstrak dengan Keyword.fetch!/2 sebelum disuntikkan ke dalam if. Macro unless bawaan Kernel bekerja dengan cara yang persis sama, sehingga contoh ini sekalian memperlihatkan cara kerja macro bawaan yang selama ini digunakan:

elixir macro_latihan.exs
# macro_latihan.exs — dua macro sederhana beserta pemakaiannya
defmodule MacroKu do
  defmacro kali_tiga(ekspresi) do
    quote do: unquote(ekspresi) * 3
  end

  defmacro kecuali(kondisi, klausa) do
    isi = Keyword.fetch!(klausa, :do)

    quote do
      if !unquote(kondisi) do
        unquote(isi)
      end
    end
  end
end

defmodule Pengguna do
  require MacroKu

  def nilai_digandakan, do: MacroKu.kali_tiga(7)

  def periksa(angka) do
    MacroKu.kecuali angka > 0 do
      IO.puts("angka masih nol")
    end
  end
end

IO.inspect(Pengguna.nilai_digandakan(), label: "kali_tiga(7)")
Pengguna.periksa(0)
IO.puts("pemeriksaan selesai")
text Keluaran
kali_tiga(7): 21
angka masih nol
pemeriksaan selesai

Skripnya dapat dijalankan langsung dari terminal setelah berkasnya disimpan:

bash
# menjalankan skrip macro dari terminal
elixir macro_latihan.exs
text Keluaran
kali_tiga(7): 21
angka masih nol
pemeriksaan selesai

Pemanggilan MacroKu.kecuali angka > 0 do ... end tanpa tanda kurung sah, karena macro memang menerima kode mentah. Karena 0 > 0 bernilai salah, bloknya dijalankan dan pesannya tercetak, sedangkan pemanggilan dengan angka yang lebih besar tidak mencetak apa pun. Apabila macro itu dipanggil tanpa blok do, Keyword.fetch!/2 langsung melempar error pada saat kompilasi, sehingga penggunaan yang keliru tidak pernah lolos diam-diam. Baris terakhir memanggil kali_tiga/1 sekali lagi sebagai pengingat bahwa kedua macro berdampingan dalam satu modul yang sama.

Melihat kode yang dibangkitkan#

Versi mandiri dari kecuali/2 berikut menampilkan kode yang dibangkitkan untuk compiler. Hasil ekspansinya adalah if biasa dengan kondisi yang dinegasikan, persis seperti yang dideklarasikan di dalam quote:

elixir Contoh: ekspansi macro kecuali
defmodule MacroKu do
  defmacro kecuali(kondisi, klausa) do
    isi = Keyword.fetch!(klausa, :do)

    quote do
      if !unquote(kondisi) do
        unquote(isi)
      end
    end
  end
end

defmodule Pengguna do
  require MacroKu

  def periksa(angka) do
    MacroKu.kecuali angka > 0 do
      IO.puts("angka masih nol")
    end
  end

  def kode_hasil do
    ast = quote do: MacroKu.kecuali(angka > 0, [do: IO.puts("angka masih nol")])
    Macro.to_string(Macro.expand_once(ast, __ENV__))
  end
end

Pengguna.periksa(0)
IO.puts(Pengguna.kode_hasil())
text Keluaran
angka masih nol
if !(angka > 0) do
  IO.puts("angka masih nol")
end

Baris pertama berasal dari pemanggilan macro sungguhan, sedangkan tiga baris berikutnya adalah teks hasil ekspansi yang diterima compiler. Dari titik itu, compiler hanya bekerja dengan if biasa, dan tidak ada jejak macro yang tersisa pada kode yang berjalan. Sifat transparan inilah yang membuat macro mudah ditelusuri selama hasil ekspansinya rutin diperiksa.

Kebiasaan memeriksa ekspansi juga membantu saat membaca macro milik library. Alih-alih menebak perilaku use atau router dari dokumentasinya, hasil ekspansi yang serupa dapat diminta kepada compiler, dan teks yang tampil itulah yang sungguh-sungguh dikompilasi. Dengan kebiasaan itu, macro tidak lagi menjadi kotak hitam, melainkan kode biasa yang dapat diperiksa. Apabila perilaku sebuah macro tidak sesuai dugaan, pemeriksaan ekspansi yang sama menjadi titik awal yang tepat untuk mencari penyebabnya, karena sumber kebenaran sebuah macro memang selalu berupa kode yang dibangkitkannya.

Kapan macro sebaiknya tidak disentuh#

Macro pada contoh di atas sengaja dipilih sesederhana mungkin, dan justru karena sederhana itulah macro tersebut kalah dari fungsi biasa. kali_tiga/1 sebagai fungsi biasa lebih pendek, dapat dipanggil tanpa require, dapat dijadikan nilai, dan errornya dapat ditelusuri seperti fungsi lain. Aturan praktisnya: tulis fungsi lebih dahulu, dan pilih macro hanya apabila fungsi terbukti tidak mampu.

elixir Contoh: fungsi yang dapat dijadikan nilai
defmodule FungsiKu do
  def kali_tiga(nilai), do: nilai * 3
end

IO.inspect(Enum.map([1, 2, 3], &FungsiKu.kali_tiga/1), label: "fungsi dipetakan")
text Keluaran
fungsi dipetakan: [3, 6, 9]

Contoh di atas memperlihatkan keunggulan fungsi yang tidak dimiliki macro, yaitu dapat ditangkap sebagai nilai melalui &FungsiKu.kali_tiga/1 lalu diteruskan ke Enum.map/2. Percobaan serupa pada macro tidak mungkin, karena macro tidak tersedia sebagai fungsi pada saat program berjalan. Penelusuran error juga lebih sulit pada macro, karena pesan kesalahannya berasal dari kode hasil ekspansi yang tidak pernah dituliskan secara utuh, sehingga stack trace (jejak tumpukan pemanggilan) tidak lagi menunjuk baris kode aslinya.

AspekFungsiMacro
Waktu berjalansaat program berjalansaat kode dikompilasi
MasukannilaiAST
Dapat dijadikan nilaiya, lewat &Modul.fungsi/1tidak
Penelusuran errorstack trace biasapesan kompilasi atas kode hasil ekspansi
Peran wajarlogika programDSL dan library

Tempat yang wajar bagi macro adalah DSL (domain specific language) dan library. Plug menggunakan macro untuk membangun pipeline router, Ecto untuk schema dan query, sedangkan Phoenix untuk route dan controller. Semuanya membangkitkan banyak fungsi serupa yang tidak mungkin diabstraksi dengan fungsi biasa. Pada kode aplikasi sehari-hari, hampir seluruh pekerjaan adalah pekerjaan fungsi, dan satu defmacro pun biasanya sudah lebih dari cukup.

Apabila macro benar-benar diperlukan, tempatkan pada modul library yang terpisah, dokumentasikan kode yang dibangkitkannya, dan sediakan versi fungsinya apabila memungkinkan. Hasil ekspansi yang terdokumentasi membuat pengguna tidak perlu menebak apa yang terjadi di balik satu kata kunci, dan kode aplikasi tetap bebas dari lapisan kompilasi yang tidak perlu.

Ringkasan#

  • quote mengubah kode menjadi AST, unquote menyisipkan nilai ke dalamnya, dan Macro.to_string/1 merendernya kembali menjadi teks.
  • defmacro mendefinisikan fungsi yang dijalankan compiler dengan argumen dan nilai balikan berupa AST, dan modul macro-nya wajib di-require sebelum dipanggil.
  • Macro.expand_once/2 memperlihatkan kode hasil ekspansi dan menjadi alat utama untuk menyelidiki perilaku macro.
  • Fungsi lebih pendek, lebih mudah diuji, dan dapat dijadikan nilai, sehingga macro hanya layak untuk DSL dan library.

Referensi#