Rancang tipe lebih dulu

Merancang signature metode dan union sebelum menulis badan kode.

Daftar isi

Kesalahan tipe yang ditangkap compiler jauh lebih murah daripada kesalahan logika yang baru muncul saat program berjalan. Topik ini mempraktikkan urutan kerja yang menempatkan tipe di depan, yaitu signature metode dan union untuk jalur gagal ditetapkan lebih dulu, sedangkan badan metode baru diisi setelahnya. Wahana latihannya berupa konverter kurs yang dibangun dalam tiga tahap, mulai dari kerangka API, pengujian placeholder, hingga versi lengkap yang berjalan. Setelah ketiga tahap itu, perubahan tipe yang melanggar kontrak dapat diserahkan kepada compiler untuk ditolak.

Signature sebagai kontrak#

Signature adalah deklarasi metode yang memuat namanya, tipe setiap parameter, dan tipe nilai kembaliannya, misalnya def ubah(jumlah : Int32, kurs : Float64) : Float64. Pada bahasa bertipe statis seperti Crystal, signature berperan sebagai kontrak, yaitu kesepakatan yang diperiksa compiler pada setiap badan metode maupun pemanggilannya. Tipe pada setiap parameter juga merupakan keputusan desain, misalnya jumlah uang menggunakan Int32 karena dihitung dalam satuan utuh, sedangkan kurs menggunakan Float64 karena memuat bagian desimal. Kontrak yang ditetapkan sejak awal membuat perubahan tipe terdeteksi di satu tempat, sedangkan kontrak yang disusun belakangan biasanya memaksa penyuntingan di setiap pemanggil.

Jalur gagal juga bagian dari kontrak. Alih-alih mengembalikan nilai sentinel seperti -1.0, metode pencarian menyatakan kegagalan melalui union, yaitu tipe gabungan berbentuk Kurs | Nil. Pemanggil dipaksa compiler menangani kemungkinan Nil sebelum menggunakan hasilnya, sehingga kegagalan tidak dapat terlewat secara diam-diam.

Kerangka yang tetap dapat dikompilasi#

Metodologi ini hanya masuk akal apabila kerangka yang badannya belum diisi tetap dapat dikompilasi. Caranya, setiap badan diisi raise "belum diimplementasi" seperti pada berkas kurs-kerangka.cr berikut. Konstanta DAFTAR_KURS dan alias union Kurs sudah final sejak tahap ini, sehingga keputusan tipe terdokumentasi di tempat yang sama dengan kontraknya.

crystal kurs-kerangka.cr
# Kerangka API konverter kurs: signature dan union ditetapkan lebih dulu,
# sedangkan badan metode masih berupa placeholder raise.
#
# Keputusan tipe: satu kurs disimpan sebagai NamedTuple, sehingga pencarian
# yang gagal dinyatakan dengan union Kurs | Nil dan bukan nilai sentinel
# seperti -1.0 yang bisa tertukar dengan hasil pencarian yang sah.
alias Kurs = NamedTuple(kode: String, nilai: Float64)

DAFTAR_KURS = [
  {kode: "USD", nilai: 15800.0},
  {kode: "EUR", nilai: 17100.0},
  {kode: "SGD", nilai: 11800.0},
] of Kurs

# Mengubah jumlah uang menjadi nilai rupiah sesuai kurs yang diberikan.
def ubah(jumlah : Int32, kurs : Float64) : Float64
  raise "belum diimplementasi"
end

# Mencari kurs berdasarkan kode mata uang, misalnya "USD".
def cari(kode : String) : Kurs | Nil
  raise "belum diimplementasi"
end

Blok perintah berikut memastikan kerangka itu benar-benar lolos kompilasi.

bash
# kerangka dengan badan raise tetap harus lolos kompilasi
crystal build kurs-kerangka.cr -o /tmp/kurs-kerangka && echo "kompilasi berhasil"
text Keluaran
kompilasi berhasil

Kompilasi yang berhasil menandakan kontraknya konsisten dari sisi tipe, mulai dari alias union hingga tipe kembalian setiap metode. Belum ada jaminan apa pun tentang perilakunya karena badan metode memang belum ada. Pada tahap inilah kontrak paling murah untuk diubah, karena belum ada satu pun pemanggil yang bergantung padanya. Hasil build ini juga hanya menjadi pemeriksa satu kali, sehingga tidak perlu dirawat sebagai berkas kerja.

Placeholder yang gagal secara jujur#

Kerangka yang gagal dijalankan lebih aman daripada kerangka yang diam-diam mengembalikan nilai keliru. Berkas kurs-uji.cr berikut memanggil ubah yang badannya masih raise, lalu menangkap error runtime tersebut agar pesan aslinya tampil. Kode pemanggil sengaja dipisah dari kerangka agar peran kedua berkas terlihat jelas.

crystal kurs-uji.cr
# Membuktikan bahwa kerangka terkompilasi dan placeholder raise-nya nyata.
require "./kurs-kerangka"

begin
  puts ubah(25, 15800.0)
rescue e : Exception
  puts "error runtime: #{e.message}"
end
bash
# panggil metode yang badannya masih raise
crystal run kurs-uji.cr
text Keluaran
error runtime: belum diimplementasi

Pesan belum diimplementasi berasal dari placeholder yang benar-benar dieksekusi dan bukan hasil simulasi. Apabila panggilan itu tidak dibungkus begin-rescue, program berhenti dengan pesan Unhandled exception: belum diimplementasi (Exception). Jejak tumpukan (stack trace) di bawah pesan tersebut menunjukkan berkas dan baris tempat raise dijalankan. Perilaku tegas inilah yang dituju metodologi ini, yaitu memastikan bagian yang belum selesai tidak pernah lolos tanpa terlihat.

Placeholder yang diam-diam mengembalikan nilai tidak memberi peringatan apa pun. Nilai 0.0 dari ubah yang belum diisi akan mengalir menjadi hasil konversi yang tampak sah, dan kekeliruannya baru terasa saat angkanya digunakan. raise membalik keadaan tersebut, yaitu bagian yang belum selesai justru menjadi bagian yang paling mudah ditemukan.

Mengisi badan tanpa mengubah kontrak#

Berkas kurs.cr berikut adalah versi lengkapnya. Signature dan union diambil persis dari kerangka, sedangkan badan ubah dan cari sudah diisi. Metode cari memanfaatkan Enumerable#find yang secara alami mengembalikan Kurs | Nil, sehingga tipe kembaliannya cocok dengan kontrak tanpa penyesuaian tambahan.

crystal kurs.cr
# Konverter kurs versi lengkap: signature dan union diambil dari kerangka,
# sedangkan badan metode sudah diisi dengan implementasi sebenarnya.
#
# Keputusan tipe: satu kurs disimpan sebagai NamedTuple, sehingga pencarian
# yang gagal dinyatakan dengan union Kurs | Nil dan bukan nilai sentinel
# seperti -1.0 yang bisa tertukar dengan hasil pencarian yang sah.
alias Kurs = NamedTuple(kode: String, nilai: Float64)

DAFTAR_KURS = [
  {kode: "USD", nilai: 15800.0},
  {kode: "EUR", nilai: 17100.0},
  {kode: "SGD", nilai: 11800.0},
] of Kurs

# Mengubah jumlah uang menjadi nilai rupiah sesuai kurs yang diberikan.
def ubah(jumlah : Int32, kurs : Float64) : Float64
  jumlah * kurs
end

# Mencari kurs berdasarkan kode mata uang, misalnya "USD".
def cari(kode : String) : Kurs | Nil
  DAFTAR_KURS.find { |k| k[:kode] == kode }
end

kurs_usd = cari("USD")
if kurs_usd
  puts "25 USD = #{ubah(25, kurs_usd[:nilai])} rupiah"
else
  puts "USD tidak terdaftar"
end

if cari("GBP").nil?
  puts "GBP tidak terdaftar"
end

kurs_sgd = cari("SGD")
if kurs_sgd
  puts "3 SGD = #{ubah(3, kurs_sgd[:nilai])} rupiah"
end
bash
# jalankan versi yang badannya sudah diisi
crystal run kurs.cr
text Keluaran
25 USD = 395000.0 rupiah
GBP tidak terdaftar
3 SGD = 35400.0 rupiah

Keluaran memperlihatkan dua cabang union yang berbeda. Kode USD terdaftar, sehingga blok if menggunakan nilai kursnya untuk konversi. Kode GBP tidak terdaftar, sehingga cari mengembalikan Nil dan pesan kegagalannya yang ditampilkan. Nilai 395000.0 dan 35400.0 dapat dihitung ulang langsung dari konstanta DAFTAR_KURS, yaitu 25 * 15800.0 dan 3 * 11800.0.

Kontrak juga bekerja ke arah sebaliknya. Apabila tipe kembalian diubah menjadi Int32 sementara badannya tetap mengembalikan Float64, compiler menolak program itu sebelum dijalankan. Potongan berikut sengaja dibuat salah untuk memperlihatkan bentuk penolakannya.

crystal Contoh: tipe kembalian bertentangan dengan badan
def ubah(jumlah : Int32, kurs : Float64) : Int32
  jumlah * kurs
end

puts ubah(25, 15800.0)
text Keluaran
Showing last frame. Use --error-trace for full trace.

In salah-tipe.cr:2:44

 2 | def ubah(jumlah : Int32, kurs : Float64) : Int32
                                                ^----
Error: method ::ubah must return Int32 but it is returning Float64

Pesan error menunjuk tepat ke signature yang dilanggar, lengkap dengan baris dan kolomnya, tanpa perlu menjalankan program. Angka 2:44 menunjuk baris kedua pada kolom letak tipe kembalian yang bermasalah. Inilah manfaat kontrak yang disusun lebih dulu, yaitu kesalahan tipe berhenti di waktu kompilasi dan tidak menyusup ke waktu berjalan.

Kesalahan umum#

Mengganti union dengan nilai sentinel. Metode pencarian yang mengembalikan -1.0 saat gagal menyembunyikan kegagalan dari tipe, sehingga pemanggil dapat memperlakukannya sebagai hasil sah tanpa peringatan compiler. Perbaikannya, pertahankan union Kurs | Nil dan tangani cabang Nil secara eksplisit.

Membiarkan placeholder terlalu lama. Badan yang masih raise berjalan normal selama jalurnya belum dipanggil, sehingga bagian yang belum selesai mudah terlupakan. Perbaikannya, uji kerangka segera setelah disusun seperti yang dilakukan kurs-uji.cr, dan jangan menumpuk badan baru di atas placeholder yang belum terisi.

Mempersempit tipe kembalian setelah banyak pemanggil ada. Perubahan dari Float64 ke Int32 misalnya memotong bagian desimal dan menuntut peninjauan seluruh pemanggil. Perbaikannya, ubah signature dan badan secara bersamaan, lalu kompilasi ulang agar setiap pelanggaran kontrak dilaporkan.

Menekan union dengan not_nil!. Pemanggilan not_nil! pada hasil cari membuat program berhenti apabila kode yang dicari tidak terdaftar, sehingga perlindungan compiler atas union tersebut hilang. Gaya ini sekilas terlihat lebih pendek, tetapi kegagalannya kembali berpindah ke waktu berjalan. Perbaikannya, tangani cabang Nil secara eksplisit seperti yang dilakukan blok if pada kurs.cr.

Ringkasan#

  • Signature dan union yang ditetapkan lebih dulu berperan sebagai kontrak yang diperiksa compiler sejak sebelum badan ada.
  • Badan placeholder raise bertipe NoReturn, sehingga kerangka tetap lolos kompilasi tanpa satu pun implementasi.
  • Pemanggilan badan yang belum diisi memunculkan error runtime yang jujur dan bukan nilai senyap yang menyesatkan.
  • Setelah badan diisi, setiap perubahan yang melanggar signature berhenti di waktu kompilasi beserta lokasinya.

Referensi#