บทที่ 8 · Part 3 — Language and Data Semantics
Values, Pointers and Ownership
เข้าใจ copy, aliasing, receiver, zero value และสัญญาว่าใครแก้หรือเก็บข้อมูลได้
bug ที่หาได้ยากใน Go จำนวนมากไม่ได้เกิดจาก pointer arithmetic แต่เกิดจากคน 2 ฝั่งเข้าใจคำว่า “ส่งค่าไปแล้ว” ไม่เหมือนกัน struct ถูก copy แต่ slice ยังชี้ backing array เดิม หรือ method ใช้ value receiver ทั้งที่กำลัง copy mutex โดยไม่ตั้งใจ บทนี้จึงมอง pointer เป็นเรื่อง contract และ ownership ก่อนมองเป็นเรื่อง performance
จบบทนี้คุณจะ
- อธิบายได้ว่า value ใดถูก copy และข้อมูลใดยัง alias กัน
- เลือก function parameter เป็น value, pointer หรือ stream จาก contract ได้
- เลือก pointer/value receiver จาก semantics ของ type
- ประกาศ ownership ของ slice, map และ output โดยไม่ให้ caller เดา
Copy ไม่ได้แปลว่า Independent เสมอ
เมื่อส่ง struct แบบ value Go copy field ทุก field แต่ถ้า field เป็น slice, map, pointer, channel หรือ function ค่าที่ copy ยังอ้างข้อมูลหรือ runtime object เดิม:
type Batch struct {
IDs []string
}
func Prefix(ids []string) []string {
out := slices.Clone(ids)
for i := range out {
out[i] = "batch:" + out[i]
}
return out
}
การ Clone ในตัวอย่างไม่ใช่พิธีกรรม แต่ทำตาม contract ว่า function จะไม่แก้ input ถ้า function
อนุญาตให้ reuse buffer เพื่อ performance ควรตั้งชื่อหรือ document ให้ชัดและมี benchmark รองรับ
map ยิ่งชัดกว่า: การ assign หรือส่งเป็น parameter ไม่ได้ copy entries หากต้องคืน snapshot ที่ caller
แก้ได้ ใช้ maps.Clone หรือสร้าง DTO ใหม่ ส่วน string เป็น immutable value แม้ implementation อาจ
แชร์ bytes ภายในได้ ผู้ใช้ไม่สามารถ mutate string ผ่าน API ปกติ
Function Parameters: Value, Pointer หรือ Stream
Go ส่ง argument ทุกตัวแบบ pass by value เสมอ การส่ง *Batch จึงไม่ใช่การส่ง object โดยตรง
แต่เป็นการ copy pointer ที่ชี้ไปยัง Batch อีกที คำถามจึงไม่ควรมีเพียง “ข้อมูลใหญ่ไหม” แต่ต้องถาม
ก่อนว่า callee แก้ค่าได้หรือไม่, object มี identity หรือไม่, nil มีความหมายหรือไม่ และการ share
ข้อมูลหลัง call เป็นส่วนหนึ่งของ contract หรือเปล่า
| Input | Course default | เหตุผลหรือเงื่อนไขเปลี่ยน |
|---|---|---|
int, bool, string, time.Time | value | เป็น value ขนาดเล็ก; *string ไม่ได้ทำให้ API ดีขึ้นเอง |
| small immutable struct | value | caller รู้ว่า function ได้ snapshot และแก้ต้นฉบับไม่ได้ |
| slice, map, channel, function | value | ตัวที่ copy เป็น descriptor; ไม่ต้องเพิ่ม pointer อีกชั้น |
| struct ที่ต้อง mutate หรือมี identity/lifecycle | pointer | การเปลี่ยนแปลงต้องเห็นที่ object ต้นฉบับ |
| optional value ที่ absence มีความหมาย | pointer หรือ explicit option type | เลือกตาม boundary contract ไม่ใช่เพื่อประหยัด byte |
| large struct หรือ large array | พิจารณา pointer | ตรวจขนาด, call frequency, escape analysis และ benchmark |
| payload ใหญ่มากหรือไม่ควรอยู่ใน memory พร้อมกัน | io.Reader หรือ iterator | streaming จำกัด memory ได้ดีกว่าการส่ง pointer ไปยังก้อนข้อมูล |
// Small immutable domain input: copy ทั้งค่าแล้วอ่านอย่างเดียว
func CalculateFee(input FeeInput) int64
// Config ถูกแก้โดยเจตนา
func ApplyDefaults(cfg *Config)
// []byte เป็น slice descriptor อยู่แล้ว ไม่ใช้ *[]byte
func Decode(data []byte) (Message, error)
// Import ข้อมูลขนาดใหญ่โดยไม่บังคับให้ caller โหลดทั้งหมดก่อน
func ImportTransactions(r io.Reader) error
อย่าใช้ตัวเลขเช่น 128 bytes เป็นกฎของภาษา มันเป็นเพียงจุดเริ่มสำหรับตั้งสมมติฐาน เพราะ compiler
อาจ inline function, เก็บ value บน stack หรือทำให้ pointer escape ไป heap ต่างกันตาม code รอบข้าง
ถ้า type มี sync.Mutex, sync.Once หรือ state ที่ห้าม copy หลังเริ่มใช้งาน ให้ใช้ pointer ด้วยเหตุผล
ด้าน correctness ทันที ไม่ต้องรอ benchmark
Large data ไม่เท่ากับ large parameter
[]byte ขนาด 100 MB ยังส่ง slice header ขนาดคงที่เข้า function; bytes ไม่ได้ถูก copy ทั้งก้อน
แต่ caller และ callee จะเห็น backing array เดียวกัน ปัญหาหลักจึงเป็น ownership และ memory
retention ถ้าต้อง process แบบ bounded memory ให้เปลี่ยน contract เป็น stream ไม่ใช่ *[]byte
Pointer บอก Semantics ไม่ใช่คำว่าเร็ว
ใช้ pointer receiver เมื่อ method mutate receiver, type มี identity/lifecycle, type ใหญ่พอที่ copy
มีนัยสำคัญ หรือ method set ต้องสม่ำเสมอกับ method อื่น ใช้ value receiver เมื่อ type เล็ก immutable
และทุก copy เป็นค่าที่สมบูรณ์ เช่น time.Time
อย่าเลือก pointer เพราะคิดว่า “เร็วกว่าเสมอ” pointer อาจทำให้ value escape ไป heap และเพิ่มงาน GC
ต้องวัดก่อนสรุป ในทางกลับกัน type ที่มี sync.Mutex, sync.Once, atomic หรือ internal pointer
ที่ห้าม copy หลังใช้งานควรใช้ pointer และป้องกัน copy; go vet ช่วยหา copylock บางรูปแบบได้
type Registry struct {
mu sync.RWMutex
batches map[string]Batch
}
func (r *Registry) Put(batch Batch) {
r.mu.Lock()
defer r.mu.Unlock()
r.batches[batch.ID] = batch
}
อย่าผสม pointer และ value receiver บน type เดียวเพียงเพราะบาง method ไม่ mutate เพราะ caller จะต้องจำ method set และเกิด accidental copy ได้ง่ายกว่า
Zero Value และ Nil Contract
zero value ที่ใช้ได้ลด constructor และ state ที่ต้องจำ เช่น bytes.Buffer และ sync.Mutex
แต่ไม่ต้องบิด invariant เพื่อให้ทุก type มี useful zero value ถ้า Service ต้องมี store และ clock
การใช้ constructor ที่ validate dependency ดีกว่าปล่อย nil panic ตอนรับ request
nil slice ใช้ len, range และ append ได้ แต่ JSON อาจต่างจาก empty slice; nil map อ่านได้
แต่เขียนแล้ว panic ดังนั้น boundary ต้องประกาศ contract:
| จุดใช้งาน | Course default |
|---|---|
| internal read-only slice | ยอมรับ nil ถ้า caller ไม่ต้องแยกความหมาย |
| JSON array response | ส่ง empty slice เมื่อ schema บอกว่าเป็น array |
| optional object | ใช้ pointer เมื่อ absence มีความหมายจริง |
| mutable map field | initialize ใน constructor หรือก่อน write |
ปัญหา nil interface เกิดเมื่อ interface เก็บ dynamic type ที่มีค่า pointer เป็น nil ค่า interface
นั้นไม่เท่ากับ nil จึงไม่ควร return typed nil ผ่าน interface:
func newStore(disabled bool) Store {
if disabled {
return nil
}
return &mysqlStore{}
}
เขียน Ownership ลงใน API
สำหรับทุก slice/map/buffer ให้ตอบ 3 คำถาม: caller แก้ input หลัง call ได้ไหม, callee จะ retain input หลัง return ไหม และ caller แก้ output ได้ไหม ทางเลือกมี copy, transfer ownership หรือ borrow ชั่วคราว ไม่มีแบบใดดีที่สุดทุกกรณี แต่ความกำกวมแย่เสมอ
Production Toolbox
ใช้ slices.Clone, maps.Clone และ copy เมื่อ contract ต้องแยก ownership ใช้ pointer เมื่อ
semantics ต้องมี mutation/identity และใช้ benchmark/escape analysis เมื่อตัดสิน performance
อย่าเพิ่ม deep-copy library ถ้า data shape เล็กและชัด เพราะ policy ว่า field ใดควร copy มักเป็น
domain decision ที่ reflection เดาแทนไม่ได้
Checklist ก่อนผ่าน Review
- receiver ทั้ง type สม่ำเสมอและไม่ copy lock
- function parameter เลือกจาก mutation, identity, nil และ ownership ก่อน performance
- slice, map และ interface ไม่ถูกเพิ่ม pointer อีกชั้นโดยไม่มี contract ที่ชัด
- API บอกว่า input/output ถูก mutate หรือ retain หรือไม่
- slice และ map ที่ต้อง independent ถูก clone ที่ boundary
- nil กับ empty มีความหมายตาม serialization/schema ไม่ใช่ตามความชอบ
- constructor ใช้เมื่อมี invariant/dependency; zero value ใช้เมื่อปลอดภัยจริง
- เหตุผลเรื่อง pointer performance มี measurement ไม่ใช่ intuition
อ่านเพิ่ม: Go FAQ — pass by value, Go slices: usage and internals, Go Code Review Comments — receiver type และ Uber Go Style — copying slices and maps