Typespec dan Dialyzer

@spec serta arah tipe set-teoretis bertahap.

Daftar isi

Elixir menyediakan anotasi tipe opsional yang mendokumentasikan kontrak fungsi tanpa mengubah perilaku program. Topik ini membahas penulisan @spec dan @type, kontrak behaviour dengan @callback, pemeriksaan statis menggunakan Dialyzer melalui paket dialyxir, serta arah pengembangan sistem tipe set-teoretis bertahap yang mulai dimasukkan ke dalam compiler sejak Elixir 1.17. Anotasi yang dibahas melengkapi dokumentasi dan memungkinkan pemeriksaan otomatis.

@spec dan @type#

Atribut @spec menuliskan kontrak tipe sebuah fungsi, yaitu jenis argumen yang diterima dan jenis nilai yang dikembalikan. Atribut @type memberi nama pada sebuah tipe agar spesifikasi mudah dibaca dan dapat digunakan ulang. Keduanya tidak memengaruhi eksekusi, karena compiler hanya memeriksa bentuk penulisannya, bukan kesesuaiannya dengan kode. Skrip berikut memuat modul dengan dua tipe dan dua fungsi yang dianotasi.

elixir
# anotasi tipe pada modul Statistik, dikompilasi tanpa keluaran
defmodule Statistik do
  @type daftar_bilangan :: [number()]
  @type hasil :: {:ok, float()} | :error

  @spec rata_rata(daftar_bilangan()) :: float()
  def rata_rata(bilangan), do: Enum.sum(bilangan) / length(bilangan)

  @spec baca_daftar(daftar_bilangan()) :: hasil()
  def baca_daftar([]), do: :error
  def baca_daftar(bilangan), do: {:ok, rata_rata(bilangan)}
end

Modul di atas dikompilasi tanpa keluaran apa pun, karena anotasi tipe hanya menyertai kode, bukan mengubahnya. Tipe daftar_bilangan didefinisikan sebagai list berisi number(), sedangkan tipe hasil adalah gabungan dua bentuk nilai, yaitu tuple {:ok, float()} atau atom :error. Tanda | pada tipe hasil menyatakan gabungan tersebut. Anotasi juga tampil pada dokumentasi, dan rujukan ke sebuah tipe ditulis dengan notasi t:Statistik.hasil/0, sehingga pembaca dokumentasi dapat memahami kontrak tipe tanpa membuka kode sumbernya.

Anotasi @spec cukup ditulis satu kali untuk satu fungsi, meskipun fungsi tersebut memiliki beberapa klausa sebagaimana baca_daftar/1. Tipe dasar yang tersedia mengikuti tipe data bahasa, misalnya integer(), float(), binary(), atom(), dan pid(), dan semuanya dapat disusun menjadi tipe lebih besar, misalnya list pada contoh di atas yang ditulis [number()].

@callback dan behaviour#

Atribut @callback mendefinisikan kontrak yang harus dipenuhi modul yang mengikuti sebuah behaviour. Behaviour adalah modul yang menetapkan daftar fungsi wajib, dan modul lain menyatakan keikutsertaannya melalui @behaviour. Atribut @impl true pada definisi fungsi menandai fungsi tersebut sebagai implementasi callback, sehingga compiler dapat memeriksa kecocokannya dengan kontrak. Skrip berikut mendemonstrasikan kedua sisi kontrak tersebut.

elixir
# behaviour dengan kontrak @callback dan implementasinya
defmodule Penyimpanan do
  @type kunci :: String.t()
  @type nilai :: String.t()

  @callback simpan(kunci(), nilai()) :: :ok
  @callback baca(kunci()) :: {:ok, nilai()} | :error
end

defmodule PenyimpananMemori do
  @behaviour Penyimpanan

  @impl true
  def simpan(_kunci, _nilai), do: :ok

  @impl true
  def baca(_kunci), do: :error
end

IO.inspect(PenyimpananMemori.simpan("tema", "gelap"), label: "simpan")
IO.inspect(PenyimpananMemori.baca("tema"), label: "baca")
text Keluaran
simpan: :ok
baca: :error

Modul Penyimpanan menetapkan dua callback beserta tipe argumen dan hasilnya, sedangkan modul PenyimpananMemori mengimplementasikan keduanya dan kemudian dipanggil untuk menunjukkan nilainya. Apabila satu callback tidak diimplementasikan, atau bentuk fungsinya meleset dari kontrak, compiler menampilkan peringatan saat kompilasi. Atribut @impl true dapat diganti dengan nama behaviour-nya, misalnya @impl Penyimpanan, dan bentuk ini berguna ketika satu modul mengikuti lebih dari satu behaviour. Kontrak semacam ini lazim digunakan untuk antarmuka yang dapat ditukar, misalnya lapisan penyimpanan yang implementasinya berbeda antara pengujian dan produksi.

Tipe utama modul dengan @type t#

Konvensi @type t menamai tipe utama sebuah modul, sehingga modul lain dapat merujuknya sebagai NamaModul.t(). Untuk modul yang mendefinisikan struct, tipe t biasanya menggambarkan bentuk struct tersebut melalui %__MODULE__{}. Skrip berikut mendefinisikan struct Ukuran beserta tipe utamanya dan fungsi luas/1 yang dianotasi.

elixir
# tipe utama modul menggambarkan bentuk struct-nya
defmodule Ukuran do
  @type t :: %__MODULE__{lebar: number(), tinggi: number()}

  defstruct lebar: 0, tinggi: 0

  @spec luas(t()) :: number()
  def luas(ukuran), do: ukuran.lebar * ukuran.tinggi
end

IO.inspect(struct(Ukuran, lebar: 4, tinggi: 5), label: "ukuran")
IO.inspect(Ukuran.luas(struct(Ukuran, lebar: 4, tinggi: 5)), label: "luas")
text Keluaran
ukuran: %Ukuran{lebar: 4, tinggi: 5}
luas: 20

Tipe t menyatakan bahwa struct Ukuran memiliki dua kunci, yaitu lebar dan tinggi yang keduanya berjenis number(). Pembentukan nilai struct pada skrip menggunakan fungsi struct/2, sehingga nilainya terbentuk saat runtime. Pola %__MODULE__{} pada tipe t merujuk ke struct milik modul itu sendiri, sehingga tipe tersebut ikut berubah apabila defstruct diperbarui.

Dialyzer dan dialyxir#

Dialyzer adalah alat analisis statis bawaan Erlang/OTP yang mengidentifikasi diskrepansi perangkat lunak, misalnya type error yang pasti terjadi dan kode yang tidak akan pernah dijalankan karena kesalahan pemrograman. Alat ini bekerja tanpa mengeksekusi program, sehingga pemeriksaannya melengkapi tes, bukan menggantikannya. Untuk proyek Mix, paket dialyxir menyediakan tugas mix dialyzer yang membungkus alat tersebut.

Analisis Dialyzer bergantung pada PLT (Persistent Lookup Table), yaitu berkas cache berisi hasil analisis pustaka yang digunakan oleh program. PLT dibangun saat mix dialyzer pertama kali dijalankan, dan prosesnya memakan waktu beberapa menit karena pustaka standar OTP harus dianalisis lebih dahulu. Eksekusi berikutnya jauh lebih cepat, karena PLT digunakan sebagai titik awal dan diperbarui secara bertahap. Sifat inilah yang membuat perintah tersebut tidak dapat direkam dalam batas waktu yang ketat.

Paket dialyxir ditambahkan ke fungsi deps/0 sebagai {:dialyxir, "~> 1.4", only: [:dev], runtime: false}, lalu diperiksa dengan perintah berikut.

bash
# tidak-dijalankan
# mix dialyzer membangun PLT selama beberapa menit sehingga melebihi batas waktu blok
cd /tmp/kalkulator
mix deps.get
mix dialyzer

Setelah PLT siap, mix dialyzer membandingkan setiap @spec dengan implementasinya dan melaporkan ketidaksesuaian, misalnya fungsi yang dapat mengembalikan nilai di luar tipe yang diklaim oleh anotasinya. Laporan yang bersih berarti analisis tidak menemukan ketidaksesuaian pada kode yang diperiksa. Karena laporan mengikuti isi kode, keluarannya tidak direkam di sini dan perintahnya dijalankan pada proyek masing-masing.

Tipe set-teoretis bertahap#

Sejak Elixir 1.17, compiler mulai menyertakan sistem tipe yang menyimpulkan tipe dari kode yang ada tanpa anotasi apa pun. Rilis 1.18 memperluasnya menjadi pemeriksaan tipe pada pemanggilan fungsi, beserta inferensi pola dan nilai kembalian. Sistem tipe ini dinyatakan resmi bersifat sound, yaitu hasil inferensinya sejalan dengan perilaku program. Sifatnya juga gradual karena menyertakan tipe dynamic() untuk kode yang tipenya baru diketahui saat runtime.

Sifat set-teoretis berarti tipe diperlakukan sebagai himpunan yang disusun dari operasi dasar, yaitu gabungan dengan or, irisan dengan and, dan negasi dengan not. Contohnya, atom() or integer() menyatakan nilai yang boleh berupa atom atau bilangan bulat, sedangkan atom() and not nil menyatakan seluruh atom kecuali nil. Tipe boolean() merupakan alias praktis untuk gabungan true or false. Pada tahap saat ini, notasi tersebut tampil dalam peringatan compiler dan diagnostik, bukan sebagai anotasi yang ditulis pengembang.

Arah selanjutnya juga dinyatakan resmi pada dokumentasi sistem tipe. Tahap berikutnya memperkenalkan struct bertipe, kemudian anotasi tipe set-teoretis untuk fungsi, dan setelah tahap itu selesai, typespec ala Erlang akan ditinggalkan secara bertahap. Anotasi @spec yang ditulis saat ini tetap berguna bagi Dialyzer dan dokumentasi, tetapi bentuknya kelak bergeser mengikuti arah tersebut.

Pemeriksaan yang berjalan tanpa anotasi tidak menjanjikan penemuan semua bug, karena inferensinya bersifat best-effort dan hanya melaporkan pelanggaran yang pasti gagal untuk semua kemungkinan tipe. Dua kasus keliru yang terdokumentasi perlu dikenali, yaitu comprehension for yang dianggap dieksekusi minimal satu kali, dan sintaks pembaruan struct yang belum terbukti secara statis. Kasus kedua ditangani dengan mencocokkan pola struct terlebih dahulu sebelum memperbaruinya.

Kesalahan umum#

Menganggap @spec diperiksa saat kompilasi. Compiler Elixir hanya memeriksa bentuk penulisan anotasi, sehingga spec yang bertentangan dengan implementasi tetap lolos tanpa peringatan apa pun. Pemeriksaan semacam itu menjadi tugas Dialyzer, dan alat itu baru bekerja setelah dialyxir dipasang dan PLT selesai dibangun. Jadikan mix dialyzer bagian rutin pemeriksaan, misalnya berdampingan dengan tes pada CI, agar ketidaksesuaian kontrak tertangkap sebelum kode digunakan pihak lain.

Menulis @spec yang lebih sempit daripada perilaku fungsi. Spec yang mengklaim satu tipe padahal fungsi dapat mengembalikan tipe lain akan menyesatkan pembaca dokumentasi dan memunculkan laporan yang salah arah pada Dialyzer. Nilai yang mungkin kosong harus dinyatakan pula, misalnya sebagai gabungan dengan nil, bukan disembunyikan dari anotasi. Tuliskan gabungan tipe yang akurat dengan tanda |, atau sempitkan perilaku fungsinya hingga benar-benar sesuai dengan klaim yang sudah tertulis. Spec yang akurat juga membuat dokumentasi tetap benar ketika implementasinya berubah.

Ringkasan#

  • @spec dan @type mendokumentasikan kontrak tipe tanpa mengubah perilaku program dan tanpa diperiksa compiler.
  • @callback menetapkan kontrak behaviour yang diperiksa compiler pada modul yang menyatakan @behaviour dan @impl.
  • Dialyzer memeriksa kesesuaian anotasi dengan implementasi, dengan PLT sebagai cache yang dibangun sekali dan digunakan berulang.
  • Sistem tipe set-teoretis bertahap sudah menyimpulkan tipe dan memeriksa pemanggilan fungsi, sedangkan anotasi tipe bagi pengembang masih berada pada peta jalan.

Referensi#