Go Struct dan Interface
Definisi singkat
Di Go, struct, interface, pointer, dan method sering dipakai bareng karena mereka membentuk cara kita memodelkan data dan perilakunya.
- struct: tipe data untuk mengelompokkan beberapa field dalam satu nilai
- pointer: alamat memori ke sebuah nilai, dipakai saat kita perlu berbagi atau memodifikasi nilai yang sama
- method: function yang “menempel” pada sebuah tipe
- interface: kontrak perilaku yang dipenuhi secara implisit oleh tipe yang punya method yang cocok
Kalau dibaca sebagai satu sistem, struct itu bentuk datanya, method itu perilaku, pointer itu cara mengakses/mengubah nilai, dan interface itu batas decoupling.
Kapan pakai struct
Pakai struct saat data punya beberapa atribut yang memang satu unit.
Contoh:
type User struct {
ID int
Name string
Email string
}struct cocok untuk:
- entitas domain:
User,Order,Invoice - konfigurasi:
Config,Options - payload request/response
- state internal yang perlu dikelompokkan
Kapan tidak perlu struct
Kalau data cuma satu nilai sederhana dan tidak punya makna gabungan, jangan dipaksakan jadi struct. Misalnya integer count, boolean flag, atau string tunggal sering lebih jelas sebagai nilai biasa.
Method di Go
Method adalah function yang punya receiver.
func (u User) DisplayName() string {
return u.Name
}Receiver menentukan tipe yang “memiliki” method itu. Karena method terpasang pada tipe, method set ini sangat penting saat kita pakai interface.
Value receiver vs pointer receiver
Ini bagian yang paling sering bikin bingung, dan paling sering jadi sumber bug yang diam-diam.
Value receiver
func (u User) DisplayName() string {
return u.Name
}Value receiver menerima salinan nilai. Cocok kalau:
- method hanya membaca data
- struct kecil dan murah disalin
- tidak perlu mengubah field asli
- ingin semantik yang jelas bahwa method tidak mengubah receiver
Pointer receiver
func (u *User) Rename(name string) {
u.Name = name
}Pointer receiver menerima alamat nilai asli. Cocok kalau:
- method mengubah field receiver
- struct besar dan ingin menghindari copy
- tipe punya mutex, slice header, map, atau state yang tidak ingin disalin sembarangan
- konsistensi method set lebih penting daripada nilai copy
Aturan praktis memilih receiver
Pakai pointer receiver kalau salah satu dari ini benar:
- method memodifikasi state
- struct cukup besar
- tipe punya invariant internal yang harus dijaga lewat mutasi
- kamu ingin semua method pada tipe itu konsisten memakai pointer receiver
Pakai value receiver kalau:
- method benar-benar read-only
- tipe kecil dan sederhana
- kamu ingin method tetap bisa dipanggil pada nilai non-pointer tanpa efek samping
Aturan konsistensi yang aman
Kalau sebuah tipe punya beberapa method dan sebagian besar perlu pointer receiver, biasanya lebih aman membuat semua method receiver-nya pointer. Ini menghindari campuran method set yang membingungkan saat tipe itu dipakai sebagai interface.
Zero value
Di Go, banyak tipe dirancang supaya zero value-nya langsung berguna.
Contoh:
var u User
fmt.Println(u.Name) // string kosong, bukan panicNamun zero value yang “aman” tidak selalu berarti zero value yang “siap pakai” untuk semua operasi.
Contoh zero value yang aman
int→0string→""bool→falseslice→nilslice yang bisa di-appendmap→ nil, tapi belum bisa di-assign sebelummakestruct→ field-field default zero value
Jebakan zero value
map nil tidak bisa langsung ditulis
var m map[string]int
m["a"] = 1 // panicHarus diinisialisasi dulu:
m := make(map[string]int)pointer nil bisa panic saat dereference
var u *User
fmt.Println(u.Name) // panicstruct zero value belum tentu valid secara domain
Misalnya Config{} mungkin secara sintaks valid, tapi secara bisnis bisa belum lengkap karena field wajib masih kosong.
Prinsip praktis
Kalau kamu mendesain tipe, usahakan zero value-nya:
- aman dipakai, atau
- jelas tidak boleh dipakai tanpa constructor/helper
Kalau zero value tidak valid, dokumentasikan dengan tegas dan sediakan constructor.
Interface di Go
Interface mendefinisikan perilaku, bukan bentuk konkret.
type Notifier interface {
Notify(message string) error
}Tipe mana pun yang punya method Notify(string) error otomatis memenuhi interface ini, tanpa deklarasi eksplisit.
Itu membuat interface sangat cocok untuk decoupling.
Interface untuk decoupling
Decoupling artinya kode kita bergantung pada perilaku, bukan implementasi spesifik.
Contoh:
type Notifier interface {
Notify(message string) error
}
type Service struct {
notifier Notifier
}
func NewService(n Notifier) *Service {
return &Service{notifier: n}
}
func (s *Service) Run() error {
return s.notifier.Notify("job started")
}Dengan pola ini, Service tidak peduli apakah notifikasinya lewat email, Slack, Telegram, atau mock untuk testing.
Manfaat decoupling lewat interface
- implementasi bisa diganti tanpa ubah pemakai
- testing lebih mudah karena mock/fake bisa disuntikkan
- dependency jadi lebih kecil dan lebih stabil
- batas tanggung jawab lebih jelas
Kapan interface bagus
Pakai interface saat:
- ada lebih dari satu implementasi yang masuk akal
- ingin menulis test tanpa dependency konkret yang berat
- boundary antar-layer perlu dijaga
- kamu ingin depend on behavior, not concrete type
Kapan jangan buru-buru pakai interface
Jangan bikin interface terlalu cepat kalau:
- hanya ada satu implementasi dan belum ada kebutuhan variasi
- interface cuma dipakai di satu tempat tanpa manfaat nyata
- nama method terlalu umum sehingga kontraknya kabur
Interface yang terlalu dini sering bikin kode lebih banyak, tapi tidak lebih jelas.
Method set dan jebakan umum
Method set menentukan method apa saja yang dimiliki suatu tipe untuk keperluan interface satisfaction.
Aturannya penting:
- method dengan receiver
Tmasuk ke method set dariT - method dengan receiver
*Tmasuk ke method set dari*T *Tpunya method set yang lebih luas daripadaT
Dampak praktis
Kalau interface butuh method yang hanya ada di pointer receiver, maka nilai T biasa tidak memenuhi interface itu.
Contoh:
type Greeter interface {
Greet() string
}
type Person struct {
Name string
}
func (p *Person) Greet() string {
return "Halo, " + p.Name
}Di sini:
*PersonmemenuhiGreeterPersontidak memenuhiGreeter
Jebakan umum 1: nilai vs pointer saat assign ke interface
var g Greeter
p := Person{Name: "Dina"}
// g = p // tidak bisa kalau Greet hanya ada di *Person
g = &p // bisaIni sering muncul saat kode terlihat “punya method”, tetapi ternyata yang punya method itu pointer-nya.
Jebakan umum 2: method set berubah saat tipe di-embed
Embedding bisa memperluas method yang terlihat, tetapi arah pointer/value tetap penting. Saat interface gagal terpenuhi, cek receiver method-nya dulu, bukan cuma nama method-nya.
Jebakan umum 3: nil interface vs interface berisi nil pointer
Ini salah satu sumber bug paling licin di Go.
var p *Person = nil
var g Greeter = pg di sini bukan nil interface, karena interface-nya berisi tipe *Person dan nilai nil. Akibatnya pengecekan g == nil bisa false walau isi konkretnya nil.
Praktiknya:
- cek nil interface dengan hati-hati
- pahami apakah yang nil itu interface-nya atau nilai konkret di dalamnya
Kapan pakai pointer receiver vs value receiver: ringkasan cepat
Pilih pointer receiver kalau
- method mengubah state
- struct besar atau sensitif terhadap copy
- ingin satu konsistensi method set untuk tipe itu
- tipe harus kompatibel dengan interface yang method-nya pointer-only
Pilih value receiver kalau
- method read-only
- tipe kecil dan murah disalin
- ingin nilai asli tetap immutable dari sudut pandang method
- method tersebut memang logis bekerja pada salinan
Aturan praktis yang paling aman
Kalau ragu, tanya tiga hal:
- Apakah method perlu mengubah receiver?
- Apakah copy nilai ini mahal atau berisiko?
- Apakah tipe ini akan dipakai lewat interface?
Kalau jawabannya ya untuk nomor 1 atau 2, pointer receiver biasanya pilihan yang lebih aman.
Contoh kecil: struct, method, pointer, dan interface
type Counter struct {
n int
}
func (c *Counter) Inc() {
c.n++
}
func (c Counter) Value() int {
return c.n
}
type ValueReader interface {
Value() int
}Keterangan:
Inc()perlu pointer receiver karena mutasi stateValue()cukup value receiver karena hanya bacaCounterdan*Countersama-sama bisa dipakai sebagaiValueReaderkarenaValue()ada di method set keduanya
Contoh pemakaian:
c := Counter{}
c.Inc()
fmt.Println(c.Value())
var r ValueReader = c
fmt.Println(r.Value())Pola baca cepat saat debugging
Kalau kode Go terasa “aneh”, biasanya urutannya begini:
- cek receiver method-nya value atau pointer
- cek nilai yang dipassing ke interface itu
Tatau*T - cek zero value tipe itu valid atau belum
- cek apakah yang nil itu interface atau nilai konkret di dalamnya
- cek apakah ada copy struct yang tidak disengaja
Catatan implementasi
- jangan campur receiver value dan pointer tanpa alasan yang jelas
- hindari interface yang terlalu gemuk; fokus pada behavior kecil yang dipakai nyata
- dokumentasikan constructor kalau zero value tidak valid
- berhati-hati saat menyimpan pointer ke data yang lifecycle-nya pendek
- bila tipe dipakai lintas boundary, pikirkan dulu kontrak method-nya sebelum menulis implementasi
Kesalahan umum
- mengira semua method otomatis dimiliki nilai dan pointer sekaligus
- memanggil method pointer receiver pada nilai yang tidak addressable lalu bingung saat dipassing ke interface
- menganggap nil interface sama dengan interface yang memegang nil pointer
- lupa inisialisasi map sebelum write
- membuat interface hanya karena “terlihat Go-ish”, bukan karena ada kebutuhan decoupling
- memakai pointer receiver untuk semua hal tanpa alasan, lalu kehilangan kemudahan pemakaian
Referensi terkait
- PHP OOP: Class, Object, Namespace, Autoloading, Traits, dan Visibility — untuk perbandingan model OOP yang lebih eksplisit
- Service Container, Dependency Injection, Facades, dan Service Providers di Laravel — untuk analogi dependency injection dan boundary
- Error Handling di JavaScript: try/catch, throw, Custom Error, dan Debugging Mindset — untuk pola debugging yang mirip dalam cara membaca alur error