Pendahuluan

Blog ini berdasarkan wicara pada GopherCon 2021.

Rilis Go 1.18 menambah dukungan terhadap generik. Generik adalah perubahan terbesar yang kami lakukan terhadap Go sejak rilis pertama. Dalam artikel ini kami akan memperkenalkan fitur bahasa yang baru. Kami tidak akan membahas semuanya secara rinci, namun kami akan jelaskan beberapa poin penting. Untuk deskripsi yang lebih detil dan lengkap, termasuk contoh-contohnya, lihatlah dokumen proposal. Untuk deskripsi tentang perubahan bahasa, lihat pembaruan dari spesifikasi bahasa. (Ingatlah bahwa implementasi generik pada versi 1.18 belum menerapkan semua gagasan yang ada dalam proposal; tapi spesifikasi bahasa seharusnya sudah akurat. Rilis selanjutnya bisa melengkapi implementasi tersebut.)

Generik adalah cara menulis kode yang tidak bergantung pada tipe tertentu. Tipe dan fungsi dapat ditulis menggunakan sekumpulan tipe.

Generik menambahkan tiga hal besar pada bahasa Go:

  1. Parameter tipe untuk fungsi dan tipe.

  2. Mendefinisikan tipe interface sebagai sekumpulan tipe, termasuk tipe-tipe yang tidak memiliki method.

  3. Inferensi tipe, yang membolehkan mengabaikan argumen tipe saat memanggil sebuah fungsi.

Parameter tipe

Sekarang, fungsi dan tipe bisa memiliki parameter tipe. Sebuah daftar parameter tipe bentuknya seperti daftar parameter seperti biasa, kecuali mereka dibungkus dengan kurung siku "[]" bukan dengan tanda kurung lengkung "()".

Untuk memperlihatkan cara kerjanya, mari kita mulai dengan fungsi Min tanpa generik untuk nilai desimal:

func Min(x, y float64) float64 {
	if x < y {
		return x
	}
	return y
}

Kita dapat membuat fungsi Min tersebut menerima tipe-tipe yang berbeda dengan menambahkan sebuah parameter tipe. Pada contoh ini kita tambahkan parameter tipe T, dan mengganti penggunaan float64 dengan T.

import "golang.org/x/exp/constraints"

func GMin[T constraints.Ordered](x, y T) T {
	if x < y {
		return x
	}
	return y
}

Sekarang kita bisa memanggil fungsi tersebut dengan argumen tipe dengan menulis pemanggilan seperti berikut

x := GMin[int](2, 3)

Dengan menyediakan argumen tipe ke fungsi GMin, pada contoh ini yaitu int, disebut juga dengan instansiasi. Instansiasi terjadi dalam dua tahap. Pertama, compiler mengganti semua argumen tipe untuk semua parameter tipe yang diberikan lewat fungsi atau tipe generik. Kedua, compiler memverifikasi setiap argumen tipe memenuhi batasan yang diberikan. Kita akan jelaskan maksud dari tahap kedua nanti, namun bila tahap tersebut gagal, maka instansiasi akan gagal dan program menjadi invalid.

Setelah instansiasi sukses, kita memiliki sebuah fungsi non-generik yang dapat dipanggil seperti fungsi lainnya. Misalnya, pada kode berikut

fmin := GMin[float64]
m := fmin(2.71, 3.14)

instansiasi GMin[float64] menghasilkan sebuah fungsi yang secara efektif seperti fungsi Min sebelumnya, yang dapat kita gunakan sebagai pemanggilan fungsi.

Parameter tipe dapat digunakan juga pada tipe bentukan.

type Tree[T interface{}] struct {
	left, right *Tree[T]
	value       T
}

func (t *Tree[T]) Lookup(x T) *Tree[T] { ... }

var stringTree Tree[string]

Pada contoh di atas, tipe generik Tree menyimpan nilai parameter bertipe T. Tipe-tipe generik dapat memiliki method, seperti Lookup pada contoh tersebut. Untuk dapat menggunakan tipe generik, ia harus di-instansiasi; Tree[string] adalah contoh cara meng-instansiasi tipe Tree dengan argumen tipe string.

Kumpulan tipe

Mari kita lihat lebih dalam tentang argumen tipe yang dapat digunakan untuk meng-instansiasi sebuah parameter tipe.

Sebuah fungsi biasa memiliki sebuah tipe untuk setiap nilai pada parameter; tipe tersebut mendefinisikan sekumpulan nilai. Misalnya, bila kita memiliki tipe float64 seperti pada fungsi non-generik Min di atas, maka kumpulan nilai argumen yang dibolehkan yaitu kumpulan dari nilai float yang dapat direpresentasikan oleh tipe float64.

Hal yang sama, daftar parameter tipe memiliki sebuah tipe untuk setiap parameter tipe. Secara sebuah parameter tipe itu sendiri adalah sebuah tipe, maka tipe-tipe dari parameter tipe berisi kumpulan tipe. Meta-tipe ini disebut juga dengan batasan tipe atau type constraint.

Pada fungsi generik GMin, batasan tipe diimpor dari paket constraints. Batasan Ordered berisi kumpulan dari semua tipe dengan nilai yang dapat diurut, atau, dengan kata lain, dapat dibandingkan dengan operator pembanding <, <=, >, atau >=. Batasan ini memastikan bahwa hanya tipe-tipe dengan nilai yang dapat diurut saja yang dapat dikirim ke GMin. Ia juga berarti bahwa di dalam badan fungsi GMin nilai dari parameter tipe dapat digunakan dalam pembandingan dengan operator <.

Dalam Go, batasan tipe haruslah berupa interface. Sebuah tipe interface dapat digunakan sebagai sebuah tipe pada nilai, dan juga dapat digunakan sebagai meta-tipe. Interface mendefinisikan method-method, sehingga kita dapat mengekspresikan batasan tipe yang membutuhkan beberapa method tertentu. Tapi constrains.Ordered adalah tipe interface juga, dan operator < bukanlah sebuah method.

Supaya dapat bekerja, kita harus melihat interface dengan cara baru.

Spesifikasi Go menyatakan bahwa sebuah interface mendefinisikan kumpulan dari method. Tipe apa pun yang mengimplementasikan semua method tersebut berarti mengimplementasikan interface tersebut.

\

(Catatan penulis: dari gambar di atas, cara pandang umum dari interface yaitu tipe P, Q, dan R mengimplementasikan interface).

Namun cara lain memandang hal ini yaitu menyatakan bahwa interface mendefinisikan kumpulan tipe, yaitu tipe-tipe yang mengimplementasikan method-method tersebut. Dari perspektif ini, tipe apa pun yang merupakan elemen dari kumpulan tipe interface mengimplementasikan interface tersebut.

\

(Catatan penulis: dari gambar di atas, cara pandang lain dari interface yaitu tipe P, Q dan R adalah kumpulan tipe dari interface).

Dua cara pandang ini mengarah ke hasil yang sama: Untuk setiap kumpulan method kita dapat bayangkan korespondensi kumpulan tipe yang mengimplementasikan kumpulan method tersebut, yaitu kumpulan dari tipe yang didefinisikan oleh interface.

Untuk tujuan ini, cara pandang terhadap kumpulan tipe memiliki kelebihan dibandingkan cara pandang terhadap kumpulan method: kita dapat secara eksplisit menambah tipe ke dalam sebuah kumpulan, dan hal ini mengontrol kumpulan tipe dengan cara yang baru.

Kami telah mengembangkan sintaksis untuk tipe interface supaya hal ini bekerja. Misalnya, interface{ int|string|bool } mendefinisikan kumpulan tipe yang berisi tipe int, string, dan bool.

\

Cara lain dari menyebut hal di atas yaitu interface tersebut dipenuhi hanya oleh int, string, atau bool.

Sekarang mari kita lihat definisi dari constrains.Ordered:

type Ordered interface {
	Integer|Float|~string
}

Deklarasi tersebut menyatakan bahwa interface Ordered adalah kumpulan dari semua tipe integer, float, dan string. Simbol baris vertikal (pipa) mengekspresikan union dari tipe (atau sekumpulan dari tipe pada kasus ini). Integer dan Float adalah tipe interface yang juga didefinisikan di dalam paket constrains. Ingatlah bahwa tidak ada method yang didefinisikan oleh interface Ordered.

Untuk batasan tipe kita tidak memperdulikan tipe tertentu, seperti string; kita lebih tertarik dengan semua tipe string. Itulah guna dari token ~. Ekspresi dari ~string artinya kumpulan dari semua tipe yang tipe dasarnya adalah string. Termasuk tipe string itu sendiri sebagaimana juga semua tipe yang dideklarasikan dengan definisi seperti type MyString string.

Tentu saja kita masih ingin menspesifikasikan method di dalam interface, dan kita masih ingin tetap menjaga kompatibilitas terbelakang. Dalam Go 1.18 sebuah interface bisa berisi sekumpulan method dan menanam interface seperti sebelumnya, namun ia juga bisa menanam tipe-tipe non-interface, union, dan sekumpulan tipe-tipe dasar.

Saat digunakan sebagai batasan tipe, kumpulan tipe yang didefinisikan oleh sebuah interface menspesifikasikan tipe-tipe apa saja yang dibolehkan sebagai argumen tipe terhadap parameter tipe. Di dalam badan fungsi generik, jika tipe dari sebuah operan adalah parameter tipe P dengan batasan C, operasi akan dibolehkan jika semua tipe dalam kumpulan tipe dari C membolehkan operasi tersebut (saat ini ada beberapa batasan implementasi, namun kode pada umumnya akan jarang menemukan batasan tersebut).

Interface yang digunakan sebagai batasan bisa diberi nama (seperti Ordered), atau bisa berupa interface literal sebaris di dalam daftar parameter tipe. Misalnya:

[S interface{~[]E}, E interface{}]

Di sini, S haruslah tipe slice yang elemen-nya bisa tipe apa saja (interface{}).

Karena kasus ini umum, maka interface{} dapat diabaikan pada saat penulisan batasan, sehingga menjadi lebih sederhana seperti:

[S ~[]E, E interface{}]

Karena interface kosong sangat umum dalam daftar parameter tipe, dan juga di dalam kode Go, Go 1.18 memperkenalkan identifikasi baru any sebagai alias dari tipe interface kosong. Dengan ini, kita dapat menulis kode lebih sederhana dan idiomatis:

[S ~[]E, E any]

Interface sebagai kumpulan tipe adalah sebuah mekanisme baru yang sangat berguna dan merupakan kunci untuk membuat batasan tipe bekerja dalam Go. Untuk saat sekarang, interface yang menggunakan bentuk sintaksis yang baru hanya bisa digunakan sebagai batasan saja.

Inferensi tipe

Fitur baru dari bahasa Go yang terakhir yaitu inferensi tipe. Ini adalah perubahan paling kompleks pada bahasa Go, namun sangat penting karena ia memudahkan pengguna saat menulis kode menggunakan fungsi generik.

Inferensi tipe pada argumen fungsi

Dengan adanya parameter tipe maka dibutuhkan pengiriman argumen tipe, yang membuat kode lebih panjang. Kembali ke fungsi generik sebelumnya GMin:

func GMin[T constraints.Ordered](x, y T) T { ... }

parameter tipe T digunakan untuk menentukan tipe dari argumen x dan y. Seperti yang kita lihat sebelumnya, fungsi ini bisa dipanggil dengan secara eksplisit menulis argumen tipe:

var a, b, m float64

m = GMin[float64](a, b) // argumen tipe eksplisit: [float64].

Pada banyak kasus, compiler dapat menurunkan argumen tipe untuk T dari argumen-argumen fungsi. Hal ini membuat kode lebih singkat dan jelas.

var a, b, m float64

m = GMin(a, b) // tidak ada argumen tipe.

Hal ini bekerja dengan menyamakan tipe-tipe dari argumen a dan b dengan tipe-tipe dari parameter x dan y.

Jenis inferensi ini, yang menurunkan argumen tipe dari tipe-tipe argumen pada fungsi, disebut dengan inferensi tipe pada argumen fungsi.

Inferensi tipe pada argumen fungsi hanya bekerja untuk parameter tipe yang digunakan dalam parameter fungsi, tidak untuk parameter tipe yang digunakan pada kembalian fungsi atau hanya di dalam badan fungsi. Contohnya, ia tidak berlaku untuk fungsi seperti MakeT[T any]() T, yang hanya menggunakan T sebagai tipe kembalian.

Inferensi tipe batasan

Bahasa Go mendukung jenis inferensi lain, inferensi tipe batasan. Untuk menjelaskan hal ini, mari kita mulai dengan contoh berikut yang mengembangkan sebuah slice dari integer:

// Scale mengembalikan salinan dari s dengan setiap elemen dikalikan
// dengan c.
// Implementasi ini memiliki masalah, yang akan kita lihat nanti.
func Scale[E constraints.Integer](s []E, c E) []E {
	r := make([]E, len(s))
	for i, v := range s {
		r[i] = v * c
	}
	return r
}

Fungsi generik di atas bekerja untuk sebuah slice dari tipe integer apa pun.

Anggap kita memiliki tipe Point, yang setiap Point adalah daftar nilai integer yang merupakan koordinat dari suatu titik. Biasanya tipe seperti ini akan memiliki beberapa method.

type Point []int32

func (p Point) String() string {
	// Badan fungsi ...
}

Anggaplah kita ingin men-Scale sebuah Point. Secara Point adalah slice dari integer, kita dapat menggunakan fungsi Scale yang kita punya sebelumnya:

// ScaleAndPrint kali dua setiap nilai pada Point dan cetak.
func ScaleAndPrint(p Point) {
	r := Scale(p, 2)
	fmt.Println(r.String()) // GAGAL KOMPILASI!
}

Sayangnya kode tersebut gagal kompilasi, dengan galat seperti “r.String undefined (type []int32 has no field or method String)”.

Masalahnya adalah fungsi Scale mengembalikan sebuah nilai bertipe []E yang mana E adalah tipe elemen dari argumen slice. Saat kita memanggil Scale dengan nilai dari tipe Point, yang tipe dasarnya adalah []int32, kita mendapatkan nilai kembalian bertipe []int32 bukan Point. Hal ini memang sesuai dengan cara menulis kode generik, namun bukan yang kita inginkan.

Untuk memperbaiki masalah ini, kita harus mengubah fungsi Scale menggunakan sebuah parameter tipe untuk tipe slice.

// Scale mengembalikan salinan s dengan setiap elemen dikalikan dengan
// c.
func Scale[S ~[]E, E constraints.Integer](s S, c E) S {
	r := make(S, len(s))
	for i, v := range s {
		r[i] = v * c
	}
	return r
}

Kita menambahkan sebuah parameter tipe baru S yaitu tipe dengan argumen slice. Kita telah membatasi parameter tipe tersebut sehingga tipe dasar adalah S bukan lagi []E, dan tipe kembalian sekarang menjadi S. Secara E dibatasi sebagai integer, efeknya sama dengan sebelumnya: argumen pertama haruslah slice dengan tipe integer. Perubahan pada badan fungsi hanya pada saat pemanggilan make, yang sebelumnya []E menjadi S.

Fungsi Scale yang baru bekerja seperti sebelumnya bila kita memanggilnya dengan slice biasa, namun bila kita panggil dengan tipe Point kita mendapatkan kembalian bertipe Point juga. Inilah yang kita inginkan. Dengan versi Scale yang baru, fungsi ScaleAndPrint sebelumnya dapat dikompilasi dan berjalan seperti yang kita inginkan.

Anda mungkin bertanya: kenapa boleh menulis pemanggilan Scale tanpa mengirim argumen tipe secara eksplisit? Dengan kata lain, kenapa kita dapat menulis Scale(p, 2), tanpa argumen tipe, bukan dengan menulis Scale[Point, int32](p, 2)? Fungsi Scale kita yang baru memiliki dua parameter tipe, S dan E. Saat memanggil Scale tanpa mengirim argumen tipe, inferensi tipe pada argumen fungsi terjadi, seperti yang telah dijelaskan sebelumnya, compiler menurunkan bahwa argumen tipe untuk S adalah Point. Namun fungsi tersebut juga memiliki parameter tipe E yang mana merupakan tipe dari parameter kedua c. Nilai parameter dari c adalah 2, dan karena 2 adalah konstanta tak bertipe, inferensi tipe pada argumen fungsi tidak dapat menurunkan tipe yang tepat untuk E (paling tidak compiler bisa menurunkan tipe baku dari 2 yang mana int dan pada hal ini tidak benar). Proses di mana compiler menurunkan argumen tipe untuk E adalah tipe elemen dari slice disebut dengan inferensi tipe batasan.

Inferensi tipe batasan men-deduksi argumen tipe dari batasan parameter tipe. Inferensi tipe batasan digunakan saat parameter tipe memiliki sebuah batasan yang didefinisikan oleh parameter tipe lainnya. Saat argumen tipe dari salah satu tipe parameter tipe diketahui, maka batasan akan digunakan untuk menurunkan argumen tipe lainnya.

Kasus umum yang mana hal ini dapat dipakai yaitu saat salah satu batasan tipe menggunakan bentuk ~type, yang mana type tersebut ditulis menggunakan parameter tipe lainnya. Kita melihat hal ini dipakai pada contoh Scale. S adalah ~[]E, yaitu ~ diikuti oleh tipe []E ditulis dengan menggunakan parameter tipe lainnya. Jika kita mengetahui argumen tipe untuk S kita dapat menurunkan argumen tipe untuk E. S adalah tipe slice, dan E adalah tipe elemen dari slice tersebut.

Hal ini adalah pengenalan dari inferensi tipe batasan. Untuk lebih lengkapnya lihat dokumen proposal atau spesifikasi bahasa.

Inferensi tipe di dunia nyata

Penjelasan rinci tentang bagaimana inferensi tipe bekerja sangatlah kompleks, namun penggunaan-nya tidak: inferensi tipe bisa sukses, bisa gagal. Jika sukses, argumen tipe dapat diabaikan, dan memanggil fungsi generik tidak ada bedanya dengan memanggil fungsi seperti biasa. Jika inferensi tipe gagal, compiler akan menampilkan pesan kesalahan, dan pada kasus tersebut kita dapat menyediakan argumen tipe yang diperlukan.

Pada saat menambahkan inferensi tipe ke dalam bahasa, kami telah mencoba menyeimbangkan antara kompleksitas dan keuntungan dari inferensi tipe. Kami ingin memastikan bahwa saat compiler menurunkan tipe, tipe-tipe tersebut akan terdeteksi. Kami mencoba berhati-hati, lebih memilih supaya gagal menurunkan tipe daripada memilih menurunkan tipe yang salah. Kami mungkin belum benar sepenuhnya, dan kami terus memperbaiki di setiap rilis selanjutnya. Efeknya adalah makin banyak program yang dapat ditulis tanpa argumen tipe yang eksplisit. Program yang tidak membutuhkan argumen tipe pada saat ini, tidak akan membutuhkan-nya di kemudian hari juga.

Kesimpulan

Generik adalah fitur baru dalam bahasa Go 1.18. Perubahan yang baru tersebut membutuhkan begitu banyak kode baru yang belum sepenuhnya diuji dalam lingkungan production. Hal itu akan terjadi saat makin banyak orang menulis dan menggunakan kode generik. Kami percaya bahwa fitur ini diimplementasikan dengan benar dan dengan kualitas tinggi. Namun, tidak seperti kebanyakan aspek pada Go, kita tidak dapat membuktikan kepercayaan kita dengan pengalaman di dunia nyata. Oleh karena itu, kita mendorong penggunaan generik bila dibutuhkan, namun perhatikan lebih seksama saat merilis kode generik ke production.

Terlepas dari peringatan tersebut, kami sangat gembira dengan adanya generik, dan kami harap ia membuat pemrograman Go lebih produktif.