Generics

Class generik bawaan dan class generik buatan sendiri dengan parameter tipe.

Daftar isi

Generics adalah mekanisme penulisan class dan metode yang bekerja terhadap banyak tipe dengan menjadikan perbedaan tipe sebagai parameter. Crystal menggunakan mekanisme ini pada struktur data di standard library, mulai dari array hingga Hash dan NamedTuple. Topik ini membahas generics bawaan, class generik buatan sendiri, instansiasi dengan inferensi tipe, metode generik, dan batasan compiler pada kode generik. Setelah membaca topik ini, tanda tangan tipe generik pada dokumentasi API dapat dibaca dan class generik sederhana dapat ditulis.

Generik pada standard library#

Class generik (generic class) adalah class yang menerima tipe sebagai argumen, ditulis dalam tanda kurung setelah nama class-nya. Array, Hash, dan NamedTuple adalah contohnya pada standard library. Array menyimpan elemen dengan satu parameter tipe yang lazim ditulis T, sedangkan Hash memerlukan dua parameter, yaitu K untuk tipe kunci dan V untuk tipe nilai. Nama lengkap hasil instansiasi terlihat dengan memanggil typeof terhadap nilainya.

crystal Contoh: membaca tipe lengkap dengan typeof
angka = [1, 2, 3]
kamus = {"satu" => 1, "dua" => 2}
catatan = {judul: "laporan", halaman: 12}

puts typeof(angka)
puts typeof(kamus)
puts typeof(catatan)
text Keluaran
Array(Int32)
Hash(String, Int32)
NamedTuple(judul: String, halaman: Int32)

Keluaran tersebut menunjukkan tipe lengkap dari ketiga nilai. Array literal [1, 2, 3] menghasilkan Array(Int32) karena semua elemennya bertipe Int32, sedangkan kamus dengan kunci string dan nilai bilangan bulat menghasilkan Hash(String, Int32). NamedTuple bahkan mencantumkan nama dan tipe setiap kolomnya. Parameter tipe membuat satu definisi class tetap aman tipe untuk banyak kombinasi tipe, dan nama lengkap inilah yang digunakan dokumentasi API untuk menjelaskan setiap class generik.

Class generik buatan sendiri#

Class generik buatan sendiri ditulis dengan menambahkan parameter tipe pada deklarasi class-nya. Definisi berikut membuat class Kotak yang menyimpan satu nilai bertipe T. Parameter tipe T digunakan pada empat tempat, yaitu tipe variabel instance @isi, tipe argumen initialize, tipe kembalian metode isi, dan tipe argumen metode isi=.

crystal kotak.cr
# Kotak menyimpan satu nilai bertipe T
class Kotak(T)
  def initialize(@isi : T)
  end

  def isi : T
    @isi
  end

  def isi=(isi_baru : T)
    @isi = isi_baru
  end
end

kotak = Kotak(Int32).new(42)
puts typeof(kotak)
puts kotak.isi

kotak.isi = 100
puts kotak.isi

kotak_teks = Kotak(String).new("catatan")
puts typeof(kotak_teks)
puts kotak_teks.isi
text Keluaran
Kotak(Int32)
42
100
Kotak(String)
catatan

Pemanggilan Kotak(Int32).new(42) mengikat T ke Int32, sedangkan Kotak(String).new("catatan") mengikat T ke String. Keluaran typeof memperlihatkan bahwa keduanya menghasilkan tipe konkret yang berbeda, yaitu Kotak(Int32) dan Kotak(String). Karena setter isi= menerima argumen bertipe T, penugasan kotak.isi = "teks" pada Kotak(Int32) ditolak compiler dengan pesan ketidakcocokan tipe. Satu definisi class dengan demikian melayani banyak tipe tanpa mengorbankan pemeriksaan tipe.

Instansiasi dengan inferensi tipe#

Argumen tipe tidak selalu harus ditulis secara eksplisit. Apabila initialize menerima argumen, compiler menyimpulkan parameternya dari tipe argumen yang dikirim, mekanisme yang disebut inferensi tipe (type inference). Pemanggilan Kotak.new(42) pada program berikut menghasilkan class yang sama dengan Kotak(Int32).new(42).

crystal Contoh: instansiasi dengan inferensi tipe
class Kotak(T)
  def initialize(@isi : T)
  end

  def isi : T
    @isi
  end
end

kotak = Kotak.new(42)
puts typeof(kotak)
text Keluaran
Kotak(Int32)

Keluaran typeof menegaskan bahwa hasil inferensinya adalah Kotak(Int32). Bentuk eksplisit dan bentuk inferensi menghasilkan tipe yang sama, sehingga pemilihannya adalah perkara kejelasan kode. Bentuk eksplisit lazim digunakan ketika kode perlu menegaskan tipe yang diharapkan, sedangkan bentuk inferensi membuat baris pemanggilan lebih pendek. Apabila argumen yang dikirim tidak cocok dengan tipe yang ditulis eksplisit, compiler menolaknya sebagaimana ditunjukkan pada bagian Kesalahan umum.

Metode generik dengan forall#

Metode juga dapat memiliki parameter tipe sendiri yang berlaku per pemanggilan. Parameternya dideklarasikan dengan kata kunci forall yang ditulis setelah tanda tangan metode, seperti forall T pada definisi berikut. Pembatas Array(T) mengikat T ke tipe elemen array yang dikirim, sehingga satu definisi melayani array dengan tipe elemen apa pun. Tipe kembalian metode juga boleh merujuk T yang sama.

crystal Contoh: metode generik ambil_pertama
def ambil_pertama(xs : Array(T)) : T forall T
  xs.first
end

puts ambil_pertama([3, 8, 5])
puts typeof(ambil_pertama([3, 8, 5]))
puts ambil_pertama(["tiga", "delapan"])
puts typeof(ambil_pertama(["tiga", "delapan"]))
text Keluaran
3
Int32
tiga
String

Keluaran typeof menunjukkan bahwa T terikat ke Int32 pada pemanggilan pertama dan ke String pada pemanggilan kedua. Nilai kembalian mengikuti tipe elemen array tanpa konversi apa pun. Penulisan forall tidak boleh dilewati karena compiler menolak parameter tipe yang tidak dideklarasikan dengan pesan undefined constant T, sebagaimana ditunjukkan pada bagian Kesalahan umum.

Batasan compiler pada parameter tipe#

Kode di dalam class generik dikompilasi untuk setiap tipe yang menginstansiasinya. Akibatnya, setiap metode dan operator yang dipanggil terhadap nilai bertipe T wajib tersedia pada semua tipe pemakaiannya. Definisi berikut menambahkan metode jumlah yang menghitung @isi + 1. Metode ini sah untuk Kotak(Int32), tetapi compiler menolaknya ketika class diinstansiasi dengan String karena String tidak mendefinisikan operator + untuk argumen Int32.

crystal Contoh: metode yang mensyaratkan operator + pada T
class Kotak(T)
  def initialize(@isi : T)
  end

  def jumlah : T
    @isi + 1
  end
end

kotak = Kotak(String).new("catatan")
puts kotak.jumlah
text Keluaran
Showing last frame. Use --error-trace for full trace.

In error_jumlah.cr:6:12

 6 | @isi + 1
            ^
Error: expected argument #1 to 'String#+' to be Char or String, not Int32

Overloads are:
 - String#+(other : self)
 - String#+(char : Char)

Pesan error tersebut berasal dari pemeriksaan overload String#+, yaitu operator + milik String, yang tidak menerima argumen Int32. Kompilasi hanya berhasil apabila pemanggilan semacam itu dihapus atau tipe argumen metode dibatasi ke tipe yang mendukungnya. Nama berkas pada baris pertama pesan mengikuti nama berkas yang dikompilasi, sehingga baris tersebut boleh berbeda pada lingkungan lain.

Kesalahan umum#

Dua kesalahan berikut lazim muncul ketika class dan metode generik pertama kali ditulis, dan keduanya terdeteksi compiler sebelum program dijalankan. Masing-masing disertai penyebab dan perbaikannya.

Kesalahan pertama adalah menambahkan anotasi tipe kembalian : T tanpa mendeklarasikan parameter tipe dengan forall. Penyebabnya, compiler belum menganggap T sebagai parameter tipe metode, sehingga T diperlakukan sebagai konstanta yang tidak ada. Perbaikannya, tambahkan forall T setelah tanda tangan metode sebagaimana pada bagian Metode generik dengan forall.

crystal Contoh: anotasi tipe kembalian tanpa forall
def ambil_pertama(xs : Array(T)) : T
  xs.first
end

puts ambil_pertama([3, 8, 5])
text Keluaran
Showing last frame. Use --error-trace for full trace.

In error_metode.cr:1:30

 1 | def ambil_pertama(xs : Array(T)) : T
                                  ^
Error: undefined constant T

Kesalahan kedua adalah mengirim argumen yang tidak cocok dengan parameter tipe yang ditulis eksplisit. Pada pemanggilan berikut, Kotak(Int32) menuntut argumen Int32, sedangkan yang dikirim adalah String. Compiler menolaknya sebelum program dijalankan. Perbaikannya, samakan tipe argumen dengan parameter tipe yang ditulis, atau hilangkan penulisan eksplisitnya agar inferensi tipe yang menentukan.

crystal Contoh: argumen new yang tidak cocok dengan T
class Kotak(T)
  def initialize(@isi : T)
  end
end

kotak = Kotak(Int32).new("teks")
puts kotak.isi
text Keluaran
Showing last frame. Use --error-trace for full trace.

In error_instansiasi.cr:6:26

 6 | kotak = Kotak(Int32).new("teks")
                              ^
Error: expected argument #1 to 'Kotak(Int32).new' to be Int32, not String

Overloads are:
 - Kotak(T).new(isi : T)

Ringkasan#

  • Array, Hash, dan NamedTuple adalah class generik bawaan yang parameter tipenya terlihat pada nama lengkapnya, misalnya Array(Int32) dan Hash(String, Int32).
  • Class generik buatan sendiri dideklarasikan dengan parameter tipe seperti class Kotak(T), dan setiap instansiasi menghasilkan tipe konkret tersendiri seperti Kotak(Int32).
  • Argumen tipe dapat disimpulkan compiler dari argumen initialize, sedangkan metode generik mendeklarasikan parameternya dengan forall.
  • Kode generik diperiksa compiler untuk setiap tipe yang menginstansiasinya, sehingga metode yang menggunakan operator tertentu hanya sah untuk tipe yang mendukung operator tersebut.

Referensi#