บทเรียน 06 · Go Data Structure

append ไปทับข้อมูลคนอื่นได้ยังไง

~15 นาที · ต้องมี: รู้จัก []int และ append คร่าว ๆ

บั๊ก Go ที่เจ็บที่สุดสำหรับมือใหม่คือบั๊กที่ compiler ไม่เตือน เทสต์ผ่าน แต่ข้อมูลเพี้ยนบน production เกือบทั้งหมดมาจากเรื่องเดียว — slice สองตัวแอบใช้ array ก้อนเดียวกันอยู่ จบบทนี้คุณจะมองออกทันทีว่าโค้ดบรรทัดไหน กำลังจะระเบิด

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:

Slice Header (24 bytes) Underlying Array (ของจริงอยู่ตรงนี้) ┌─────────────────────┐ ┌────┬────┬────┬────┬────┐ │ Data ───────────────┼─────────────▶│ 10 │ 20 │ 30 │ 40 │ 50 │ │ Len = 3 │ └────┴────┴────┴────┴────┘ │ Cap = 5 │ [0] [1] [2] [3] [4] └─────────────────────┘
ช่องความหมายอ่านค่าด้วย
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) กำหนดไว้ชัดว่า:

len ของตัวใหม่ = high − low ← อันนี้ทุกคนเดาถูก cap ของตัวใหม่ = cap(เดิม) − low ← อันนี้แหละตัวปัญหา

สังเกตว่า high ไม่มีผลกับ cap เลย — subslice (slice ที่ตัดออกมาจาก slice เดิม เช่น s[1:3]) จึง "มองเห็น" ช่องที่เหลือทางขวาของ array เดิมทั้งหมด

อ่าน diagram นี้จากบนลงล่าง: แถวบนคือ array จริง แถวล่างคือขอบเขตที่ sub เห็น

s := []int{10, 20, 30, 40, 50} len=5 cap=5 sub := s[1:3] len=2 cap=4 ← 5−1 = 4 ! [0] [1] [2] [3] [4] ┌─────┬─────┬─────┬─────┬─────┐ s ──▶│ 10 │ 20 │ 30 │ 40 │ 50 │ └─────┴─────┴─────┴─────┴─────┘ ▲ sub ───────┘ เริ่มที่ [1] │◀── len=2 ──▶│ │ │ sub ใช้อยู่ │ │ │◀──────── cap=4 ──────────────▶│ │◀── ที่ sub ยัง ──▶│ │ "มองเห็น" อยู่ │ │ = [3] และ [4] │

ช่วง [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 โดยตรง:

len = high − low cap = max − low ← เพิ่มตัวนี้เข้ามาคุมได้เอง
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

  1. เปิด Go Playground
  2. วางโค้ดจากหัวข้อ 1 ลงไป รันดูให้เห็นบั๊กด้วยตาตัวเอง
  3. เพิ่มบรรทัด fmt.Println(len(top), cap(top)) ก่อน append — ยืนยันว่า cap คือ 5 ไม่ใช่ 3
  4. ซ่อมด้วย three-index ก่อน รันใหม่ ต้องได้ [10 20 30 40 50]
  5. ซ่อมอีกรอบด้วย copy() แทน เทียบดูว่าต่างกันตรงไหน
  6. โจทย์ท้าทาย: เขียน 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 เสริมให้เฉพาะจุด หรือถ้าอยากได้โจทย์เพิ่มก็บอกได้

อ้างอิง