ข้ามไปยังเนื้อหา

Slices

Array ใน Go มีขนาดคงที่ที่เป็นส่วนหนึ่งของ type [3]int และ [4]int คือ type คนละตัวกัน:

arr := [3]int{10, 20, 30}
fmt.Println(len(arr)) // 3 — always

Array เป็น value type: การ assign a2 := a1 จะ copy ทุก element มีประโยชน์สำหรับข้อมูลขนาดคงที่ แต่ยืดหยุ่นน้อยเกินไปสำหรับงานส่วนใหญ่

ไม่ต้องนับ element เองก็ได้ — [...]T{...} ให้ compiler คำนวณขนาด array จาก literal แทน:

a := [...]int{1, 2, 3, 4}
fmt.Printf("%T %d\n", a, len(a)) // [4]int 4 — still a genuine array, size fixed at compile time

Slice คือ header ขนาดเบา — สาม word — ที่อธิบาย “หน้าต่าง” เข้าไปยัง array ที่อยู่ด้านล่าง:

flowchart LR
  subgraph Header["slice header"]
    P["pointer"]
    L["len = 3"]
    C["cap = 5"]
  end
  subgraph Backing["backing array"]
    A0["10"] --> A1["20"] --> A2["30"] --> A3["40"] --> A4["50"]
  end
  P --> A0
slice header ชี้เข้าไปใน backing array; len=3 คือส่วนที่มองเห็น, cap=5 คือระยะไปจนสุด array
  • len — จำนวน element ที่เข้าถึงได้ผ่าน slice ในขณะนี้
  • cap — จำนวน element จากจุดเริ่มต้นของ slice ไปจนถึงท้ายของ backing array

สร้าง slice ด้วย make, composite literal, หรือการ slice array:

s := make([]int, 3, 5) // len=3, cap=5, all zeros
t := []int{1, 2, 3} // len=3, cap=3
u := arr[1:3] // len=2, cap=2, window into arr

append เพิ่ม element ให้ slice และคืนค่า slice (ที่อาจเป็นตัวใหม่):

s = append(s, 4, 5, 6)

เมื่อ len == cap, append จะ allocate backing array ใหม่ที่ใหญ่กว่า copy element ที่มีอยู่ แล้วคืน slice ที่ชี้ไป array ใหม่ slice เดิมไม่ถูกกระทบ ควร reassign ผลลัพธ์ของ append เสมอ — s = append(s, ...)

เวลา append ต้องการ backing array ใหม่ Go ไม่ได้สุ่มขนาดขึ้นมาเฉย ๆ — มันมีนโยบายการเติบโตเพื่อให้การ append ทีละ element n ครั้งยังมีต้นทุนรวม O(n) ไม่ใช่ O(n²) บน Go 1.26.5 นโยบายคือ double capacity ไปเรื่อย ๆ ตอนที่ยังเล็ก (ต่ำกว่า 256) แล้วค่อยโตประมาณ 1.25× และสุดท้ายปัดขึ้นไปยัง size class ที่ runtime มีให้เลือกไว้ตายตัว การปัดขึ้นขั้นสุดท้ายนี่แหละที่ทำให้ตัวเลขไม่ใช่ยกกำลังสองพอดีหรือ ×1.25 พอดี — allocator มี bucket ขนาดจำกัดให้เลือก แล้วปัดขึ้นไปยัง bucket ที่ใกล้ที่สุดเสมอ

นี่คือที่มาของ 6 10 ใน playground ด้านล่างพอดี: make([]int, 3, 5) เริ่มที่ cap 5 พอ append เพิ่มอีก 3 element ต้องการ cap ≥ 6 การเติบโตจึง double 5 → 10 (ยังอยู่ในโซน “double”)

การ append ทีละ element แล้ว print cap(s) เฉพาะตอนที่มันเปลี่ยน แสดงนโยบายนี้ได้ตรง ๆ (go run บน 1.26.5):

s := make([]int, 0)
prevCap := cap(s)
for i := 0; i < 2100; i++ {
s = append(s, i)
if cap(s) != prevCap {
fmt.Println(len(s), cap(s))
prevCap = cap(s)
}
}
// 1 4
// 5 8
// 9 16
// 17 32
// 33 64
// 65 128
// 129 256
// 257 512 <- still doubling exactly at the 256 boundary
// 513 848 <- now ~1.25x + size-class rounding, not a clean double
// 849 1280
// 1281 1792
// 1793 2560

ห้ามพึ่งพาตัวเลขการเติบโตที่แม่นยำในโค้ดของตัวเองเด็ดขาด — มันเป็นรายละเอียด implementation ของ runtime และเคยเปลี่ยนแปลงมาแล้วข้าม Go version สิ่งที่พึ่งพาได้คือต้นทุนแบบ amortized O(1) ต่อ append หนึ่งครั้ง ถ้ารู้ล่วงหน้าคร่าว ๆ ว่าจะเก็บ element กี่ตัว ให้ pre-size ด้วย make([]T, 0, n) หรือขยายตรง ๆ ด้วย slices.Grow(s, n) (Go 1.21+) เพื่อข้ามขั้นตอน reallocation ไปเลย — slices.Grow รับประกัน capacity สำหรับอย่างน้อย n element เพิ่มเติม โดยไม่แตะ len:

s := make([]int, 2, 2)
s = slices.Grow(s, 10)
fmt.Println(len(s), cap(s)) // 2 12 — len unchanged, cap rounded up by the same size classes

a[low:high] สร้าง slice header ใหม่ที่แชร์ backing array เดียวกัน:

base := []int{1, 2, 3, 4, 5}
a := base[1:3] // [2 3] — shares backing array with base
b := base[1:4] // [2 3 4] — also shares
a[0] = 99
fmt.Println(b[0]) // 99 — mutation visible through b too

นี่คือพลัง (sub-slicing แบบ zero-copy) แต่อันตรายถ้าลืมเรื่องนี้

จุดที่อันตรายจริง ๆ ไม่ใช่ a[0] = 99 — นั่นคือ mutation ที่มองเห็นได้ชัดเจนอยู่แล้ว จุดที่อันตรายคือ append เพราะมันดูเหมือนจะกระทบแค่ a เท่านั้น แต่ถ้า a ยังมี capacity เหลือใน backing array ของ base append จะเขียนตรงลงไปในหน่วยความจำที่ base ยังถือว่าเป็น “ของตัวเอง”:

base := []int{1, 2, 3, 4, 5}
a := base[1:3] // len=2, cap=4 — a has 2 elements of spare capacity inside base
a = append(a, 99)
fmt.Println(base) // [1 2 3 99 5] — base[3] silently overwritten, base never touched directly

a มี cap == 4 (นับจาก index 1 ไปจนสุด base) การ append หนึ่ง element จึงพอดีในพื้นที่ array เดิม — ไม่มี reallocation ไม่มีการเตือน มีแค่การเขียนลงไปที่ base[3] นี่คือหนึ่งใน bug ที่ AI เขียน Go แล้วพลาดบ่อยที่สุด — โค้ดที่ตัด sub-slice ออกมา “เก็บไว้ดูทีหลัง” หรือ “เก็บ tail ไว้เผื่อใช้” แล้วไป append ที่ sub-slice หรือ slice เดิม จนทำให้อีกฝั่งเสียหายแบบเงียบ ๆ

Three-index slice expression base[low:high:max] แก้ปัญหานี้ได้โดยจำกัด capacity ของ slice ใหม่ให้เท่ากับ max ทำให้ไม่มีที่ว่างเหลือ และบังคับให้ append ต้อง allocate backing array ใหม่แทนที่จะย้อนกลับไปแตะ base:

c := base[1:3:3] // len=2, cap=2 — no spare capacity, "full slice expression"
c = append(c, 77)
fmt.Println(base, c) // base unchanged; c now has its own independent backing array

ใช้ base[low:high:high] (จำกัด max เท่ากับ high) ทุกครั้งที่ส่ง sub-slice ออกไปให้คนอื่นและไม่ต้องการให้ append ของฝั่งนั้นย้อนกลับมาแตะ array ของเรา

Slice header ถูก copy by value เข้าไปในฟังก์ชัน เหมือนทุกอย่างใน Go — แต่ copy นั้นยังชี้ไปยัง backing array เดียวกัน สิ่งนี้ทำให้ slice ที่เป็น argument มีพฤติกรรมสองบุคลิก:

func appendElem(s []int) {
s[0] = -1 // writes through the shared backing array — visible to the caller
s = append(s, 999) // only reassigns this function's local copy of the header — invisible to the caller
}
nums := []int{1, 2, 3}
appendElem(nums)
fmt.Println(nums) // [-1 2 3] — the element write stuck, the append did not

การเขียน element (s[i] = x) มองเห็นได้จาก caller เสมอเพราะเดินผ่าน pointer ที่แชร์กัน ส่วน append จะมองเห็นได้จาก caller ก็ต่อเมื่อฟังก์ชัน return slice ใหม่กลับมาและ caller assign มันกลับ — nums = appendElem(nums) ฟังก์ชันที่ AI เขียนแล้ว append เข้า parameter โดยคาดหวังว่า caller จะเห็นการเติบโตโดยไม่ return อะไรกลับเลย คือ bug ที่ compile ผ่านแต่ผิดแบบเงียบ ๆ

ใช้ built-in copy(dst, src) เพื่อตัดการแชร์ โดย copy min(len(dst), len(src)) element และคืนจำนวนที่ copy:

fresh := []int{10, 20, 30}
dst := make([]int, 3)
copy(dst, fresh)
dst[0] = 0
fmt.Println(fresh[0], dst[0]) // 10 0 — independent

slices.Clone(a) และ append([]int(nil), a...) ให้ผลเดียวกับการ copy ลง slice ใหม่ — ทั้งสามวิธีได้ backing array ที่เป็นอิสระ

slice ที่เป็น nil กับ slice ที่ว่างเปล่า (ความยาวศูนย์ แต่ไม่ใช่ nil) ทั้งคู่มี len == 0 และการทำงานส่วนใหญ่ปฏิบัติกับมันเหมือนกัน — แต่มันไม่ใช่ค่าเดียวกัน และโค้ดที่สมมติว่าเหมือนกันจะพังใน 2 จุด:

var n []int // nil slice: no backing array at all
e := []int{} // empty slice: has a backing array, just zero elements
fmt.Println(n == nil, e == nil) // true false
fmt.Println(len(n), len(e)) // 0 0 — both report zero length

== กับ nil เป็นการเปรียบเทียบเดียวที่ slice รองรับ (slice ที่ไม่ใช่ nil สองตัวเทียบกันด้วย == ตรง ๆ ไม่ได้เลย — เป็น compile error) และนี่คือจุดเดียวที่ nil กับ []int{} ต่างกันจริง ๆ อีกจุดคือ JSON: encoding/json แปลง slice ที่เป็น nil เป็น null แต่แปลง slice ว่างเป็น [] ถ้า API contract สัญญาว่า array field จะไม่เป็น null เด็ดขาด ให้ initialize ด้วย make([]T, 0) หรือ []T{} ไม่ใช่ var s []T

แทบไม่ต้องสร้าง nil slice เองโดยตั้งใจ — var s []T เป็น zero value ที่เป็น nil อยู่แล้ว และ append ใส่มันได้อย่างปลอดภัย (append จะ allocate ให้เองตอนใช้ครั้งแรก) เก็บ []T{} ไว้ใช้เฉพาะกรณีที่ array ว่างแต่ “มีอยู่” มีความหมายต่างจากการไม่มีจริง ๆ

เพราะ sub-slice แชร์ backing array กับ slice ต้นทาง การเก็บ sub-slice เล็ก ๆ ไว้จึงทำให้ backing array ทั้งก้อน ของต้นทางยังมีชีวิตอยู่ด้วย — garbage collector เก็บกวาดส่วนไหนไม่ได้เลยตราบใดที่ยังมี slice header ตัวใดตัวหนึ่งชี้เข้าไปในนั้น จุดนี้พลาดง่ายมากเวลาโค้ดมี pattern แบบ “อ่าน buffer ก้อนใหญ่ แล้วเก็บชิ้นเล็ก ๆ ที่สนใจไว้”:

// bigBuf might be a 64 KB read buffer from a network connection or bufio.Reader
bigBuf := make([]byte, 65536)
// ... fill bigBuf ...
header := bigBuf[:16] // "just the first 16 bytes" — but header.cap reaches to the end of bigBuf
store(header) // if store() keeps header around, all 65536 bytes stay alive

วิธีแก้ก็คือเครื่องมือเดิมที่ใช้ตัด aliasing ไปแล้วก่อนหน้านี้: copy ส่วนที่สนใจลงไปยัง slice ที่ allocate ใหม่ในขนาดที่พอดี ก่อนจะปล่อยการอ้างอิงไปยัง buffer ก้อนใหญ่ เพื่อให้เหลือแค่ชิ้นเล็ก ๆ รอดอยู่:

headerCopy := make([]byte, 16)
copy(headerCopy, bigBuf[:16])
store(headerCopy) // bigBuf can now be garbage-collected once it goes out of scope

ให้ระวัง pattern นี้ทุกครั้งที่ slice ที่มีอายุยืนถูกสร้างจากการ slice (ไม่ใช่ copy) buffer ก้อนใหญ่ที่มีอายุสั้น — bufio.Scanner.Bytes() เขียนไว้ตรง ๆ ถึงอันตรายแบบเดียวกันนี้: slice ที่ return กลับมาใช้ได้แค่จนกว่าจะเรียก Scan() ครั้งถัดไป เพราะมันแชร์ buffer ภายในของ scanner นั่นเอง

Go 1.21 เพิ่ม builtin สามตัวและ package slices แบบ generic ทำให้ helper loop ที่เคยเขียนเองส่วนใหญ่หายไปจาก code สมัยใหม่:

import "slices"
n := min(3, 7, 1) // 1 — works for any ordered type
m := max(2.5, 1.0) // 2.5
clear(s) // zero every element, keeps len and cap
xs := []int{3, 1, 2}
slices.Sort(xs) // [1 2 3]
slices.Contains(xs, 2) // true
i, found := slices.BinarySearch(xs, 3) // 2, true
ys := slices.Clone(xs) // independent copy, no make+copy needed
xs = slices.Insert(xs, 0, 9)

slices.Sort, slices.Index, slices.Reverse, slices.Compact และเพื่อน ๆ เป็น type-safe และมักเร็วกว่า package sort ตั้งแต่ Go 1.23 slices.Collect, slices.Sorted และ slices.Values ยังเชื่อม slice กับ iterator ด้วย — ดูบท Iterators การรัน go fix (Go 1.26+) จะเขียน loop เก่า ๆ หลายแบบใหม่เป็น call เหล่านี้ให้อัตโนมัติ

package main
import "fmt"
func main() {
// Array vs slice
arr := [3]int{10, 20, 30} // fixed-size array
sl := arr[:] // slice backed by arr
sl[0] = 99
fmt.Println(arr[0], sl[0]) // 99 99 — shared backing array
// make: len and cap
s := make([]int, 3, 5)
fmt.Println(len(s), cap(s)) // 3 5
// append beyond cap allocates a new backing array
s = append(s, 1, 2, 3)
fmt.Println(len(s), cap(s)) // 6 10
// Two slices sharing the same backing array
base := []int{1, 2, 3, 4, 5}
a := base[1:3] // [2 3]
b := base[1:4] // [2 3 4]
a[0] = 99
fmt.Println(b[0]) // 99 — shared backing array
// copy to get an independent slice
fresh := []int{10, 20, 30}
dst := make([]int, 3)
copy(dst, fresh)
dst[0] = 0
fmt.Println(fresh[0], dst[0]) // 10 0 — independent after copy
}

Playground ถัดไปเน้นเฉพาะหัวข้อเชิงลึกด้านบน: aliasing ผ่าน append, three-index slice expression, slice เป็น function argument, nil เทียบกับ empty, และ range ที่ copy element

package main
import "fmt"
// appendElem writes through the shared backing array, but growing via append
// only updates this function's local copy of the slice header.
func appendElem(s []int) {
s[0] = -1 // visible to the caller
s = append(s, 999) // invisible to the caller
}
func main() {
// Aliasing: append can silently overwrite a sibling slice's data
base := []int{1, 2, 3, 4, 5}
a := base[1:3] // len=2 cap=4 -- spare capacity inside base
a = append(a, 99)
fmt.Println(base) // [1 2 3 99 5] -- base[3] overwritten
// The three-index slice expression removes the spare capacity
c := base[1:3:3] // len=2 cap=2 -- no room left
c = append(c, 77)
fmt.Println(base, c) // base untouched, c has its own backing array
// Slices as function arguments
nums := []int{1, 2, 3}
appendElem(nums)
fmt.Println(nums) // [-1 2 3]
// nil vs empty
var n []int
e := []int{}
fmt.Println(n == nil, e == nil, len(n), len(e)) // true false 0 0
// range copies each element into v
vals := []int{1, 2, 3}
for _, v := range vals {
v *= 10
}
fmt.Println(vals) // [1 2 3] -- unchanged
}
flowchart TD
  Header["slice header (3 words, 24 bytes): pointer, len=3, cap=5"] --> Backing["backing array (cap=5): [0][1][2][3][4], visible range = len (3)"]
  subgraph WithinCap["append within cap"]
    A1["s = append(s, 4)"] --> A2["len: 4, cap: 5 (no new allocation)"]
  end
  subgraph PastCap["append past cap"]
    B1["s = append(s, 5, 6)"] --> B2["Go allocates a new backing array (~2x cap)"]
    B2 --> B3["old backing array stays alive if another slice still points at it"]
  end
  Backing --> WithinCap
  Backing --> PastCap
append ที่ยังไม่เกิน cap จะโตในที่เดิม; append ที่เกิน cap จะจอง backing array ใหม่ และของเก่ายัง live ถ้ามี slice อื่นชี้อยู่
สิ่งที่ได้ประโยชน์ต้นทุน
slice header เป็น value (24 bytes)pass to function ถูกmutation ผ่าน slice copy กระทบ backing array เดิม
dynamic appendขยายได้อัตโนมัติreallocation เมื่อเกิน cap — O(n) copy
slicing [a:b]zero-copy subviewbacking array ที่แชร์ — อาจ prevent GC ของ array ใหญ่
make([]T, len, cap)pre-allocate หลีกเลี่ยง reallocต้องประมาณ cap ล่วงหน้า
  • slice และ array คือสิ่งเดียวกัน — slice คือ (pointer, len, cap) header ที่ชี้ไปยัง array; array คือ fixed-size value type
  • append ปลอดภัยเสมอและ mutate in-place — append อาจ return slice ที่ชี้ backing array ใหม่ทั้งหมด — ต้อง assign ผลลัพธ์กลับเสมอ
  • len และ cap เหมือนกัน — len คือจำนวน element ที่เข้าถึงได้ตอนนี้, cap คือขนาดสูงสุดก่อน reallocation
  • copy(dst, src) ทำ deep copy — copy ถ่ายโอน values แต่ถ้า element เป็น pointer หรือ interface ยัง share underlying data เดิม

เวลาอ่านฟังก์ชันที่เกี่ยวกับ slice ที่ AI เขียนให้ (หรือแม้แต่ที่เขียนเองแบบเร่ง ๆ) ให้เช็คสิ่งเหล่านี้ก่อนเชื่อ:

  • มันเอา parameter หรือ struct field ไป slice (x[a:b]) แล้วส่งผลลัพธ์ไปให้อะไรที่อายุยืนหรือเปล่า? sub-slice นั้นจะทำให้ backing array ทั้งก้อน ของต้นทางมีชีวิตอยู่ต่อ และถ้าต้นทางถูก mutate หรือ append ทีหลัง sub-slice อาจเห็นการเขียนที่ไม่ควรเห็น
  • มัน append เข้า slice ที่สร้างจากการ slice slice อื่น (sub := big[i:j]) โดยไม่ใช้ three-index expression หรือเปล่า? ถ้า sub ยังมี capacity เหลือใน big การ append นั้นอาจเขียนทับข้อมูลของ big เกิน index j แบบเงียบ ๆ
  • ฟังก์ชัน append เข้า slice parameter แล้วคาดหวังให้ caller เห็น element ใหม่โดยไม่ return slice กลับหรือเปล่า? มีแค่การเขียน element (s[i] = x) เท่านั้นที่รับประกันว่า caller เห็น — การเติบโตไม่ใช่
  • มันเทียบ slice กับ nil ทั้งที่จริง ๆ ต้องการเช็คว่า “ว่างหรือเปล่า” หรือกลับกันหรือเปล่า (เช่น พึ่งพา JSON null เทียบกับ [] โดยไม่เช็คว่า code path ไหนสร้างอันไหน)?
  • มี loop แบบ for _, v := range s { v = ... } แล้วคาดหวังให้ s เปลี่ยนหรือเปล่า? v เป็น copy มีแค่ s[i] = ... (หรือ for i := range s) เท่านั้นที่ mutate ต้นทางจริง ๆ

🐛 หา bug ในโค้ดนี้

func dedupe(nums []int) []int {
out := nums[:0]
for i, n := range nums {
if i == 0 || n != nums[i-1] {
out = append(out, n)
}
}
return out
}
func main() {
data := []int{1, 1, 2, 3, 3}
backup := data[2:] // keep the tail "just in case"
result := dedupe(data)
fmt.Println(result, backup)
}
เฉลย

out := nums[:0] ใช้ backing array ของ nums เองเป็นปลายทาง — เป็นเทคนิค dedupe แบบ in-place ที่ปกติแล้วถูกต้อง แต่ backup := data[2:] แชร์ array เดียวกัน นั้น เริ่มจาก index 2 พอ dedupe เขียนค่าที่ dedupe แล้วไล่ไปข้างหน้า (1, 2, 3 ไปตกที่ index 0, 1, 2) มันจึงเขียนทับ index 2 ซึ่งเป็นจุดเริ่มต้นของ backup พอดี ผลลัพธ์จริงที่ verify บน 1.26.5: [1 2 3] [3 3 3] — backup ถูกทำลายแบบเงียบ ๆ จากการเขียน in-place ของ dedupe ทั้งที่ dedupe ไม่เคยแตะตัวแปรชื่อ backup เลย วิธีแก้: ไม่เขียน in-place (out := make([]int, 0, len(nums))) หรือทำให้ backup เป็น copy ที่เป็นอิสระ (backup := slices.Clone(data[2:])) ก่อนเรียกอะไรก็ตามที่อาจ mutate backing array ของ data แบบ in-place

คุณเขียน s = append(s, 1) เมื่อ len(s) == cap(s) Go ทำอะไร?
make([]string, 4, 10) สร้างอะไร?
วิธี idiomatic ที่ทำให้ slice สองตัวไม่แชร์ backing array คืออะไร?
โค้ดนี้ print อะไร? base1 := []int{1, 2, 3} a := base1[:2] a = append(a, 99) fmt.Println(base1)
setup เดิม แต่คราวนี้ slice ด้วย three-index expression — โค้ดนี้ print อะไร? base2 := []int{1, 2, 3} c := base2[:2:2] c = append(c, 99) fmt.Println(base2)
โค้ดนี้ print อะไร? func f(s []int) { s[0] = 100 s = append(s, 1) } nums := []int{1, 2, 3} f(nums) fmt.Println(nums)