Behaviour

use, callback, dan modul yang dapat dipasang lepas.

Daftar isi

Behaviour memasangkan kontrak antarmuka dengan implementasi yang dapat dipasang lepas. Modul yang mendefinisikan kontrak hanya menuliskan nama fungsi beserta bentuk argumen dan nilai balikannya, sedangkan modul penyedia mengisi isinya sesuai kebutuhan masing-masing. Topik ini membahas pendefinisian kontrak dengan @callback dan @optional_callbacks, penyuntikan kode melalui use dan __using__, serta pemanggilan beberapa modul penyedia secara polimorfik melalui satu daftar modul.

Kontrak antarmuka dengan @callback#

Sebuah behaviour dideklarasikan dengan @callback. Atribut ini menuliskan nama fungsi, argumennya, dan tipe nilai balikannya dengan notasi typespec, yaitu anotasi tipe bawaan Elixir, tetapi tanpa isi fungsi. Kontrak ini berlaku bagi modul mana pun yang menyatakan diri mengikutinya, sehingga pemanggil dapat memanggil fungsi kontrak tersebut tanpa perlu mengetahui modul penyedianya. Pemeriksaan tipe yang mendetail diserahkan kepada Dialyzer, alat analisis statis untuk kode Erlang dan Elixir, sedangkan compiler memeriksa keberadaan fungsinya.

Contoh berikut mendefinisikan behaviour KonversiUang dengan satu callback konversi/1. Fungsi kontraknya menerima nilai bilangan bulat dan mengembalikan bilangan bulat, sedangkan isinya diserahkan sepenuhnya kepada modul penyedia. Daftar callback yang sudah terdaftar dapat diperiksa melalui behaviour_info/1:

elixir Contoh: behaviour KonversiUang dengan satu callback
defmodule KonversiUang do
  @callback konversi(nilai :: integer) :: integer
end

IO.inspect(KonversiUang.behaviour_info(:callbacks))
text Keluaran
[konversi: 1]

Keluaran berupa pasangan nama dan arity (jumlah argumen fungsi), yaitu [konversi: 1]. Pada tahap ini belum ada satu baris implementasi pun, padahal kontraknya sudah lengkap dan dapat dirujuk oleh modul lain. Perilaku inilah yang memisahkan pihak yang mendefinisikan kontrak dari pihak yang mengisinya, dan keduanya dapat dikembangkan secara terpisah.

Tanpa behaviour, pemanggilan fungsi pada modul tetap dapat dilakukan, dan kegagalannya baru terlihat saat UndefinedFunctionError muncul. Behaviour mengubah kesepakatan implisit menjadi kontrak yang tercatat: compiler mengetahui fungsi mana yang seharusnya ada, dan pembaca kode dapat menilai kelengkapan sebuah penyedia hanya dari daftar callbacknya. Konvensi inilah yang digunakan GenServer, Plug, dan sebagian besar library Elixir untuk menerima modul pemasangan dari luar.

Implementasi dengan @behaviour dan @impl#

Modul penyedia menyatakan dirinya mengikuti behaviour dengan atribut @behaviour, lalu menuliskan fungsi kontraknya. Anotasi @impl di atas fungsi menegaskan bahwa fungsi tersebut adalah implementasi callback, sehingga compiler memeriksa kesesuaiannya dengan behaviour yang dirujuk. Kesalahan nama fungsi dan jumlah argumennya tertangkap pada saat kompilasi. Tanpa @impl, compiler tidak menerima petunjuk fungsi mana yang dimaksudkan sebagai implementasi, sehingga kesalahan nama baru ketahuan dari warning callback yang menganggur.

Contoh berikut mengisi kontrak KonversiUang dengan kurs tetap 15.000 per dolar. Nilai kursnya ditulis sebagai 15_000 agar mudah dibaca, dan Elixir memperlakukannya sama dengan 15000:

elixir Contoh: modul penyedia dengan @behaviour dan @impl
defmodule KonversiUang do
  @callback konversi(nilai :: integer) :: integer
end

defmodule DolarKeRupiah do
  @behaviour KonversiUang

  @impl KonversiUang
  def konversi(nilai), do: nilai * 15_000
end

IO.inspect(DolarKeRupiah.konversi(10), label: "hasil konversi")
text Keluaran
hasil konversi: 150000

Pesan peringatan yang muncul disebut warning, yaitu catatan compiler yang tidak menghentikan jalannya program. Pemeriksaan itu berlangsung pada saat kompilasi, sehingga kesalahan bentuk kontrak lebih baik ditangkap melalui @impl dan warning kompilasi, bukan dibiarkan muncul sebagai UndefinedFunctionError pada saat program berjalan.

use dan kode yang disuntikkan#

Menuliskan @behaviour secara manual pada setiap modul penyedia merupakan pekerjaan yang berulang. use menyelesaikannya: use KonversiUang memanggil macro __using__/1 milik KonversiUang pada saat kompilasi, lalu menyuntikkan kode yang ditulis di dalam quote ke modul pemanggil. Kode yang disuntikkan dapat berupa atribut, fungsi, maupun impor.

Dua callback pada contoh berikut menggambarkan pembagian menjadi wajib dan opsional. konversi/1 wajib diisi, sedangkan mata_uang/0 dinyatakan opsional melalui @optional_callbacks. Macro __using__ menyuntikkan @behaviour sekaligus implementasi default untuk callback opsional itu, dan defoverridable memastikan default tersebut boleh diganti tanpa memunculkan peringatan klausa ganda:

elixir Contoh: use menyuntikkan @behaviour dan implementasi default
defmodule KonversiUang do
  @callback konversi(nilai :: integer) :: integer
  @callback mata_uang :: String.t()
  @optional_callbacks [mata_uang: 0]

  defmacro __using__(_opsi) do
    quote do
      @behaviour KonversiUang

      # implementasi default untuk callback opsional, boleh diganti
      def mata_uang, do: "mata uang asal"
      defoverridable mata_uang: 0
    end
  end

  def rincian(nilai, module) do
    "#{module.mata_uang()} #{nilai} menjadi Rp#{module.konversi(nilai)}"
  end
end

defmodule YenKeRupiah do
  use KonversiUang

  @impl KonversiUang
  def konversi(nilai), do: nilai * 105
end

IO.puts(KonversiUang.rincian(10, YenKeRupiah))
IO.puts(YenKeRupiah.mata_uang())
text Keluaran
mata uang asal 10 menjadi Rp1050
mata uang asal

YenKeRupiah tidak menuliskan mata_uang/0 sama sekali, sehingga implementasi default dari use digunakan. Pemanggilan YenKeRupiah.mata_uang() pun mengembalikan teks default tersebut. Skrip utuh pada bagian berikutnya memperlihatkan sisi lainnya, yaitu modul penyedia yang mengganti default itu dengan miliknya sendiri.

Nilai opsi juga dapat diteruskan, misalnya use KonversiUang, akurat: true, dan seluruhnya diterima sebagai argumen opsi pada __using__/1. Pola ini menjelaskan mengapa pemanggilan use terasa seperti menempatkan banyak baris kode dengan satu kata: seluruh isi quote benar-benar disalin ke modul pemanggil pada saat kompilasi, sehingga hasilnya tidak dapat dibedakan dari kode yang ditulis manual di tempat itu.

Skrip utuh: pemanggilan polimorfik#

Skrip utuh berikut menggabungkan seluruh bagian di atas. Dua modul penyedia dibuat dari behaviour yang sama, dengan DolarKeRupiah mengganti default mata_uang/0 menjadi "USD" dan YenKeRupiah menggunakan default bawaan. Keduanya lalu dipanggil secara polimorfik dari satu daftar modul, persis seperti daftar penyedia kanal pembayaran yang berisi kanal transfer bank dan kanal kupon hadiah:

elixir konversi.exs
# konversi.exs — behaviour KonversiUang beserta dua implementasinya
defmodule KonversiUang do
  @callback konversi(nilai :: integer) :: integer
  @callback mata_uang :: String.t()
  @optional_callbacks [mata_uang: 0]

  defmacro __using__(_opsi) do
    quote do
      @behaviour KonversiUang

      # implementasi default untuk callback opsional, boleh diganti
      def mata_uang, do: "mata uang asal"
      defoverridable mata_uang: 0
    end
  end

  def rincian(nilai, module) do
    "#{module.mata_uang()} #{nilai} menjadi Rp#{module.konversi(nilai)}"
  end
end

defmodule DolarKeRupiah do
  use KonversiUang

  @impl KonversiUang
  def konversi(nilai), do: nilai * 15_000

  @impl KonversiUang
  def mata_uang, do: "USD"
end

defmodule YenKeRupiah do
  use KonversiUang

  @impl KonversiUang
  def konversi(nilai), do: nilai * 105
end

IO.puts(KonversiUang.rincian(10, DolarKeRupiah))
IO.puts(KonversiUang.rincian(10, YenKeRupiah))

Enum.each([DolarKeRupiah, YenKeRupiah], fn module ->
  IO.puts("#{module.mata_uang()} 100 menjadi Rp#{module.konversi(100)}")
end)
text Keluaran
USD 10 menjadi Rp150000
mata uang asal 10 menjadi Rp1050
USD 100 menjadi Rp1500000
mata uang asal 100 menjadi Rp10500

Skripnya dapat dijalankan langsung dari terminal setelah berkasnya disimpan:

bash
# menjalankan skrip konversi dari terminal
elixir konversi.exs
text Keluaran
USD 10 menjadi Rp150000
mata uang asal 10 menjadi Rp1050
USD 100 menjadi Rp1500000
mata uang asal 100 menjadi Rp10500

Modul di Elixir hanyalah atom, sehingga [DolarKeRupiah, YenKeRupiah] adalah daftar nilai biasa yang dapat diperulangkan. Pemanggilan module.konversi(100) di dalam perulangan berlaku untuk kurs mana pun, karena kontrak konversi/1 menjamin fungsinya ada pada setiap modul di dalam daftar. Behaviour besar di ekosistem bekerja dengan pola yang sama: use GenServer menyuntikkan kerangka server beserta child_spec, sedangkan kontrak callbacknya diisi oleh modul yang menurutinya.

Modul penyedia baru juga dapat ditambahkan belakangan tanpa menyentuh satu baris kode pemanggil pun. Cukup buat modul yang mengikuti behaviour yang sama, lalu masukkan namanya ke dalam daftar. Pemanggil tidak berubah sedikit pun, dan kontrak konversi/1 menjadi satu-satunya kesepakatan yang menjaga keduanya tetap cocok.

Ringkasan#

  • @callback mendeklarasikan kontrak fungsi pada behaviour, dan behaviour_info(:callbacks) menampilkan daftar kontrak yang terdaftar.
  • Modul penyedia menyatakan dirinya dengan @behaviour dan menandai tiap implementasinya dengan @impl, sehingga kesalahan bentuk tertangkap pada saat kompilasi.
  • use memanggil __using__/1 dan menyuntikkan kode ke modul pemanggil, lazimnya berisi @behaviour beserta implementasi default yang boleh diganti berkat defoverridable.
  • Modul hanyalah atom, sehingga daftar modul penyedia dapat dipanggil secara polimorfik selama kontrak callbacknya terpenuhi.

Referensi#