Kodeloka
  1. Kodeloka
  2. Kelas Golang
  3. Materi

Domain 8 ยท 4 bab

Go Modern (1.21 - 1.26)

115. Iterator & range-over-func (Go 1.23)

Menulis iterator kustom yang bisa dipakai langsung oleh perulangan range, memakai tipe iter.Seq dan iter.Seq2.

Masalah Sebelum Go 1.23

Sebelum Go 1.23, tidak ada cara seragam untuk mengiterasi struktur data buatan sendiri. Setiap pustaka mengarang polanya masing-masing: ada yang mengembalikan slice (boros memori), ada yang memakai channel (lambat dan rawan bocor goroutine), ada yang memakai objek Next() bergaya Java.

Tiga pola lama yang saling tidak kompatibel
// 1. Kembalikan slice -- seluruh data harus muat di memori
func (t *Tree) Semua() []int { ... }

// 2. Channel -- lambat, dan bocor bila konsumen berhenti di tengah
func (t *Tree) Iterasi() <-chan int { ... }

// 3. Objek iterator -- verbose
it := t.Iterator()
for it.Next() { gunakan(it.Value()) }

range-over-func

Go 1.23 memperkenalkan kemampuan range untuk mengiterasi fungsi. Paket iter mendefinisikan dua tipe bakunya:

package iter

type Seq[V any]      func(yield func(V) bool)
type Seq2[K, V any]  func(yield func(K, V) bool)

Kuncinya ada pada nilai balik bool dari yield. Bila konsumen menghentikan perulangan (lewat break, return, atau panic), yield mengembalikan false dan iterator wajib berhenti. Inilah yang membuat pembersihan sumber daya menjadi benar secara otomatis.

Menulis iterator sendiri
import "iter"

type Pohon struct {
    Nilai       int
    Kiri, Kanan *Pohon
}

// Penelusuran in-order sebagai iterator
func (p *Pohon) Semua() iter.Seq[int] {
    return func(yield func(int) bool) {
        p.telusuri(yield)
    }
}

func (p *Pohon) telusuri(yield func(int) bool) bool {
    if p == nil {
        return true
    }
    return p.Kiri.telusuri(yield) &&
           yield(p.Nilai) &&
           p.Kanan.telusuri(yield)
}

// Dipakai persis seperti mengiterasi slice:
for nilai := range pohon.Semua() {
    fmt.Println(nilai)
    if nilai > 100 {
        break     // iterator berhenti dengan rapi
    }
}
๐Ÿ“Œ Iterator dengan pembersihan sumber daya

Karena iterator adalah fungsi biasa, defer bekerja seperti biasa. Ini menyelesaikan masalah kebocoran yang dulu menghantui iterator berbasis channel.

func BarisBerkas(nama string) iter.Seq2[string, error] {
    return func(yield func(string, error) bool) {
        f, err := os.Open(nama)
        if err != nil {
            yield("", err)
            return
        }
        defer f.Close()      // SELALU dipanggil, termasuk saat break

        pemindai := bufio.NewScanner(f)
        for pemindai.Scan() {
            if !yield(pemindai.Text(), nil) {
                return       // konsumen berhenti -- defer tetap jalan
            }
        }
        if err := pemindai.Err(); err != nil {
            yield("", err)
        }
    }
}

for baris, err := range BarisBerkas("data.txt") {
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(baris)
}

Fungsi Pembantu di Paket iter

// Mengubah iterator "push" menjadi gaya "pull"
next, stop := iter.Pull(pohon.Semua())
defer stop()

for {
    nilai, ada := next()
    if !ada {
        break
    }
    fmt.Println(nilai)
}
// Berguna untuk menggabungkan dua iterator secara berselang-seling,
// yang mustahil dilakukan dengan gaya push biasa.
โš ๏ธ Jangan lupa memeriksa nilai balik yield

Iterator yang mengabaikan hasil yield akan terus berjalan meski konsumen sudah break. Pada sumber data tak terbatas, ini berarti perulangan tak berujung. Selalu tulis if !yield(v) { return }.

Contoh program

package main

import "fmt"

// Simulasi iter.Seq: fungsi yang menerima yield
type Seq func(yield func(int) bool)

func Angka(dari, sampai int) Seq {
	return func(yield func(int) bool) {
		for i := dari; i <= sampai; i++ {
			if !yield(i) {
				fmt.Println("   (iterator berhenti lebih awal di", i, ")")
				return
			}
		}
	}
}

func main() {
	fmt.Println("=== Iterasi penuh ===")
	Angka(1, 5)(func(n int) bool {
		fmt.Println("  nilai:", n)
		return true
	})

	fmt.Println("\n=== Berhenti saat n > 3 (setara break) ===")
	Angka(1, 10)(func(n int) bool {
		if n > 3 {
			return false
		}
		fmt.Println("  nilai:", n)
		return true
	})
}

Praktik dan kuis bab ini di kelas interaktif โ†’

116. Paket slices, maps & cmp

Menggantikan kode boilerplate manual dengan fungsi generik baku untuk mengurutkan, mencari, membandingkan, dan memanipulasi slice serta map.

Akhir dari Kode Berulang

Sebelum Go 1.21, setiap proyek punya paket utils berisi fungsi yang sama: cari indeks dalam slice, cek keberadaan elemen, ambil semua kunci map. Paket slices dan maps kini menyediakannya secara baku dan generik.

Paket slices

Mengurutkan
import "slices"

angka := []int{5, 2, 8, 1, 9}
slices.Sort(angka)                    // [1 2 5 8 9]

// Urut berdasarkan aturan sendiri
orang := []Orang{{"Budi", 30}, {"Ani", 25}}
slices.SortFunc(orang, func(a, b Orang) int {
    return cmp.Compare(a.Umur, b.Umur)
})

// Versi stabil: elemen yang sama mempertahankan urutan aslinya
slices.SortStableFunc(orang, func(a, b Orang) int {
    return strings.Compare(a.Nama, b.Nama)
})

slices.IsSorted(angka)                // true
Mencari
slices.Contains(angka, 8)             // true
slices.Index(angka, 8)                // 3, atau -1 bila tidak ada

slices.IndexFunc(orang, func(o Orang) bool {
    return o.Umur > 28
})

// Pencarian biner -- HANYA pada slice yang sudah terurut
idx, ketemu := slices.BinarySearch(angka, 5)
Memanipulasi
a := []int{1, 2, 3}
b := []int{4, 5}

gabung := slices.Concat(a, b)         // [1 2 3 4 5]
salinan := slices.Clone(a)            // salinan dangkal
slices.Reverse(a)                     // [3 2 1]

// Buang elemen berurutan yang duplikat (perlu diurutkan dulu)
dedup := slices.Compact([]int{1, 1, 2, 2, 3})   // [1 2 3]

// Hapus indeks 1 sampai sebelum 3
sisa := slices.Delete([]int{1,2,3,4,5}, 1, 3)   // [1 4 5]

// Sisipkan di indeks 2
hasil := slices.Insert([]int{1,2,5}, 2, 3, 4)   // [1 2 3 4 5]

slices.Equal(a, b)                    // perbandingan elemen per elemen
slices.Max(angka)
slices.Min(angka)

Iterator di slices (Go 1.23+)

// Mengiterasi dengan indeks sebagai iter.Seq2
for i, v := range slices.All(angka) { ... }

// Hanya nilainya
for v := range slices.Values(angka) { ... }

// Dari belakang
for i, v := range slices.Backward(angka) { ... }

// Mengumpulkan iterator kembali menjadi slice
hasil := slices.Collect(pohon.Semua())
terurut := slices.Sorted(pohon.Semua())

Paket maps

import "maps"

m := map[string]int{"a": 1, "b": 2, "c": 3}

// Mengembalikan ITERATOR, bukan slice
for k := range maps.Keys(m) { ... }
for v := range maps.Values(m) { ... }

// Ubah menjadi slice terurut -- pola yang sangat sering dipakai
kunci := slices.Sorted(maps.Keys(m))   // [a b c]

salinan := maps.Clone(m)
maps.Copy(tujuan, sumber)
maps.Equal(m1, m2)

// Hapus berdasarkan kondisi
maps.DeleteFunc(m, func(k string, v int) bool {
    return v < 2
})
โš ๏ธ Urutan iterasi map di Go sengaja diacak

Go dengan sengaja mengacak urutan iterasi map agar programmer tidak diam-diam bergantung padanya. Bila Anda butuh keluaran yang dapat direproduksi โ€” misalnya untuk log, JSON, atau uji โ€” selalu urutkan kuncinya dulu dengan slices.Sorted(maps.Keys(m)).

Paket cmp

import "cmp"

cmp.Compare(3, 5)          // -1
cmp.Less("a", "b")         // true

// Mengembalikan nilai bukan-kosong yang pertama
nama := cmp.Or(dariEnv, dariConfig, "bawaan")

// Pengurutan berjenjang: berdasarkan umur, lalu nama
slices.SortFunc(orang, func(a, b Orang) int {
    return cmp.Or(
        cmp.Compare(a.Umur, b.Umur),
        cmp.Compare(a.Nama, b.Nama),
    )
})
๐Ÿ’ก Fungsi bawaan min, max, dan clear

Sejak Go 1.21 tersedia juga tiga fungsi bawaan tanpa perlu import: min(a, b, c), max(a, b), serta clear(m) untuk mengosongkan map atau menolkan seluruh elemen slice.

Contoh program

package main

import (
	"fmt"
	"sort"
)

type Orang struct {
	Nama string
	Umur int
}

func main() {
	angka := []int{5, 2, 8, 1, 9, 2}
	fmt.Println("Asli      :", angka)

	sort.Ints(angka)
	fmt.Println("Terurut   :", angka)

	// Simulasi slices.Compact
	unik := angka[:1]
	for _, v := range angka[1:] {
		if v != unik[len(unik)-1] {
			unik = append(unik, v)
		}
	}
	fmt.Println("Tanpa dup :", unik)

	// min & max bawaan Go 1.21+
	fmt.Println("Min       :", min(5, 2, 8))
	fmt.Println("Max       :", max(5, 2, 8))

	// Map: kunci harus diurutkan agar keluaran konsisten
	m := map[string]int{"c": 3, "a": 1, "b": 2}
	kunci := make([]string, 0, len(m))
	for k := range m {
		kunci = append(kunci, k)
	}
	sort.Strings(kunci)
	fmt.Println("Kunci urut:", kunci)
}

Praktik dan kuis bab ini di kelas interaktif โ†’

117. errgroup & Concurrency Terstruktur

Menjalankan banyak goroutine secara paralel dengan penanganan error, pembatalan otomatis, dan pembatasan jumlah pekerja memakai golang.org/x/sync/errgroup.

Kelemahan sync.WaitGroup

sync.WaitGroup hanya bisa menunggu. Ia tidak tahu apa-apa tentang error, dan tidak bisa membatalkan goroutine lain ketika salah satu gagal.

Pola lama yang bertele-tele
var wg sync.WaitGroup
var mu sync.Mutex
var errPertama error

for _, url := range daftarURL {
    wg.Add(1)
    go func(u string) {
        defer wg.Done()
        if err := ambil(u); err != nil {
            mu.Lock()
            if errPertama == nil {
                errPertama = err
            }
            mu.Unlock()
        }
    }(url)
}
wg.Wait()
// Semua goroutine tetap berjalan sampai selesai
// meski yang pertama sudah gagal sejak awal.

errgroup

import "golang.org/x/sync/errgroup"

func ambilSemua(ctx context.Context, daftarURL []string) error {
    g, ctx := errgroup.WithContext(ctx)

    for _, url := range daftarURL {
        g.Go(func() error {
            return ambil(ctx, url)     // cukup kembalikan error
        })
    }

    return g.Wait()   // error PERTAMA yang bukan nil
}
๐Ÿ’ก Kekuatan errgroup.WithContext

Begitu ada satu goroutine yang mengembalikan error, context yang dikembalikan akan dibatalkan. Semua goroutine lain yang menghormati context tersebut akan berhenti dengan sendirinya. Anda tidak perlu lagi mengatur mutex, channel penanda, atau pelacakan error secara manual.

Membatasi Jumlah Pekerja

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10)     // maksimal 10 goroutine berjalan bersamaan

for _, berkas := range sejutaBerkas {
    g.Go(func() error {
        return proses(ctx, berkas)
    })
}
return g.Wait()
โš ๏ธ Tanpa SetLimit, sejuta pekerjaan berarti sejuta goroutine

Goroutine memang murah (sekitar 2 KB), tetapi sejuta goroutine berarti beberapa gigabyte memori, ditambah kemungkinan membuka sejuta koneksi sekaligus ke database atau API tujuan. SetLimit mengubah errgroup menjadi kolam pekerja dengan satu baris kode.

Mengumpulkan Hasil, Bukan Hanya Error

func ambilSemuaHasil(ctx context.Context, urls []string) ([]Hasil, error) {
    hasil := make([]Hasil, len(urls))   // dialokasikan di muka
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(runtime.NumCPU())

    for i, url := range urls {
        g.Go(func() error {
            r, err := ambil(ctx, url)
            if err != nil {
                return fmt.Errorf("ambil %s: %w", url, err)
            }
            hasil[i] = r     // AMAN: tiap goroutine menulis indeks berbeda
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return hasil, nil
}
๐Ÿ“Œ Kenapa menulis ke indeks slice itu aman

Menulis ke indeks yang berbeda dari slice yang sama tidak menimbulkan data race, karena tidak ada dua goroutine yang menyentuh lokasi memori yang sama. Yang tidak aman adalah append ke slice yang sama dari banyak goroutine, karena append mengubah panjang dan bisa memicu realokasi.

Bug Lama: Variabel Perulangan

// Di Go 1.21 dan sebelumnya, kode ini BUG:
for _, url := range urls {
    go func() {
        ambil(url)     // semua goroutine melihat url yang SAMA
    }()
}
// Solusi lama: url := url, atau kirim sebagai argumen.

// Sejak Go 1.22, variabel perulangan dibuat BARU di tiap iterasi,
// sehingga kode di atas kini berperilaku benar.
โš ๏ธ Perilaku ini bergantung pada baris go di go.mod

Semantik baru hanya aktif bila modul Anda menyatakan go 1.22 atau lebih tinggi di go.mod. Modul lama tetap memakai perilaku lama demi kompatibilitas. Saat memutakhirkan modul lama, periksa kembali seluruh goroutine di dalam perulangan.

Menyebarkan Pembatalan dengan Benar

func proses(ctx context.Context, item Item) error {
    // Selalu periksa pembatalan pada pekerjaan yang panjang
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
    }

    // Teruskan ctx ke SEMUA panggilan yang memblokir
    req, _ := http.NewRequestWithContext(ctx, "GET", item.URL, nil)
    resp, err := http.DefaultClient.Do(req)
    ...
    row := db.QueryRowContext(ctx, "SELECT ...")
    ...
}
๐Ÿšซ Goroutine yang mengabaikan context tidak akan pernah berhenti

Pembatalan di Go bersifat kooperatif. Membatalkan context tidak menghentikan goroutine secara paksa โ€” ia hanya menutup sebuah channel. Bila kode Anda tidak pernah memeriksa ctx.Done() dan tidak meneruskan context ke pemanggilan yang memblokir, goroutine akan terus berjalan sampai selesai, dan errgroup akan menunggunya.

Contoh program

package main

import (
	"errors"
	"fmt"
	"sync"
	"time"
)

// Simulasi errgroup sederhana
type Grup struct {
	wg      sync.WaitGroup
	mu      sync.Mutex
	err     error
	batal   chan struct{}
	sekali  sync.Once
}

func (g *Grup) Go(fn func() error) {
	g.wg.Add(1)
	go func() {
		defer g.wg.Done()
		if err := fn(); err != nil {
			g.mu.Lock()
			if g.err == nil {
				g.err = err
				g.sekali.Do(func() { close(g.batal) })
			}
			g.mu.Unlock()
		}
	}()
}

func (g *Grup) Wait() error { g.wg.Wait(); return g.err }

func main() {
	g := &Grup{batal: make(chan struct{})}

	tugas := []string{"api-a", "api-b", "api-c"}
	for i, nama := range tugas {
		gagal := i == 1
		g.Go(func() error {
			time.Sleep(time.Duration(i+1) * 50 * time.Millisecond)
			if gagal {
				fmt.Println("  โœ—", nama, "GAGAL")
				return errors.New(nama + ": koneksi ditolak")
			}
			fmt.Println("  โœ“", nama, "sukses")
			return nil
		})
	}

	if err := g.Wait(); err != nil {
		fmt.Println("\nError pertama:", err)
		return
	}
	fmt.Println("\nSemua berhasil")
}

Praktik dan kuis bab ini di kelas interaktif โ†’

118. Fuzzing & testing/synctest

Menemukan bug yang tidak terpikirkan lewat fuzzing bawaan, dan menguji kode konkuren berbasis waktu secara deterministik dengan testing/synctest.

Fuzzing: Membiarkan Mesin Mencari Kasus Uji

Uji biasa hanya memeriksa masukan yang Anda pikirkan. Fuzzing membangkitkan masukan secara otomatis dan cerdas, dipandu oleh cakupan kode, untuk menemukan yang tidak terpikirkan.

parser_test.go
func FuzzUraiWaktu(f *testing.F) {
    // Benih: contoh masukan yang valid sebagai titik awal
    f.Add("12:30")
    f.Add("00:00")
    f.Add("23:59")

    f.Fuzz(func(t *testing.T, s string) {
        jam, menit, err := UraiWaktu(s)
        if err != nil {
            return    // menolak masukan buruk itu WAJAR
        }

        // Bila berhasil diurai, hasilnya HARUS masuk akal
        if jam < 0 || jam > 23 {
            t.Errorf("jam di luar rentang: %d dari %q", jam, s)
        }
        if menit < 0 || menit > 59 {
            t.Errorf("menit di luar rentang: %d dari %q", menit, s)
        }
    })
}
# Jalankan sebagai uji biasa (hanya memakai benih)
go test ./...

# Jalankan fuzzing sungguhan selama 30 detik
go test -fuzz=FuzzUraiWaktu -fuzztime=30s

# Masukan yang membuat gagal otomatis DISIMPAN ke
# testdata/fuzz/FuzzUraiWaktu/ dan menjadi kasus uji permanen.
๐Ÿ“Œ Sifat yang paling berguna untuk diuji
  • Bolak-balik: Urai(Format(x)) == x untuk setiap x.
  • Tidak pernah panic: masukan apa pun boleh ditolak, tetapi tidak boleh membuat program crash.
  • Invarian: hasil selalu berada dalam rentang yang sah.
  • Kesetaraan: implementasi baru yang dioptimalkan memberi hasil sama dengan implementasi lama yang sederhana.
๐Ÿ’ก Fuzzing sangat efektif pada kode parsing

Penguraian JSON, YAML, protokol jaringan, format berkas, dan validasi masukan pengguna adalah tempat fuzzing paling sering menemukan bug nyata โ€” terutama panic akibat indeks di luar batas dan pointer nil pada masukan yang cacat.

testing/synctest: Menguji Waktu Tanpa Menunggu

Menguji kode yang memakai time.Sleep, time.After, atau timeout selalu bermasalah: uji menjadi lambat karena benar-benar menunggu, dan menjadi rapuh karena mesin CI yang sibuk membuat waktunya meleset.

Masalahnya
func TestCacheKedaluwarsa(t *testing.T) {
    c := NewCache(100 * time.Millisecond)
    c.Set("a", 1)

    time.Sleep(150 * time.Millisecond)   // uji jadi lambat

    if _, ada := c.Get("a"); ada {
        t.Error("seharusnya sudah kedaluwarsa")
    }
}
// Punya 50 uji seperti ini? Pipeline Anda bertambah lambat
// berdetik-detik, dan tetap gagal acak di runner yang sibuk.
Dengan synctest โ€” waktu palsu, instan, deterministik
import "testing/synctest"

func TestCacheKedaluwarsa(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        c := NewCache(100 * time.Millisecond)
        c.Set("a", 1)

        // Waktu di dalam gelembung ini PALSU.
        // Maju 150 ms secara seketika.
        time.Sleep(150 * time.Millisecond)

        if _, ada := c.Get("a"); ada {
            t.Error("seharusnya sudah kedaluwarsa")
        }
    })
}
๐Ÿ’ก Cara kerja "gelembung" synctest

Semua goroutine yang dibuat di dalam gelembung memakai jam virtual bersama. Ketika seluruh goroutine dalam gelembung sedang memblokir, jam virtual otomatis melompat maju ke timer terjadwal berikutnya. Akibatnya time.Sleep(1 * time.Hour) selesai seketika, dan hasilnya sepenuhnya deterministik โ€” tidak ada lagi uji yang gagal acak karena mesin sedang sibuk.

Menunggu goroutine mencapai keadaan diam
synctest.Test(t, func(t *testing.T) {
    hasil := make(chan int, 1)

    go func() {
        time.Sleep(1 * time.Second)
        hasil <- 42
    }()

    // Tunggu sampai semua goroutine dalam gelembung memblokir
    synctest.Wait()

    // Sekarang aman untuk memeriksa keadaan
    select {
    case v := <-hasil:
        if v != 42 { t.Errorf("dapat %d", v) }
    default:
        t.Error("goroutine belum selesai")
    }
})
โš ๏ธ Batasan gelembung

Operasi yang benar-benar menyentuh dunia luar โ€” I/O jaringan, pembacaan berkas, panggilan sistem โ€” tidak ikut dipalsukan dan bisa membuat gelembung tidak pernah mencapai keadaan diam. synctest ditujukan untuk menguji logika penjadwalan dan waktu: timeout, retry dengan backoff, kedaluwarsa cache, rate limiter, dan debounce. Untuk pengujian yang menyentuh dependensi nyata, tetap gunakan uji integrasi biasa.

Perkakas Uji Lain yang Sering Terlupakan

# Deteksi data race -- WAJIB dijalankan di CI
go test -race ./...

# Laporan cakupan
go test -coverprofile=cover.out ./...
go tool cover -html=cover.out

# Benchmark beserta alokasi memori
go test -bench=. -benchmem

# Uji contoh yang keluarannya diverifikasi
func ExampleUraiWaktu() {
    j, m, _ := UraiWaktu("12:30")
    fmt.Println(j, m)
    // Output: 12 30
}

# Subuji berbasis tabel dengan t.Parallel()
for _, kasus := range kasusUji {
    t.Run(kasus.nama, func(t *testing.T) {
        t.Parallel()
        ...
    })
}
๐Ÿšซ -race tidak boleh dilewatkan

Data race di Go tidak selalu menampakkan diri saat pengembangan; ia muncul di produksi di bawah beban tinggi, dan menghasilkan kerusakan data yang sangat sulit dilacak. Detektor race memperlambat uji beberapa kali lipat, tetapi menemukan bug yang praktis mustahil ditemukan dengan cara lain. Jadikan go test -race wajib di pipeline CI.

Contoh program

package main

import (
	"fmt"
	"strconv"
	"strings"
)

// Fungsi yang akan di-fuzz
func UraiWaktu(s string) (jam, menit int, err error) {
	bagian := strings.Split(s, ":")
	if len(bagian) != 2 {
		return 0, 0, fmt.Errorf("format tidak valid: %q", s)
	}
	jam, err = strconv.Atoi(bagian[0])
	if err != nil {
		return 0, 0, err
	}
	menit, err = strconv.Atoi(bagian[1])
	if err != nil {
		return 0, 0, err
	}
	if jam < 0 || jam > 23 || menit < 0 || menit > 59 {
		return 0, 0, fmt.Errorf("di luar rentang: %q", s)
	}
	return jam, menit, nil
}

func main() {
	// Masukan yang biasanya ditemukan fuzzer
	masukan := []string{"12:30", "00:00", "23:59", "24:00", "-1:30", "12:60", "12", "::", "", "12:3a"}

	for _, s := range masukan {
		j, m, err := UraiWaktu(s)
		if err != nil {
			fmt.Printf("%-8q -> ditolak: %v\n", s, err)
			continue
		}
		fmt.Printf("%-8q -> %02d:%02d โœ“\n", s, j, m)
	}
}

Praktik dan kuis bab ini di kelas interaktif โ†’