Slice secara Mendalam

len dan cap, pertumbuhan append, serta jebakan aliasing slice.

Daftar isi

Slice menjadi kumpulan data utama di Go karena ukurannya dapat tumbuh, tetapi fleksibilitas itu dikelola melalui dua angka yang melekat pada setiap slice, yaitu panjang (len) dan kapasitas (cap). Topik ini membahas makna keduanya, cara make menyiapkan kapasitas, pola pertumbuhan yang dilakukan append ketika kapasitas habis, serta perilaku aliasing ketika dua slice berbagi satu array penyangga. Perilaku terakhir adalah sumber bug yang paling sering dialami pemula, sehingga penyebab dan perbaikannya diperlihatkan dengan program yang lengkap.

len dan cap#

Array penyangga (backing array) adalah array tempat elemen slice benar-benar disimpan. Fungsi len menghitung banyak elemen yang sedang terlihat pada slice, sedangkan cap menghitung banyak slot yang tersedia pada array penyangganya sebelum slice harus pindah ke array baru. Keduanya dapat diatur saat pembuatan dengan make: bentuk make([]int, 3, 5) membuat slice dengan tiga elemen siap pakai dan lima slot total, sedangkan make([]int, 3) menyamakan keduanya pada angka tiga.

go len-cap.go
package main

import "fmt"

func main() {
	s := make([]int, 3, 5)

	fmt.Println("slice :", s)
	fmt.Println("len   :", len(s))
	fmt.Println("cap   :", cap(s))
}
text Keluaran
slice : [0 0 0]
len   : 3
cap   : 5

Tiga elemen yang dibuat make langsung berisi 0 karena zero value int, sehingga slice tersebut dapat diindeks segera tanpa append. Dua slot sisanya tetap tersedia meskipun tidak terlihat, dan itulah wilayah yang dihitung oleh cap tetapi tidak dihitung oleh len. Selisih cap dikurangi len menyatakan banyak slot kosong yang dapat diisi append tanpa memindahkan data.

Pertumbuhan append#

Fungsi append menambah elemen ke ujung slice dan mengembalikan slice hasilnya, yang bisa jadi menunjuk array penyangga yang sama maupun array baru. Apabila cap masih longgar, elemen baru ditulis ke slot kosong yang sudah ada. Apabila cap habis, append mengalokasikan array penyangga yang lebih besar, menyalin seluruh elemen lama, lalu mengembalikan slice yang menunjuk array baru itu:

go pertumbuhan.go
package main

import "fmt"

func main() {
	s := []int{}

	for i := 1; i <= 8; i++ {
		s = append(s, i*i)
		fmt.Printf("len %2d cap %2d\n", len(s), cap(s))
	}
	fmt.Println("isi:", s)
}
text Keluaran
len  1 cap  1
len  2 cap  2
len  3 cap  4
len  4 cap  4
len  5 cap  8
len  6 cap  8
len  7 cap  8
len  8 cap  8
isi: [1 4 9 16 25 36 49 64]

Pada Go 1.27 di lingkungan linux/amd64, kapasitas tumbuh mendekati penggandaan pada slice kecil, yaitu 1, 2, 4, lalu 8. Pola penggandaan ini membuat penambahan satu per satu tetap efisien secara rata-rata, karena perpindahan data yang mahal terjadi semakin jarang seiring slice bertambah besar. Angka pastinya bukan jaminan spesifikasi bahasa, dan versi Go yang berbeda dapat memilih angka pertumbuhan yang berbeda pula, sehingga logika program tidak boleh bergantung pada nilai cap yang spesifik.

Program dijalankan dari folder kerja tetap:

bash
# siapkan folder kerja untuk topik ini
mkdir -p /tmp/gotipe/slice-mendalam
cp len-cap.go pertumbuhan.go aliasing.go salin-aman.go /tmp/gotipe/slice-mendalam/
cd /tmp/gotipe/slice-mendalam

# periksa format lalu jalankan program pertumbuhannya
gofmt -l .
go run pertumbuhan.go
text Keluaran
len  1 cap  1
len  2 cap  2
len  3 cap  4
len  4 cap  4
len  5 cap  8
len  6 cap  8
len  7 cap  8
len  8 cap  8
isi: [1 4 9 16 25 36 49 64]

Dua slice berbagi satu array penyangga#

Mengiris slice tidak menyalin datanya. Ekspresi nilai[0:2] hanya membuat header baru, yaitu penanda kecil berisi lokasi data, yang menunjuk ke array penyangga yang sama dengan len dan cap yang dihitung ulang. Akibatnya, dua slice dapat menulis ke elemen fisik yang sama, dan perubahan lewat salah satunya terlihat pada yang lain. Program berikut memperlihatkan urutan kejadian dua tahap:

go aliasing.go
package main

import "fmt"

func main() {
	nilai := []int{80, 75, 90, 65}
	bagian := nilai[0:2]

	bagian[0] = 85
	fmt.Println("setelah mengubah bagian[0]")
	fmt.Println("nilai :", nilai)
	fmt.Println("bagian:", bagian)

	bagian = append(bagian, 60)
	fmt.Println("setelah append")
	fmt.Println("nilai :", nilai)
	fmt.Println("bagian:", bagian)
	fmt.Println("cap   :", cap(bagian))
}
text Keluaran
setelah mengubah bagian[0]
nilai : [85 75 90 65]
bagian: [85 75]
setelah append
nilai : [85 75 60 65]
bagian: [85 75 60]
cap   : 4

Tahap pertama masih mudah dijelaskan, yaitu bagian[0] = 85 mengubah elemen yang memang berbagi lokasi fisik, sehingga nilai ikut berubah. Tahap kedua lebih mengejutkan. Saat append menambahkan 60, kapasitas bagian masih empat dengan len dua, sehingga append menulis ke slot kosong array penyangga yang sama, tepat pada indeks 2. Elemen 90 milik nilai tertimpa menjadi 60 tanpa pesan error apa pun.

Perbaikan yang paling langsung adalah menyalin elemen ke array penyangga baru dengan fungsi copy. Bentuk copy(tujuan, sumber) menyalin sebanyak elemen terkecil dari kedua sisinya, sehingga tujuan sebaiknya disiapkan dengan make berukuran tepat:

go salin-aman.go
package main

import "fmt"

func main() {
	nilai := []int{80, 75, 90, 65}
	bagian := make([]int, 2)
	copy(bagian, nilai[0:2])

	bagian[0] = 85
	fmt.Println("nilai :", nilai)
	fmt.Println("bagian:", bagian)
}
text Keluaran
nilai : [80 75 90 65]
bagian: [85 75]

Setelah disalin, bagian memiliki array penyangga sendiri, sehingga perubahan dan penambahan tidak menyentuh nilai lagi. Fungsi copy mengembalikan banyak elemen yang benar-benar tersalin, yaitu yang terkecil antara kedua sisinya, sehingga hasilnya dapat dipakai untuk memeriksa apakah salinannya terpotong. Alternatif lain adalah ekspresi full slice dengan tiga indeks, yaitu bentuk iris yang mengikat kapasitas sehingga append langsung pindah array, dan diuraikan menyeluruh pada topik Bagian dalam Slice.

Kesalahan umum#

  1. Hasil append tidak ditugaskan. Pernyataan append(s, 10) yang berdiri sendiri ditolak kompiler dengan pesan evaluated but not used, karena fungsi itu mengembalikan slice yang bisa jadi baru. Perbaikannya, selalu tulis s = append(s, 10) agar hasilnya kembali ke variabel.
  2. Mengira penugasan slice menyalin elemennya. Pernyataan lain := nilai hanya menyalin header, sehingga keduanya tetap berbagi array penyangga. Perbaikannya, gunakan copy ke slice make yang baru apabila salinan yang lepas dibutuhkan.
  3. Keliru memilih bentuk make. make([]int, 3) membuat tiga elemen berisi nol, sedangkan make([]int, 0, 3) membuat nol elemen dengan kapasitas tiga. Pengisian lewat append pada bentuk pertama menambah elemen setelah tiga nol tadi. Perbaikannya, pilih bentuk sesuai maksud, yaitu panjang untuk data yang siap diindeks dan kapasitas untuk penampung yang akan diisi append.

Ringkasan#

  • len menyatakan banyak elemen yang terlihat, sedangkan cap menyatakan banyak slot pada array penyangga hingga ujungnya.
  • append menulis ke slot kosong selama masih ada, dan memindahkan data ke array yang lebih besar saat kapasitas habis.
  • Pola pertumbuhan mendekati penggandaan pada slice kecil, tetapi angkanya bukan jaminan spesifikasi dan dapat berubah antar versi Go.
  • Irisan dan asalnya berbagi array penyangga, sehingga perubahan maupun append pada irisan dapat menyentuh data asal, dan copy menjadi pelindung bila data asal harus terjaga.

Referensi#