Pendahuluan

Tulisan ini adalah versi blog dari wicara Ian di Google Open Source Live:

dan GopherCon 2021:

Go versi 1.18 memiliki fitur bahasa baru: mendukung pemrograman generik. Dalam artikel ini, saya tidak akan menjelaskan apa itu generik atau bagaimana cara pakainya. Artikel ini tentang kapan menggunakan generik pada kode Go, dan kapan tidak menggunakan generik.

Supaya lebih jelas, saya akan memberikan beberapa panduan umum, yang tidak terlalu baku dan ringkas. Gunakan penilaian Anda sendiri. Namun bila Anda tidak yakin, Saya rekomendasi kan menggunakan panduan yang didiskusikan di sini.

Tulis kode

Mari kita mulai dengan sebuah panduan umum untuk pemrograman dengan Go: tulis program Go dengan menulis kode, bukan dengan mendefinisikan tipe. Bila berbicara tentang generik, jika Anda menulis program dengan mendefinisikan batasan parameter tipe terlebih dahulu, Anda bisa jadi sudah salah arah. Mulai lah dengan menulis fungsi. Akan mudah untuk menambahkan parameter tipe nantinya saat jelas bahwa ia memang akan berguna.

Kapan parameter tipe berguna?

Mari kita lihat kasus-kasus apa saja yang dapat menggunakan parameter tipe.

Saat menggunakan tipe penampung bawaan dari bahasa

Kasus yang pertama yaitu saat menulis fungsi yang mengoperasikan tipe-tipe penampung bawaan dari bahasa: slice, map, dan channel. Jika sebuah fungsi memiliki parameter dengan tipe-tipe tersebut, dan badan fungsi tidak bergantung pada elemen tipe, maka mungkin saja bisa menggunakan parameter tipe.

Contohnya, berikut fungsi yang mengembalikan semua kunci dari sebuah map dari tipe apa saja, dalam bentuk slice:

// MapKeys mengembalikan sebuah slice yang berisi semua kunci dari
// sebuah map m.
// Kunci-kunci tersebut tidak dikembalikan secara berurutan.
func MapKeys[Key comparable, Val any](m map[Key]Val) []Key {
	s := make([]Key, 0, len(m))
	for k := range m {
		s = append(s, k)
	}
	return s
}

Kode tersebut tidak mengasumsikan tipe kunci dari map, dan ia juga tidak menggunakan tipe nilai dari map. Sehingga fungsi tersebut dapat bekerja dengan tipe map apa saja. Hal ini membuatnya menjadi kandidat yang bagus untuk penggunaan parameter tipe.

Alternatif dari parameter tipe pada jenis fungsi seperti ini yaitu biasanya menggunakan refleksi, namun cara tersebut menjadi aneh, karena tidak ada pemeriksaan tipe secara statis pada kode, dan terkadang saat dijalankan menjadi lambat.

Struktur data umum

Kasus lain yang mana parameter tipe dapat berguna yaitu untuk struktur data untuk keperluan umum. Sebuah struktur data keperluan umum yaitu seperti slice atau map, tapi yang bukan bawaan dari bahasa itu sendiri, seperti linked list, atau pohon biner.

Saat ini, program yang membutuhkan struktur data tersebut biasanya melakukan satu dari dua hal berikut: menulisnya dengan tipe elemen tertentu, atau menggunakan tipe interface. Mengganti tipe elemen tertentu dengan sebuah parameter tipe dapat menghasilkan sebuah struktur data yang lebih umum yang dapat digunakan pada bagian lain program, atau oleh program lain. Mengganti tipe interface dengan dengan parameter tipe membolehkan data disimpan lebih efisien, menghemat memori; ia juga menghindari asersi tipe, dan tipe-nya diperiksa secara penuh saat pembangunan.

Contohnya, berikut struktur data dari pohon biner menggunakan parameter tipe:

// Tree merepresentasikan pohon biner.
type Tree[T any] struct {
	cmp  func(T, T) int
	root *node[T]
}

// Sebuah node dalam pohon biner.
type node[T any] struct {
	left, right *node[T]
	val         T
}

// find mengembalikan sebuah pointer terhadap node yang berisi val,
// atau, bila val tidak ada, sebuah pointer tempat ia akan disimpan.
func (bt *Tree[T]) find(val T) **node[T] {
	pl := &bt.root
	for *pl != nil {
		switch cmp := bt.cmp(val, (*pl).val); {
		case cmp < 0:
			pl = &(*pl).left
		case cmp > 0:
			pl = &(*pl).right
		default:
			return pl
		}
	}
	return pl
}

// Insert menambahkan val ke dalam bt jika belum ada, dan
// mengembalikan true bila berhasil ditambah.
func (bt *Tree[T]) Insert(val T) bool {
	pl := bt.find(val)
	if *pl != nil {
		return false
	}
	*pl = &node[T]{val: val}
	return true
}

Setiap node di dalam pohon berisi sebuah nilai dari parameter tipe T. Saat pohon dibuat dengan argumen tipe tertentu, nilai dari tipe tersebut akan disimpan langsung di dalam node-node. Ia tidak disimpan sebagai tipe interface.

Contoh di atas adalah penggunaan yang masuk akal dari parameter tipe karena struktur data Tree, termasuk kode pada method-nya, independen terhadap tipe elemen T.

Struktur data Tree tidak perlu tahu bagaimana cara membandingkan nilai dari tipe elemen T; ia menggunakan fungsi pembanding yang di-kirim. Anda dapat melihat ini di baris ke empat pada method find, pada saat pemanggilan bt.cmp. Selain itu, parameter tipe tidak berpengaruh sama sekali.

Untuk parameter tipe, pilih fungsi daripada method

Contoh pada Tree sebelumnya memiliki panduan umum lainnya: saat Anda membutuhkan fungsi tertentu seperti pembanding, pilih lah dengan mengimplementasikan dalam sebuah fungsi daripada method.

Kita bisa saja mendefinisikan tipe Tree sehingga tipe elemen harus memiliki method Compare atau Less. Hal ini dapat dilakukan dengan menulis sebuah tipe batasan yang membutuhkan method-method tersebut, dengan kata lain setiap argumen tipe yang digunakan untuk membangun sebuah tipe Tree harus memiliki method-method tersebut.

Akibatnya adalah setiap orang yang ingin menggunakan Tree untuk tipe data sederhana seperti int harus mendefinisikan tipe integer-nya sendiri dan menulis method-method pembanding. Jika kita mendefinisikan Tree untuk menerima fungsi pembanding, seperti pada kode di atas, maka akan mudah untuk mengirim fungsi yang diinginkan.

Jika seandainya tipe elemen dari Tree sudah memiliki method Compare, maka kita dapat dengan mudah menggunakan ekspresi seperti ElementType.Compare sebagai fungsi pembanding.

Dengan kata lain, lebih mudah mengubah method menjadi fungsi daripada menambahkan method ke sebuah tipe. Jadi untuk tipe data umum, pilih lah sebuah fungsi daripada menulis sebuah batasan yang membutuhkan sebuah method.

Mengimplementasikan method umum

Kasus lain yang mana parameter tipe dapat berguna yaitu saat tipe-tipe yang berbeda harus mengimplementasikan proses yang sama, dan implementasi dari tipe-tipe yang berbeda tersebut semuanya tampak sama.

Contohnya, lihat sort.Interface pada pustaka bawaan Interface tersebut membutuhkan sebuah tipe mengimplementasikan tiga method: Len, Swap, dan Less.

Berikut contoh sebuah tipe generik SliceFn yang mengimplementasikan sort.Interface untuk tipe slice apa pun:

// SliceFn mengimplementasikan sort.Interface untuk slice bertipe T.
type SliceFn[T any] struct {
	s    []T
	less func(T, T) bool
}

func (s SliceFn[T]) Len() int {
	return len(s.s)
}
func (s SliceFn[T]) Swap(i, j int) {
	s.s[i], s.s[j] = s.s[j], s.s[i]
}
func (s SliceFn[T]) Less(i, j int) bool {
	return s.less(s.s[i], s.s[j])
}

Untuk tipe slice apa saja, method Len dan Swap hampir sama. Method untuk Less membutuhkan pembandingan, karena itulah ditulis Fn sebagai nama dari SliceFn. Seperti contoh Tree sebelumnya, kita akan mengirim sebuah fungsi pada saat membuat sebuah SliceFn.

Berikut cara menggunakan SliceFn untuk mengurutkan slice tipe apa saja menggunakan sebuah fungsi pembanding:

// SortFn mengurutkan s menggunakan fungsi pembanding.
func SortFn[T any](s []T, less func(T, T) bool) {
	sort.Sort(SliceFn[T]{s, less})
}

Contoh ini mirip dengan fungsi sort.Slice pada pustaka bawaan, namun fungsi pembandingan ditulis menggunakan nilai bukan dengan indeks dari slice.

Menggunakan parameter tipe untuk bentuk kode di atas sangat lah sesuai karena isi method-nya akan sama untuk semua tipe slice.

(Saya harus mengingatkan bahwa Go 1.19 —bukan 1.18— bisa jadi akan mengikutkan sebuah fungsi generik untuk mengurutkan sebuah slice menggunakan fungsi pembandingan, dan fungsi generik tersebut kemungkinan besar tidak menggunakan sort.Interface. Lihat proposal. Sangat masuk akal menggunakan parameter tipe saat Anda butuh mengimplementasikan method yang hampir sama untuk tipe-tipe yang dibutuhkan.)

Kapan parameter tipe tidak berguna?

Sekarang mari kita bicarakan sisi lain dari pertanyaan tadi: kapan tidak menggunakan parameter tipe.

Jangan ganti tipe interface dengan parameter tipe

Seperti yang kita semua ketahui, Go memiliki tipe interface. Tipe interface membolehkan semacam pemrograman generik.

Contohnya, interface io.Reader yang umum digunakan menyediakan sebuah mekanisme generik untuk membaca data dari nilai apa saja yang berisi informasi (misalnya, berkas) atau menghasilkan informasi (misalnya, generator bilangan acak). Jika yang Anda butuhkan dari sebuah nilai dari tipe tertentu adalah pemanggilan method dari nilai tersebut, gunakan tipe interface, bukan parameter tipe. Menggunakan io.Reader lebih mudah dibaca, efisien, dan efektif. Tidak perlu menggunakan parameter tipe untuk membaca data dari sebuah nilai dengan memanggil method Read.

Contohnya, memang menggoda untuk mengubah fungsi berikut, yang menggunakan tipe interface, menjadi versi kedua, yang menggunakan parameter tipe.

func ReadSome(r io.Reader) ([]byte, error)

func ReadSome[T io.Reader](r T) ([]byte, error)

Jangan buat perubahan seperti itu. Mengabaikan parameter tipe membuat fungsi tersebut lebih mudah ditulis, mudah dibaca, dan waktu eksekusi-nya bisa jadi sama.

Poin yang terakhir ini perlu ditekankan. Walaupun bisa saja mengimplementasikan generik dengan berbagai cara, dan implementasi tersebut mungkin berkembang dan berubah seiring waktu, implementasi yang sekarang digunakan pada Go 1.18 akan, pada banyak kasus, memperlakukan nilai dari parameter tipe seperti nilai dari tipe interface. Maksudnya adalah menggunakan parameter tipe umumnya tidak akan lebih cepat daripada tipe interface. Jadi jangan ubah dari tipe interface ke parameter tipe hanya supaya lebih cepat, karena belum tentu begitu.

Jangan gunakan parameter tipe bila cara implementasinya berbeda

Saat memilih apakah menggunakan parameter tipe atau sebuah tipe interface, pertimbangkan cara implementasinya. Sebelumnya kita mengatakan bahwa jika cara implementasinya sama untuk semua tipe, gunakan lah parameter tipe. Sebaliknya, jika implementasinya berbeda untuk setiap tipe, maka gunakan tipe interface, jangan gunakan parameter tipe.

Contohnya, implementasi Read dari sebuah berkas tidak sama dengan implementasi Read pada pembangkit bilangan acak. Artinya kita harus menulis method Read yang berbeda untuk keduanya, dan menggunakan tipe interface seperti io.Reader.

Gunakan refleksi sesuai tempatnya

Go memiliki refleksi run time. Refleksi yaitu semacam pemrograman generik, yang mana ia membolehkan kita menulis kode dengan tipe apa pun.

Jika beberapa operasi harus mendukung tipe-tipe yang tidak memiliki method (sehingga tipe interface tidak membantu disini), dan jika operasi berbeda untuk setiap tipe (sehingga parameter tipe tidak sesuai), gunakan refleksi.

Salah satu contoh dari kasus ini yaitu paket encoding/json. Kita tidak ingin setiap tipe yang kita enkode memiliki method MarshalJSON, sehingga kita tidak dapat menggunakan tipe interface. Namun mengodekan sebuah tipe interface tidak sama dengan mengodekan tipe struct, sehingga kita tidak dapat menggunakan parameter tipe. Melainkan, paket tersebut menggunakan refleksi. Kode-nya tidak lah sederhana, namun bekerja dengan baik. Untuk lebih rinci, lihat sumber kode-nya.

Satu panduan sederhana

Sebagai penutup, diskusi tentang kapan menggunakan generik ini dapat disimpulkan menjadi satu panduan sederhana saja.

Jika Anda suatu saat nanti menulis kode yang sama beberapa kali, yang perbedaan-nya hanya pada penggunaan tipe, mungkin Anda bisa menggunakan parameter tipe.

Dengan kata lain, hindari menggunakan parameter tipe sampai Anda menyadari bahwa Anda akan menulis kode yang sama beberapa kali untuk tipe-tipe yang berbeda.