1 · ฉากที่เกิดเรื่อง
สมมติคุณเขียน service ที่เก็บรายการ order แล้วมีฟังก์ชันดึง "3 รายการแรก" ไปทำรายงาน ดูโค้ดนี้ก่อน — มันดูปกติมาก
func topThree(orders []int) []int {
return orders[0:3]
}
func main() {
all := []int{10, 20, 30, 40, 50}
top := topThree(all)
top = append(top, 999) // เพิ่มแถวสรุปท้ายรายงาน
fmt.Println(all) // ← ตัวนี้ควรจะเหมือนเดิมไหม?
}
all ไม่เหมือนเดิม มันกลายเป็น [10 20 30 999 50] —
ค่า 40 หายไปเฉย ๆ ทั้งที่ไม่มีใครแตะ all เลยสักบรรทัด
จะเข้าใจว่าทำไม ต้องรู้ก่อนว่า slice คืออะไรจริง ๆ
2 · slice ไม่ได้เก็บข้อมูล
Go spec (เอกสารที่กำหนดกฎของภาษาอย่างเป็นทางการ) เรียก slice ว่า descriptor — แปลตรง ๆ ว่า "ใบบอกรายละเอียด" ของ array ก้อนหนึ่ง — blog ทางการของ Go อธิบายว่ามันคือ โครงสร้าง 3 ช่องที่เรียกว่า slice header:
| ช่อง | ความหมาย | อ่านค่าด้วย |
|---|---|---|
Data | ที่อยู่ของ array จริง — slice ไม่ได้เก็บข้อมูลเอง | — |
Len | ตอนนี้ใช้อยู่กี่ช่อง | len(s) |
Cap | จองไว้ทั้งหมดกี่ช่อง (นับจาก Data ไปจนสุด array) | cap(s) |
กฎเหล็กที่ต้องท่องได้: 0 <= len <= cap เสมอ
จาก Go Slices: usage and internals: "Slicing does not copy the slice's data. It creates a new slice value that points to the original array."
แปลว่า orders[0:3] ไม่ใช่การก็อป — มันคือการ
"ยืม array เดิมมาใช้ร่วมกัน" แค่ทำ header ใบใหม่ที่ชี้ไปที่เดิม
นึกถึงโต๊ะยาว 5 ที่นั่งในร้านอาหาร พนักงานจดใบจองให้คุณว่า
"โต๊ะ A เริ่มเก้าอี้ตัวที่ 1 · ใช้อยู่ 3 ที่ · ร้านกันไว้ให้ 5 ที่"
สามข้อความนี้ตรงกับ 3 ช่องของ header ข้างบนพอดี —
Data (โต๊ะไหน เริ่มเก้าอี้ตัวไหน) · Len = 3 · Cap = 5
ใบจองใบนั้นไม่ใช่โต๊ะ เป็นแค่กระดาษที่บอกว่าโต๊ะไหน เริ่มตรงไหน กี่ที่
ถ่ายเอกสารใบจองแจกเพื่อนอีก 10 ใบ โต๊ะก็ยังมีตัวเดียว
slice คือใบจอง · array คือตัวโต๊ะ ·
orders[0:3] คือการเขียนใบจองใบใหม่ที่ชี้ไปโต๊ะตัวเดิม
ไม่ใช่การยกโต๊ะตัวใหม่มาให้ — นี่คือเหตุผลที่ top ไปทำ all พังได้
ที่ไม่เหมือนกัน: ที่ร้านจริง ใบจองสองใบทับที่กัน พนักงานจะทักทันที แต่ Go ไม่มีใครทัก — compiler ปล่อยผ่าน รู้ตัวอีกทีตอนข้อมูลเพี้ยนแล้ว
คำพวกนี้คือคำที่ dev เขาใช้เรียกสิ่งที่คุณเพิ่งเข้าใจไป — ใช้คำให้ตรง จะได้คำตอบที่ตรง
- underlying array
- "ตัวโต๊ะ" — array ก้อนจริงที่เก็บข้อมูลอยู่ ซึ่ง slice แค่ชี้ไปหา · ประโยคที่เอาไปใช้ได้เลย: "สอง slice นี้มัน share underlying array กันอยู่รึเปล่า" จะสื่อกว่าถามว่า "มันเกี่ยวกันไหม"
- slice header
- "ใบจอง" — โครงสร้าง 3 ช่อง (
Data/Len/Cap) ที่ตัวแปร slice เก็บไว้จริง ๆ · ส่ง slice เข้าฟังก์ชัน สิ่งที่ถูกก็อปคือใบนี้ ไม่ใช่ข้อมูล - Go spec
- เอกสารกำหนดกฎของภาษาอย่างเป็นทางการ · ถ้าเถียงกันไม่จบว่าภาษามันทำงานยังไง คำตอบสุดท้ายอยู่ที่นี่ — พูดว่า "Go spec เขียนไว้ว่ายังไง" ได้เลย
3 · ลองทำนายดู
s := []int{10, 20, 30, 40, 50}
sub := s[1:3]
sub[0] = 777
fmt.Println(s)
คิดในใจก่อน (อย่าเพิ่งกดเฉลย) — s พิมพ์ออกมาว่าอะไร?
[10 777 30 40 50]
sub ชี้เข้าไปที่ array เดิมโดยเริ่มที่ index 1 → sub[0]
คือ s[1] คนละชื่อ แต่เป็นช่องเดียวกันในหน่วยความจำ
ตรงนี้ยังพอเดาได้ ข้อต่อไปคือข้อที่หลอกจริง
4 · กุญแจของเรื่องคือ cap
ตอนทำ subslice s[low:high] Go spec
(Slice expressions)
กำหนดไว้ชัดว่า:
สังเกตว่า high ไม่มีผลกับ cap เลย —
subslice (slice ที่ตัดออกมาจาก slice เดิม เช่น s[1:3]) จึง
"มองเห็น" ช่องที่เหลือทางขวาของ array เดิมทั้งหมด
อ่าน diagram นี้จากบนลงล่าง: แถวบนคือ array จริง แถวล่างคือขอบเขตที่ sub เห็น
ช่วง [3] กับ [4] คือส่วนที่ sub ยังนับว่าเป็นของตัวเอง
ทั้งที่มีเลข 40 กับ 50 ของ s นั่งอยู่ — นี่คือที่ที่ append จะไปเขียนทับ
ทีนี้ append ทำงานแบบนี้: ถ้า cap ยังเหลือ → เขียนลงช่องถัดไป
ของ array เดิมทันที (ไม่ย้ายบ้าน) ส่วน "ช่องถัดไป" ของ sub
ก็คือ s[3] ที่มีเลข 40 อยู่ นั่นแหละ
กลับไปที่โต๊ะยาว 5 ที่นั่งตัวเดิม คราวนี้เขียนใบจองใบใหม่ (s[1:3]) ว่า
"โต๊ะ A เริ่มเก้าอี้ตัวที่ 2 · ใช้อยู่ 2 ที่ · ร้านกันไว้ให้ 4 ที่"
· len = คนที่นั่งอยู่จริงตอนนี้ → 2 คน
· cap = ร้านกันเก้าอี้ให้ถึงไหน → จองแบบสองเลข
(s[1:3]) ร้านจะกันให้ยาวจนสุดปลายโต๊ะโดยปริยาย — นับจากเก้าอี้
ตัวที่คุณเริ่มได้ 4 ตัว · เดี๋ยวหัวข้อ 5 จะสอนวิธีสั่งให้ร้านกันสั้นกว่านี้
ทีนี้เพื่อนมาเพิ่มอีกคน (นั่นคือ append) ในเมื่อร้านกันที่ไว้ให้ยังเหลือ
เขาก็นั่งเก้าอี้ตัวถัดไปได้เลย ทั้งที่เก้าอี้ตัวนั้นมีกระเป๋าของใบจองอีกใบ
บนโต๊ะตัวเดียวกันนี้วางอยู่ (ใบจองใบเดิมคือ s ที่จองยาวทั้งโต๊ะ)
กระเป๋าใบนั้นเลยโดนปัดตกไป — คือเลข 40 ที่ s[3] หายไปนั่นเอง
ที่ไม่เหมือนกัน: สามข้อ —
(1) ของจริงเจ้าของกระเป๋าโวยทันที แต่ในหน่วยความจำเงียบสนิท ·
(2) การกันที่นับไปทางขวาอย่างเดียว คนที่เริ่มเก้าอี้ตัวที่ 2
ยุ่งกับตัวที่ 1 ไม่ได้เลย ·
(3) "กันจนสุดปลายโต๊ะ" เป็นแค่ค่าตั้งต้น ไม่ใช่กฎตายตัว —
เขียนใบจองแบบสามเลข s[1:3:3] ร้านจะกันให้แค่ 2 ที่ ไม่ถึงปลายโต๊ะ
s := []int{10, 20, 30, 40, 50}
c := s[1:3]
c = append(c, 999)
fmt.Println(s)
fmt.Println(c)
คราวนี้ s กับ c พิมพ์ออกมาว่าอะไร?
s → [10 20 30 999 50]
c → [20 30 999]
cap(c) = 4 แต่ len(c) = 2 → ยังเหลือที่ →
append เขียนทับ s[3] เลข 40 ตายตรงนี้
โดยไม่มี error ไม่มี warning ไม่มีอะไรทั้งนั้น
subslice ไม่ใช่การก็อป — และ append ที่ cap ยังเหลือ
จะเขียนทับข้อมูลของคนอื่น
- capacity (cap)
- "ร้านกันที่ให้ถึงไหน" — เพดานที่ slice โตได้โดยไม่ต้องย้ายที่ · เวลาสงสัยว่าโค้ดจะพังไหม
ให้ถามตรง ๆ ว่า "ตรงนี้ cap เท่าไหร่" ไม่ใช่ "มีกี่ตัว" (นั่นคือ
len) - aliasing
- อาการที่ของก้อนเดียวมีสองชื่อ แก้ชื่อหนึ่งแล้วอีกชื่อเปลี่ยนตาม · นี่คือชื่อโรคของทั้งบทนี้ · พิมพ์ถาม AI ว่า "slice aliasing bug in Go, why does append overwrite the original" จะเจอคำตอบตรงจุดทันที
- grow / reallocate
- ตอนที่
append"ย้ายบ้าน" — จอง array ก้อนใหม่ที่ใหญ่กว่า แล้วก็อปของเก่าตามไป · ประโยคที่ใช้ได้จริง: "append ตรงนี้มัน reallocate รึเปล่า ถ้าไม่ มันไปทับของเดิมนะ"
5 · ทางแก้ 2 แบบ
แบบที่ 1 — Three-index slice s[low:high:max]
รูปแบบเต็มของ slice expression มี 3 ตัวเลข ตัวที่สามคุม cap โดยตรง:
c := s[1:3:3] // len = 3−1 = 2, cap = 3−1 = 2 → เต็มพอดี
c = append(c, 999) // cap ไม่พอ → Go จองบ้านใหม่ให้
fmt.Println(s) // [10 20 30 40 50] ✓ ปลอดภัย
fmt.Println(c) // [20 30 999]
พอ cap เต็ม append ไม่มีทางเลือก ต้องจอง array ใหม่ +
ก็อปของเก่าไปใส่ → ขาดจากกันทันที
แบบที่ 2 — copy() ให้ขาดไปเลย
c := make([]int, 2)
copy(c, s[1:3]) // copy(ปลายทาง, ต้นทาง)
Three-index s[1:3:3] | copy() | |
|---|---|---|
ยังใช้ array ก้อนเดียวกับ s ไหม | ใช่ จนกว่าจะ append | ไม่ ขาดตั้งแต่แรก |
append(c, x) ไปทับ s ได้ไหม | ❌ ไม่ได้ (cap เต็ม → จองใหม่) | ❌ ไม่ได้ |
c[0] = x ไปทับ s ได้ไหม | ⚠️ ได้ — c[0] ยังเป็นช่องเดียวกับ s[1] | ❌ ไม่ได้ |
| เสียแรมเพิ่มไหม | ไม่เสีย จนกว่าจะ append | เสียทันที (ก็อปของ) |
| ใช้เมื่อ | ส่ง slice ออกจากฟังก์ชัน แล้วห่วงแค่ว่าคนรับจะ append | ต้องการอิสระเต็มที่ คนรับทำอะไรก็ไม่กระทบเรา |
copy() ยังแก้อีกปัญหาด้วย ปกติ Go มีตัวเก็บขยะอัตโนมัติชื่อ
Garbage Collector (GC) คอยคืนหน่วยความจำที่ไม่มีใครใช้แล้วให้ระบบ
กฎของมันง่ายมาก: ถ้ายังมีใครชี้อยู่ = ยังเก็บไม่ได้
ทีนี้ถ้าคุณ subslice ข้อมูลแค่ 10 ตัวออกจาก array ขนาด 100 MB
slice ใบเล็กนั้นยังมีช่อง Data ชี้กลับไปที่ array ก้อนใหญ่อยู่
GC จึงคืน 100 MB นั้นไม่ได้เลยตราบใดที่ slice ใบเล็กยังมีชีวิต
ใช้ 10 ตัวแต่จ่ายค่าแรม 100 MB — blog ทางการเรียกอาการนี้ว่า
"The full array will be kept in memory until it is no longer referenced."
เหลือเค้กอยู่ชิ้นเดียว แต่คุณยัดทั้งกล่องเข้าตู้เย็นเพราะขี้เกียจหาจาน
กล่องใบใหญ่เลยกินพื้นที่ไปครึ่งชั้น ทั้งที่ของที่จะกินจริงมีนิดเดียว —
และตราบใดที่ยังไม่ย้ายเค้กใส่จานเล็ก กล่องนั้นก็ทิ้งไม่ได้
slice ใบเล็กที่ตัดออกมา = เค้กชิ้นเดียว · array ก้อน 100 MB = กล่องใบใหญ่ ·
copy() = ย้ายเค้กใส่จานเล็ก — ส่วนกล่องใบใหญ่จะทิ้งได้
ก็ต่อเมื่อไม่เหลือใครชี้ไปหามันอีกเลย
ที่ไม่เหมือนกัน: สองข้อ —
(1) ตู้เย็นเต็มเราเปิดดูก็เห็น แต่แรมที่ค้างแบบนี้มองด้วยตาไม่เห็น
โปรแกรมก็ไม่พังด้วย มันแค่กินแรมมากกว่าที่ควรเงียบ ๆ ต้องใช้เครื่องมือวัดถึงจะจับได้ ·
(2) ที่บ้าน ย้ายเค้กเสร็จเราเดินไปทิ้งกล่องเองได้เลย แต่ Go สั่ง GC ให้ทิ้งตรง ๆ ไม่ได้ —
แค่เรียก copy() แรม 100 MB ยังไม่ถูกคืน ถ้า slice ตัวเดิมยังมีชีวิตอยู่ในสโคป
(ในโค้ดข้างบน s ก็ยังอยู่) · จะคืนได้จริงต้องปล่อยให้ตัวที่ชี้ array ก้อนใหญ่หมดอายุไปให้ครบก่อน
- full slice expression (three-index slice)
- ชื่อทางการของ
s[low:high:max]· ถามว่า "ตัดสามเลขคืออะไร" senior อาจงง แต่ "ใช้ full slice expression ล็อก cap ไว้ดีไหม" เขาจะรู้ทันที - defensive copy
- "ก็อปไว้ก่อนกันเหนียว" — ก็อปข้อมูลก่อนส่งให้คนอื่น จะได้ไม่มีใครมาแก้ของเราได้ ·
copy()ในหัวข้อนี้คือ defensive copy ตรงตัว - memory retention
- อาการแรมค้างเพราะยังมีคนชี้ค้างไว้ (ที่ GC เก็บไม่ได้) · ถามว่า "slice ใบเล็กนี้มัน retain array ก้อนใหญ่ไว้อยู่รึเปล่า" senior จะรู้ทันทีว่าคุณห่วงเรื่องแรม ไม่ใช่เรื่องค่าผิด · เวลาค้นให้พิมพ์ว่า Go slice memory leak จะเจอตรงกว่า — คำว่า memory retention เฉย ๆ มักไปโผล่เรื่อง retention policy ของระบบ log แทน
เคสพิเศษ: slice ที่ยังไม่มี array
var s []int ให้ slice header ที่ทั้งสามช่องว่างเปล่า —
Data ไม่ชี้ไปไหน (ค่านี้เรียกว่า nil), Len = 0,
Cap = 0 เรียกกันว่า nil slice
ข้อดีคือมันใช้งานได้เกือบทุกอย่างทันที ไม่ต้อง make ก่อน ·
len(s) ได้ 0 · for range s วนศูนย์รอบเงียบ ๆ ·
append(s, 1) ก็ทำได้
ที่ append ทำได้เพราะ cap = 0 ไม่พอกับ len ใหม่ →
append ก็จองบ้านใหม่ให้อยู่แล้ว (กฎเดิมจากหัวข้อ 4)
สิ่งเดียวที่ทำไม่ได้คือเข้าถึงด้วย index — s[0]
จะทำให้โปรแกรมหยุดกลางคัน (Go เรียกอาการนี้ว่า panic จะเจอเต็ม ๆ ในบทที่ 08)
เพราะ len เป็น 0 ไม่มีช่องที่ 0 ให้แตะ
6 · ทดสอบความจำ
อย่าเลื่อนกลับไปดูนะครับ ตอบจากหัวล้วน ๆ — ตอบผิดตรงนี้ดีกว่าไปผิดตอน review
1. s มี len=6 cap=6 แล้ว x := s[2:4] — cap(x) เป็นเท่าไหร่?
cap = cap(เดิม) − low = 6 − 2 = 4 — ตัวเลข high
(เลข 4) ไม่เกี่ยวกับ cap เลย มันคุมแค่ len เท่านั้น
(len(x) = 4−2 = 2)
2. append จะสร้าง array ก้อนใหม่ก็ต่อเมื่อ…
cap คือเพดาน ถ้าใส่แล้วยังไม่เกินเพดาน มันเขียนลงที่เดิม (เร็วกว่ามาก) — และนั่นแหละคือตอนที่ข้อมูลคนอื่นโดนทับ ถ้าเกินเพดานเมื่อไหร่ ถึงจะจองใหม่ ซึ่งกลายเป็นปลอดภัยโดยบังเอิญ
3. อยากให้ x := s[2:4] ปลอดภัยจากการ append ทับ ต้องเขียนยังไง?
s[2:4:4] → len = 4−2 = 2, cap = 4−2 = 2
เท่ากันพอดี = ไม่เหลือที่ให้ append เขียนทับ
ส่วน s[2:4:6] ให้ cap = 6−2 = 4
ซึ่งยังเหลือที่ → ยังอันตรายเหมือนเดิม
4. var s []int (nil slice) — ข้อไหนผิด?
nil slice ใช้ได้เกือบทุกอย่าง ยกเว้นเข้าถึงด้วย index —
s[0] จะ panic เพราะ len เป็น 0 (index out of range)
จำแยกจาก nil map ให้ดี: nil map
อ่านได้ แต่ เขียนแล้ว panic ส่วน nil slice append
ได้สบาย
7 · ลงมือทำ
ภารกิจ: ซ่อม topThree
- เปิด Go Playground
- วางโค้ดจากหัวข้อ 1 ลงไป รันดูให้เห็นบั๊กด้วยตาตัวเอง
- เพิ่มบรรทัด
fmt.Println(len(top), cap(top))ก่อนappend— ยืนยันว่า cap คือ 5 ไม่ใช่ 3 - ซ่อมด้วย three-index ก่อน รันใหม่ ต้องได้
[10 20 30 40 50] - ซ่อมอีกรอบด้วย
copy()แทน เทียบดูว่าต่างกันตรงไหน - โจทย์ท้าทาย: เขียน
func safeSlice(s []int, lo, hi int) []intที่คืน slice ซึ่งไม่มีทางไปแตะsได้เลย แล้วทดสอบด้วยโค้ดชุดนี้ — ต้องผ่านทั้งสองข้อ:
ถ้าs := []int{1, 2, 3, 4, 5} x := safeSlice(s, 1, 3) x[0] = 777 // ทดสอบ 1 ← three-index อย่างเดียวจะตกข้อนี้ x = append(x, 999) // ทดสอบ 2 fmt.Println(s) // ต้องได้ [1 2 3 4 5] เป๊ะ ๆsเปลี่ยนแม้แต่ช่องเดียว แปลว่ายังไม่ขาดจากกันจริง ลำดับสองบรรทัดนี้สำคัญ — ถ้าappendก่อน แล้วค่อยx[0]=777ตัว three-index จะรอดไปได้แบบฟลุ๊ค เพราะappendย้ายบ้านให้แล้ว
ต่อไปเวลาเห็น return xs[a:b] ในโค้ดใคร คุณจะสะดุดทันทีและถามว่า
"คนรับ append ต่อได้ไหม แล้วมันจะไปทับอะไรของเรารึเปล่า" — นั่นคือสายตา
ของคน review Go เป็น
อ่านต่อจากต้นทาง
แหล่งหลักที่แนะนำ: Go Slices: usage and internals — เขียนโดย Andrew Gerrand จากทีม Go เอง ยาวประมาณ 10 นาที มีหัวข้อ "A possible 'gotcha'" ท้ายบทความที่ตรงกับบทเรียนนี้เป๊ะ อ่านให้จบก่อนบทเรียนหน้า
💬 ติดตรงไหนถามได้เลย — ผมเป็นครูประจำ workspace นี้ ถ้าอ่านแล้วยังไม่คลิก บอกมาว่าไม่คลิกตรงไหน เดี๋ยวผมทำ lesson เสริมให้เฉพาะจุด หรือถ้าอยากได้โจทย์เพิ่มก็บอกได้
อ้างอิง
- Go Slices: usage and internals — slice header, การแชร์ array, memory retention
- Go spec — Slice expressions — สูตร len/cap ของ
a[low:high]และa[low:high:max] - The mechanics of 'append' — Rob Pike, ว่าด้วย cap เป็นเพดานของ len