BAB 54: Generics — Fungsi dan Tipe yang Fleksibel

Pelajari cara menulis fungsi dan tipe generik di Go menggunakan type parameter dan type constraint untuk menghilangkan duplikasi kode tanpa mengorbankan keamanan tipe.

Ebook ini sudah membawa KontenKu cukup jauh — dari program sederhana, ke concurrency pipeline, lalu ke web service dan database. Di sepanjang perjalanan itu, mungkin kamu pernah menulis fungsi yang hampir identik dua kali karena tipe datanya berbeda. Satu untuk int64, satu untuk float64. Satu untuk slice of string, satu untuk slice of int. Duplikasi seperti ini bukan hanya melelahkan — ini juga jadi sumber bug ketika perbaikan dilakukan di satu tempat tapi lupa di tempat lain.

Go 1.18 menghadirkan solusinya: generics. Dengan generics, kamu bisa menulis satu fungsi atau satu tipe yang bekerja untuk berbagai tipe data sekaligus, tanpa melepaskan jaminan keamanan tipe yang sudah kamu nikmati selama ini.

Masalah Sebelum Generics

KontenKu punya sistem laporan statistik — menghitung total, rata-rata, dan aggregasi dari berbagai metrik. Sebelum generics, kamu mungkin punya kode seperti ini:

// statistik.go — sebelum generics

func totalPengunjungInt(data []int) int {
    total := 0
    for _, v := range data {
        total += v
    }
    return total
}

func totalPengunjungFloat(data []float64) float64 {
    total := 0.0
    for _, v := range data {
        total += v
    }
    return total
}

Logikanya identik. Satu-satunya perbedaan adalah tipe data. Kalau nanti ada kebutuhan untuk menghitung total dari int32 atau int64, kamu harus menambah fungsi baru lagi. Ini tidak skalabel.

Solusi lama menggunakan interface{} memang bisa menghilangkan duplikasi, tapi dengan harga yang mahal: type assertion manual di dalam fungsi, risiko runtime panic, dan hilangnya keamanan tipe saat kompilasi.

Type Parameter: Tipe sebagai Argumen

Generics memperkenalkan konsep type parameter — sebuah placeholder tipe yang ditentukan saat fungsi dipanggil, bukan saat fungsi ditulis. Sintaksnya menggunakan kurung siku [] sebelum kurung parameter biasa:

func namaFungsi[T Constraint](params) ReturnType

T adalah nama type parameter (konvensi: huruf kapital satu karakter, tapi nama deskriptif juga valid). Constraint menentukan tipe apa saja yang boleh mengisi T.

Constraint: Batasan Tipe yang Aman

Constraint dideklarasikan sebagai interface. Ini adalah perluasan dari sistem interface Go yang sudah kamu kenal — bedanya, interface biasa mendefinisikan method, sedangkan constraint bisa mendefinisikan tipe konkret menggunakan operator |.

Untuk kebutuhan statistik KontenKu, definisikan constraint untuk semua tipe numerik:

// statistik.go

package main

// Numerik adalah constraint untuk semua tipe bilangan yang didukung laporan
type Numerik interface {
    int | int32 | int64 | float32 | float64
}

Sekarang tulis satu fungsi yang bekerja untuk semua tipe tersebut:

// statistik.go (lanjutan)

func totalMetrik[T Numerik](data []T) T {
    var hasil T
    for _, v := range data {
        hasil += v
    }
    return hasil
}

Fungsi totalMetrik bisa dipanggil dengan slice tipe apapun yang memenuhi constraint Numerik:

func main() {
    harianbaru := []int{120, 345, 210, 480}
    ratarating := []float64{4.5, 3.8, 5.0, 4.2}

    fmt.Println(totalMetrik(harianbaru))  // 1155
    fmt.Println(totalMetrik(ratarating)) // 17.5
}

Go menyimpulkan tipe parameter secara otomatis dari argumen yang diberikan. Kamu tidak perlu menulis totalMetrik[int](harianbaru) — cukup totalMetrik(harianbaru) dan compiler tahu apa yang dimaksud.

Type inference bekerja ketika tipe bisa disimpulkan dari argumen fungsi. Jika fungsi generik tidak punya parameter yang cukup untuk disimpulkan, kamu harus menuliskan tipe secara eksplisit: totalMetrik[int](harianbaru).

Comparable: Constraint untuk Perbandingan

Go menyediakan constraint bawaan bernama comparable — mewakili semua tipe yang bisa dibandingkan menggunakan == dan !=. Tipe ini wajib ketika type parameter digunakan sebagai key map.

KontenKu perlu fungsi untuk menghitung kemunculan tiap nilai dalam slice — misalnya menghitung berapa kali setiap kategori artikel muncul di log akses:

// statistik.go (lanjutan)

// hitungFrekuensi mengembalikan map berisi jumlah kemunculan tiap elemen
func hitungFrekuensi[T comparable](items []T) map[T]int {
    frekuensi := make(map[T]int)
    for _, item := range items {
        frekuensi[item]++
    }
    return frekuensi
}

Fungsi ini bisa digunakan untuk slice string maupun slice int, selama elemennya bisa dibandingkan:

func main() {
    kategori := []string{"teknologi", "bisnis", "teknologi", "sains", "bisnis", "teknologi"}
    fmt.Println(hitungFrekuensi(kategori))
    // map[bisnis:2 sains:1 teknologi:3]

    statusKode := []int{200, 404, 200, 500, 200, 404}
    fmt.Println(hitungFrekuensi(statusKode))
    // map[200:3 404:2 500:1]
}

Dengan satu fungsi, dua kebutuhan berbeda terlayani — tanpa duplikasi, tanpa type assertion.

Generic Type: Struct yang Fleksibel

Generics tidak terbatas pada fungsi. Tipe data — termasuk struct — juga bisa memiliki type parameter.

KontenKu membutuhkan struktur antrian (queue) generik untuk berbagai keperluan: antrian task background, antrian notifikasi, atau antrian event. Daripada membuat struct terpisah untuk tiap tipe data, satu generic struct sudah cukup:

// antrian.go

package main

// Antrian adalah struktur antrian FIFO yang bekerja untuk tipe apapun
type Antrian[T any] struct {
    items []T
}

func (a *Antrian[T]) Masuk(item T) {
    a.items = append(a.items, item)
}

func (a *Antrian[T]) Keluar() (T, bool) {
    if len(a.items) == 0 {
        var kosong T
        return kosong, false
    }
    item := a.items[0]
    a.items = a.items[1:]
    return item, true
}

func (a *Antrian[T]) Panjang() int {
    return len(a.items)
}

Constraint any adalah alias dari interface{} — berarti tipe apapun diterima. Gunakan any ketika tidak ada operasi spesifik yang perlu dijamin oleh constraint.

Perhatikan cara method dideklarasikan pada generic struct: receiver-nya adalah *Antrian[T], bukan *Antrian. Type parameter T harus disertakan di receiver supaya Go tahu bahwa method ini adalah bagian dari tipe generik.

func main() {
    // Antrian untuk task background (string)
    taskQueue := &Antrian[string]{}
    taskQueue.Masuk("kirim-email-digest")
    taskQueue.Masuk("generate-laporan-bulanan")
    taskQueue.Masuk("backup-database")

    for taskQueue.Panjang() > 0 {
        task, _ := taskQueue.Keluar()
        fmt.Println("Memproses:", task)
    }
    // Memproses: kirim-email-digest
    // Memproses: generate-laporan-bulanan
    // Memproses: backup-database

    // Antrian yang sama untuk notifikasi (int — ID notifikasi)
    notifQueue := &Antrian[int]{}
    notifQueue.Masuk(1001)
    notifQueue.Masuk(1002)
    fmt.Println("Notifikasi pending:", notifQueue.Panjang()) // 2
}

Constraint dengan Tilde: Tipe Turunan

Terkadang kamu ingin constraint yang menerima bukan hanya tipe dasar, tapi juga tipe yang didasarkan pada tipe tersebut. Operator ~ (tilde) melakukan hal ini.

Misalnya KontenKu punya tipe Skor yang didasarkan pada int:

type Skor int

Tanpa ~, constraint int | float64 menolak Skor karena Skor bukan int — meskipun underlying type-nya sama. Dengan ~:

type Numerik interface {
    ~int | ~int32 | ~int64 | ~float32 | ~float64
}

Sekarang Skor diterima karena underlying type-nya int, dan ~int mencakup semua tipe dengan underlying type int.

func main() {
    skor := []Skor{85, 92, 78, 95, 88}
    fmt.Println(totalMetrik(skor)) // 438
}

Gunakan ~ secara konsisten di constraint yang kamu buat sendiri. Ini membuat constraint lebih fleksibel dan mengakomodasi tipe-tipe custom yang didasarkan pada tipe dasar — situasi yang sering muncul di codebase yang sudah besar.

Keterbatasan Generics di Go

Ada satu batasan yang perlu kamu ketahui: type parameter tidak bisa ditambahkan ke method secara langsung. Hanya fungsi standalone dan struct (beserta method-nya) yang bisa punya type parameter.

// TIDAK VALID — method tidak bisa punya type parameter sendiri
func (a *Antrian[T]) Filter[U any](fn func(T) U) []U { ... }

// VALID — pindahkan ke fungsi standalone
func FilterAntrian[T any, U any](a *Antrian[T], fn func(T) U) []U { ... }

Ini adalah keputusan desain yang disengaja oleh tim Go untuk menjaga implementasi tetap sederhana. Jika butuh transformasi generik pada sebuah struct, tulis sebagai fungsi biasa dengan struct tersebut sebagai argumen.

Latihan

Latihan 1 — Fungsi max generik: Tulis fungsi maks[T Numerik](a, b T) T yang mengembalikan nilai terbesar dari dua argumen. Tambahkan Numerik constraint dengan ~ untuk mendukung tipe custom. Uji dengan int, float64, dan tipe custom Durasi yang didasarkan pada int64.

Latihan 2 — Stack generik: Buat struct Tumpukan[T any] yang mengimplementasikan operasi push (Dorong) dan pop (Ambil). Berbeda dari Antrian yang FIFO, Tumpukan adalah LIFO — elemen terakhir masuk adalah yang pertama keluar. Uji dengan string dan int.

Latihan 3 — Filter slice: Tulis fungsi generik saring[T any](items []T, kondisi func(T) bool) []T yang mengembalikan elemen-elemen dari items yang memenuhi kondisi. Gunakan untuk memfilter slice artikel berdasarkan jumlah kata minimum, dan untuk memfilter slice angka berdasarkan nilai ambang batas.


Generics menyelesaikan masalah nyata yang pernah kamu alami saat menulis kode Go: duplikasi fungsi karena perbedaan tipe, atau terpaksa menggunakan interface{} dan kehilangan keamanan tipe. Dengan type parameter dan constraint, kode bisa lebih ringkas tanpa mengorbankan apa yang membuat Go menyenangkan untuk digunakan — kejelasan dan keamanan tipe yang diperiksa saat kompilasi, bukan saat runtime.

Fondasi yang sudah kamu bangun di ebook ini — mulai dari variabel sederhana, goroutine, channel, web server, database, sampai generics — adalah representasi Go yang sesungguhnya sebagai bahasa sistem modern. Eksplorasi selanjutnya yang natural adalah golang.org/x/exp/slices dan golang.org/x/exp/maps, dua package eksperimental yang menggunakan generics secara ekstensif dan menjadi inspirasi untuk fungsi-fungsi yang kini masuk ke standard library di Go 1.21.

Referensi

  1. 1Tutorial: Getting started with generics — The Go Programming Language
  2. 2An Introduction To Generics — The Go Blog
  3. 3Type Parameter Declarations — The Go Programming Language Specification