Slices
Array: รากฐาน
หัวข้อที่มีชื่อว่า “Array: รากฐาน”Array ใน Go มีขนาดคงที่ที่เป็นส่วนหนึ่งของ type [3]int และ [4]int คือ type คนละตัวกัน:
arr := [3]int{10, 20, 30}fmt.Println(len(arr)) // 3 — alwaysArray เป็น 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 timeSlice: มุมมองแบบ dynamic
หัวข้อที่มีชื่อว่า “Slice: มุมมองแบบ dynamic”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 len— จำนวน element ที่เข้าถึงได้ผ่าน slice ในขณะนี้cap— จำนวน element จากจุดเริ่มต้นของ slice ไปจนถึงท้ายของ backing array
สร้าง slice ด้วย make, composite literal, หรือการ slice array:
s := make([]int, 3, 5) // len=3, cap=5, all zerost := []int{1, 2, 3} // len=3, cap=3u := arr[1:3] // len=2, cap=2, window into arrappend และการเติบโต
หัวข้อที่มีชื่อว่า “append และการเติบโต”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 classesSlicing expression และ backing array ที่แชร์
หัวข้อที่มีชื่อว่า “Slicing expression และ backing array ที่แชร์”a[low:high] สร้าง slice header ใหม่ที่แชร์ backing array เดียวกัน:
base := []int{1, 2, 3, 4, 5}a := base[1:3] // [2 3] — shares backing array with baseb := base[1:4] // [2 3 4] — also sharesa[0] = 99fmt.Println(b[0]) // 99 — mutation visible through b tooนี่คือพลัง (sub-slicing แบบ zero-copy) แต่อันตรายถ้าลืมเรื่องนี้
Aliasing: append เขียนทับข้อมูลของ slice อื่นได้
หัวข้อที่มีชื่อว่า “Aliasing: append เขียนทับข้อมูลของ slice อื่นได้”จุดที่อันตรายจริง ๆ ไม่ใช่ 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 basea = append(a, 99)fmt.Println(base) // [1 2 3 99 5] — base[3] silently overwritten, base never touched directlya มี 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 เป็น function argument
หัวข้อที่มีชื่อว่า “Slice เป็น function argument”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 ผ่านแต่ผิดแบบเงียบ ๆ
copy เพื่อ slice ที่เป็นอิสระ
หัวข้อที่มีชื่อว่า “copy เพื่อ slice ที่เป็นอิสระ”ใช้ 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] = 0fmt.Println(fresh[0], dst[0]) // 10 0 — independentslices.Clone(a) และ append([]int(nil), a...) ให้ผลเดียวกับการ copy ลง slice ใหม่ — ทั้งสามวิธีได้ backing array ที่เป็นอิสระ
nil กับ empty slice
หัวข้อที่มีชื่อว่า “nil กับ empty slice”slice ที่เป็น nil กับ slice ที่ว่างเปล่า (ความยาวศูนย์ แต่ไม่ใช่ nil) ทั้งคู่มี len == 0 และการทำงานส่วนใหญ่ปฏิบัติกับมันเหมือนกัน — แต่มันไม่ใช่ค่าเดียวกัน และโค้ดที่สมมติว่าเหมือนกันจะพังใน 2 จุด:
var n []int // nil slice: no backing array at alle := []int{} // empty slice: has a backing array, just zero elements
fmt.Println(n == nil, e == nil) // true falsefmt.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 ว่างแต่ “มีอยู่” มีความหมายต่างจากการไม่มีจริง ๆ
Memory retention จากการ sub-slice buffer ก้อนใหญ่
หัวข้อที่มีชื่อว่า “Memory retention จากการ sub-slice buffer ก้อนใหญ่”เพราะ 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.ReaderbigBuf := make([]byte, 65536)// ... fill bigBuf ...header := bigBuf[:16] // "just the first 16 bytes" — but header.cap reaches to the end of bigBufstore(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 นั่นเอง
min, max, clear และ package slices
หัวข้อที่มีชื่อว่า “min, max, clear และ package slices”Go 1.21 เพิ่ม builtin สามตัวและ package slices แบบ generic ทำให้ helper loop ที่เคยเขียนเองส่วนใหญ่หายไปจาก code สมัยใหม่:
import "slices"
n := min(3, 7, 1) // 1 — works for any ordered typem := max(2.5, 1.0) // 2.5clear(s) // zero every element, keeps len and cap
xs := []int{3, 1, 2}slices.Sort(xs) // [1 2 3]slices.Contains(xs, 2) // truei, found := slices.BinarySearch(xs, 3) // 2, trueys := slices.Clone(xs) // independent copy, no make+copy neededxs = 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} Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”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 ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
| 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 subview | backing 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 คือขนาดสูงสุดก่อน reallocationcopy(dst, src)ทำ deep copy — copy ถ่ายโอน values แต่ถ้า element เป็น pointer หรือ interface ยัง share underlying data เดิม
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เวลาอ่านฟังก์ชันที่เกี่ยวกับ 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เกิน indexjแบบเงียบ ๆ - ฟังก์ชัน
appendเข้า slice parameter แล้วคาดหวังให้ caller เห็น element ใหม่โดยไม่ return slice กลับหรือเปล่า? มีแค่การเขียน element (s[i] = x) เท่านั้นที่รับประกันว่า caller เห็น — การเติบโตไม่ใช่ - มันเทียบ slice กับ
nilทั้งที่จริง ๆ ต้องการเช็คว่า “ว่างหรือเปล่า” หรือกลับกันหรือเปล่า (เช่น พึ่งพา JSONnullเทียบกับ[]โดยไม่เช็คว่า 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