Pointers
Pointer คืออะไร?
หัวข้อที่มีชื่อว่า “Pointer คืออะไร?”Pointer คือตัวแปรที่เก็บ memory address ของตัวแปรอื่น type ของ pointer ไปยัง T เขียนว่า *T:
x := 42p := &x // & takes the address of x; p has type *intเพื่ออ่านหรือเขียนผ่าน pointer ให้ dereference ด้วย *:
fmt.Println(*p) // 42 — read through p*p = 100 // write through p — x is now 100fmt.Println(x) // 100& และ * เป็น inverse กัน
หัวข้อที่มีชื่อว่า “& และ * เป็น inverse กัน”| Operator | อ่านว่า | ผล |
|---|---|---|
&x | ”address of x” | คืน *T ที่ชี้ไป x |
*p | ”value at p” | dereference p อ่านหรือเขียน T |
เมื่อไรควรใช้ pointer
หัวข้อที่มีชื่อว่า “เมื่อไรควรใช้ pointer”1. Mutation ข้ามขอบเขตของ function. Go ส่ง argument ทุกตัวเป็น value หาก function ต้องแก้ไขตัวแปรของ caller ให้ส่ง pointer:
func double(n *int) { *n = *n * 2}x := 5double(&x)fmt.Println(x) // 102. Struct ขนาดใหญ่. การ copy struct ขนาดใหญ่ทุกครั้งที่เรียก function นั้นสิ้นเปลือง ส่ง *Config แทน Config เมื่อ struct มีหลาย field
3. Optional value. *T สามารถเป็น nil เพื่อแทน “ไม่ได้กำหนด” คล้ายกับ null ในภาษาอื่น นี่คือการใช้งานคลาสสิกของอะไรแบบ *bool (สามสถานะ: true, false, ยังไม่กำหนด) — แต่เมื่อ type ที่จะห่อด้วย pointer เป็น struct หรือ time.Time เอง convention แบบ “มีค่าอยู่หรือเปล่า” เฉพาะทางอย่าง omitzero (Go 1.24) ของ encoding/json ช่วยเลี่ยง pointer และ nil check ไปได้เลย ดู stdlib module สำหรับการเปรียบเทียบนั้น
Escape analysis: ทำไม pointer ไม่ได้เร็วกว่าเสมอไป
หัวข้อที่มีชื่อว่า “Escape analysis: ทำไม pointer ไม่ได้เร็วกว่าเสมอไป”“ส่ง pointer เพื่อเลี่ยง copy” เป็นเรื่องจริง แต่มันแลกต้นทุนหนึ่งกับอีกต้นทุนหนึ่ง: value ที่ pointer ชี้ไปต้องอยู่ในที่ที่มีชีวิตยืนกว่าการเรียก function ถ้า pointer นั้นหลุดออกจาก function (“escape”) — และ “ที่” นั้นคือ heap ซึ่ง garbage collector ต้องคอย track ต่อไป Compiler ตัดสินใจเรื่องนี้อัตโนมัติด้วย escape analysis: ถ้าพิสูจน์ได้ว่า value ไม่มีชีวิตอยู่เกิน function ของมัน มันจะอยู่บน stack (allocate ฟรี, reclaim ฟรี); ถ้าพิสูจน์ไม่ได้ มัน “escape” ไปยัง heap
type Config struct{ Workers int }
// newConfig returns a pointer to a local variable — it must escape to the heapfunc newConfig() *Config { c := Config{Workers: 4} return &c}go build -gcflags=-m print เหตุผลของ compiler เองออกมา verify บน 1.26.5 แล้ว:
./main.go:8:6: can inline newConfig./main.go:13:6: can inline sum./main.go:18:18: inlining call to newConfig./main.go:19:30: inlining call to sum./main.go:19:13: inlining call to fmt.Println./main.go:9:2: moved to heap: c./main.go:19:13: ... argument does not escape./main.go:19:17: cfg.Workers escapes to heap./main.go:19:30: ~r0 escapes to heapบรรทัดที่สำคัญคือ moved to heap: c — compiler กำลังบอกเหตุผลตรง ๆ ว่าทำไม newConfig เก็บ c ไว้บน stack ไม่ได้: address ของมันถูก return ออกไป ดังนั้นมันต้องมีชีวิตอยู่เกิน function นี่คือเหตุผลที่ “return &T{} ทุกที่ เผื่อไว้ก่อน” — pattern ที่โค้ด AI-generated Go พึ่งพาหนักเพราะดูปลอดภัยเชิงป้องกัน — ไม่ได้ฟรี: pointer แต่ละตัวคือ heap allocation และ object ที่ GC ต้อง track แม้ caller จะอ่านแค่ field เดียวครั้งเดียวแล้วทิ้งก็ตาม การ return value ตรง ๆ (Config ไม่ใช่ *Config) เมื่อ struct เล็กและ caller ไม่ต้อง mutate ต้นฉบับ ทำให้ compiler เก็บมันไว้บน stack ได้
Shorthand สำหรับ struct pointer
หัวข้อที่มีชื่อว่า “Shorthand สำหรับ struct pointer”เมื่อมี pointer ไปยัง struct Go จะ dereference ให้อัตโนมัติสำหรับการเข้าถึง field คุณไม่ต้องเขียน (*cfg).Debug:
cfg := &Config{Workers: 4}cfg.Debug = true // shorthand for (*cfg).Debug = truenew allocate zero value
หัวข้อที่มีชื่อว่า “new allocate zero value”Built-in new(T) allocate zero value ของ type T และคืน *T:
n := new(int) // *int pointing to 0*n = 7new พร้อมค่าเริ่มต้น (Go 1.26)
หัวข้อที่มีชื่อว่า “new พร้อมค่าเริ่มต้น (Go 1.26)”ตั้งแต่ Go 1.26 new รับ expression ได้ด้วย และคืน pointer ที่ชี้ไปยัง copy ของค่านั้น หมดยุคที่ต้องประกาศตัวแปรก่อนแค่เพื่อเอา address ซึ่งพบบ่อยกับ optional field ใน struct:
type Person struct { Name string Age *int // optional}
yearsSince := func(y int) int { return 2026 - y }born := 1990
p := Person{Name: "Ada", Age: new(36)} // *int pointing to 36q := Person{Name: "Bob", Age: new(yearsSince(born))} // any expression worksสามวิธีในการได้ pointer ที่เป็น zero value
หัวข้อที่มีชื่อว่า “สามวิธีในการได้ pointer ที่เป็น zero value”&T{}, new(T), และ var t T; &t ทั้งหมดให้ pointer ไปยัง zero value ที่เท่ากัน — แต่ถ้าเทียบด้วย identity มันคือสอง allocation ที่แยกกัน:
p1 := &Config{}p2 := new(Config)var v Configp3 := &v
fmt.Println(*p1 == *p2, *p2 == *p3) // true true — all zero-valuedfmt.Println(p1 == p2) // false — two distinct allocationsverify บน 1.26.5 แล้ว: *p1 == *p2 และ *p2 == *p3 เป็น true ทั้งคู่ (dereference แล้วเท่ากันเพราะเป็น zero value เดียวกัน) แต่ p1 == p2 เป็น false (address คนละที่กัน — new กับ &T{} ไม่เคยคืน pointer ตัวเดียวกันเลย) เรื่อง style: &T{} เป็นตัวเลือก idiomatic เมื่อต้องการ set บาง field ไปในตัวด้วย (&Config{Workers: 4}); new(T) อ่านชัดกว่านิดหน่อยเมื่อต้องการแค่ zero value จริง ๆ ไม่มีอะไรอื่น ทั้งคู่ escape ไป heap ภายใต้กฎเดียวกับ pointer ทั่วไป — ดู escape analysis ด้านบน
Nil pointer
หัวข้อที่มีชื่อว่า “Nil pointer”Zero value ของ pointer type ทุกชนิดคือ nil การ dereference nil pointer ทำให้เกิด runtime panic:
var p *intfmt.Println(p == nil) // true// *p = 1 would panic: nil pointer dereferenceควรตรวจสอบ pointer ว่าเป็น nil ก่อน dereference เสมอ เมื่อ pointer นั้นอาจไม่ได้ถูกกำหนดค่า
Nil pointer receiver เป็น Go ที่ถูกกฎหมาย
หัวข้อที่มีชื่อว่า “Nil pointer receiver เป็น Go ที่ถูกกฎหมาย”การเรียก method บน nil pointer receiver ไม่ panic ในตัวมันเอง — Go panic ก็ต่อเมื่อ method body ไป dereference receiver จริง ๆ เท่านั้น method ที่เช็ค nil ก่อนแล้ว return ออกไปเลยเป็น Go ที่ถูกต้องสมบูรณ์ และเป็น pattern idiomatic ที่พบทั่วไปสำหรับโครงสร้างแบบ recursive เช่น linked list กับ tree:
type List struct { Val int Next *List}
// Sum works on a nil *List receiver — no field is dereferenced when l == nilfunc (l *List) Sum() int { if l == nil { return 0 } return l.Val + l.Next.Sum()}
var l *Listfmt.Println(l.Sum()) // 0 — calling a method on a nil pointer is legal
l = &List{Val: 1, Next: &List{Val: 2}}fmt.Println(l.Sum()) // 3อย่าสับสนกับ typed-nil interface trap: *List ที่เป็น nil เก็บอยู่ใน interface{}/error/interface อื่นใด คือ interface ที่ไม่ nil ที่เก็บ concrete pointer ที่เป็น nil และ iface == nil เป็น false แม้ pointer ข้างในจะเป็น nil ก็ตาม trap นั้นอธิบายละเอียดใน Error Values — กฎในหัวข้อนี้เรื่องเรียก method บน nil receiver ได้อย่างถูกกฎหมาย เป็นคนละประเด็นที่แคบกว่ามาก และเป็นเรื่องของ pointer ไม่ใช่ interface
&s[i] หลัง append อาจกลายเป็นของเก่า
หัวข้อที่มีชื่อว่า “&s[i] หลัง append อาจกลายเป็นของเก่า”Pointer ที่ชี้เข้าไปใน backing array ของ slice จะ valid ก็ต่อเมื่อ backing array นั้นยังเป็นตัวที่ slice ใช้อยู่ ถ้า append ทำให้เกิด reallocation pointer เก่าจะยังชี้ไปยัง array ที่ถูกทิ้งแล้ว:
s := make([]int, 3, 3) // len == cap: no room to grow in places[0], s[1], s[2] = 1, 2, 3p := &s[1] // points at index 1 of the current backing array
s = append(s, 4) // len(3) == cap(3) -> reallocates to a new backing arrays[1] = 99 // written to the NEW backing array
fmt.Println(*p, s) // p still points at the OLD backing arrayverify บน 1.26.5 แล้ว โค้ดนี้ print 2 [1 99 3 4] — *p ยังอ่านค่า 2 ค่าเดิมก่อน reallocation แม้ s[1] จะเป็น 99 แล้วก็ตาม &s[i] ปลอดภัยที่จะเก็บไว้ข้าม append ก็ต่อเมื่อรู้แน่ชัดว่า slice จะไม่โตเกิน capacity ของมัน นี่คือภาพสะท้อนแบบ pointer ของอันตราย slice-aliasing จากบท Slices — และ &m[k] สำหรับ map นั้นเขียนไม่ได้เลยตั้งแต่แรก เพราะ map value ถูกย้ายได้ทุกเมื่อจากการ rehash (ดู Maps)
Pointer ไปยัง loop variable: ก่อนและหลัง Go 1.22
หัวข้อที่มีชื่อว่า “Pointer ไปยัง loop variable: ก่อนและหลัง Go 1.22”ก่อน Go 1.22 loop variable ของ for (index/range) เป็นตัวแปรตัวเดียวที่แชร์กันทุก iteration — closure ที่สร้างในตัว loop body จับตัวแปรตัวนั้นตัวเดียว ไม่ใช่ snapshot ต่อ iteration bug คลาสสิก: launch goroutine หรือเก็บ closure ต่อ iteration แล้วทุกตัวเห็นค่า สุดท้าย ของ loop ตอนที่ loop จบ เพราะทั้งหมดแชร์ตัวแปรเดียวกัน
ตั้งแต่ Go 1.22 แต่ละ iteration ได้ copy ของ loop variable เป็นของตัวเอง ซึ่งคือสิ่งที่โค้ดต่อไปนี้ print บน toolchain ปัจจุบันตัวใดก็ตาม:
funcs := make([]func() int, 0, 3)for i := 0; i < 3; i++ { funcs = append(funcs, func() int { return i })}got := make([]int, 0, 3)for _, f := range funcs { got = append(got, f())}fmt.Println(got) // [0 1 2] — each closure captured its own iการเปลี่ยนแปลงนี้ถูก gate ด้วยบรรทัด go ใน go.mod เพื่อไม่ให้โค้ดเดิมเปลี่ยนความหมายแบบเงียบ ๆ: module ที่ declare go 1.21 หรือเก่ากว่าจะยังใช้พฤติกรรม shared-variable แบบเดิม ส่วน go 1.22 ขึ้นไปจะได้แบบ per-iteration verify บน toolchain Go 1.27.0 แล้วด้วยการ build โค้ดชิ้นเดียวกันข้างบนทั้งสองแบบ: go 1.21 print [3 3 3] ส่วน go 1.22 print [0 1 2] นี่คือเหตุผลที่การแก้โค้ดเก่าทำได้สองทาง คือยก go directive ขึ้น หรือคงบรรทัด i := i ไว้ และเป็นเหตุผลที่ต้องอ่าน go.mod ของ repo ก่อนตัดสินว่า loop-variable capture ในนั้นเป็น bug หรือไม่
%p กับ uintptr ไม่ใช่สิ่งเดียวกัน
หัวข้อที่มีชื่อว่า “%p กับ uintptr ไม่ใช่สิ่งเดียวกัน”fmt.Printf("%p", p) format address ของ pointer เป็น hex มีประโยชน์สำหรับ logging และ debugging:
x := 42p := &xfmt.Printf("%p\n", p) // 0x... — the actual runtime address, format onlyuintptr หน้าตาคล้ายกัน (มันคือ integer ธรรมดาที่เก็บ address โดยโครงสร้าง) แต่มัน ไม่ใช่ pointer ในสายตาของ garbage collector GC จะ track และ update เฉพาะ value ที่เป็น pointer type เท่านั้นตอนย้าย memory — uintptr มองไม่เห็นสำหรับมันเลย การแปลง pointer เป็น uintptr ทำ arithmetic แล้วแปลงกลับ คือ operation แบบ unsafe ที่จบลงด้วยการชี้ไปยัง memory ที่ถูก free หรือถูกย้ายไปแล้วได้ — documentation ของ package unsafe เองกำหนด pattern ที่เฉพาะเจาะจงมาก (ใช้ทันที ไม่เก็บไว้) เพื่อให้ปลอดภัย โค้ด application แทบไม่ต้องใช้ uintptr เลย มันมีไว้สำหรับ runtime และกรณี FFI/interop ที่แคบมาก
ไม่มี pointer arithmetic
หัวข้อที่มีชื่อว่า “ไม่มี pointer arithmetic”Go ออกแบบมาโดยตั้งใจไม่รวม pointer arithmetic คุณไม่สามารถทำ p++ หรือ p + 4 เพื่อเดินผ่าน memory ได้ ทำให้ขจัดบั๊กด้านความปลอดภัยของ memory ทั้งคลาสหนึ่งไปได้ Package unsafe มี pointer arithmetic เป็น escape hatch แต่แทบไม่จำเป็นในโค้ด application
package main
import "fmt"
type Config struct { Debug bool Workers int}
func enableDebug(cfg *Config) { cfg.Debug = true // mutates the caller's Config}
func double(n *int) { *n = *n * 2}
func main() { // & takes the address; * dereferences x := 42 p := &x fmt.Println(*p) // 42 *p = 100 fmt.Println(x) // 100 -- x was mutated through p
// Pointer for mutation across function boundary double(&x) fmt.Println(x) // 200
// Pointer to struct cfg := Config{Debug: false, Workers: 4} enableDebug(&cfg) fmt.Println(cfg.Debug) // true
// new() allocates a zeroed value and returns a pointer n := new(int) *n = 7 fmt.Println(*n) // 7
// nil pointer var ptr *int fmt.Println(ptr == nil) // true} Playground ตัวที่สองเน้นหัวข้อเชิงลึกด้านบน: สามวิธีในการได้ pointer ที่เป็น zero value, nil pointer receiver, และ pointer ที่กลายเป็นของเก่าหลัง append reallocate
package main
import "fmt"
type Config struct{ Workers int }
type List struct { Val int Next *List}
// Sum works on a nil *List receiver — no field is dereferenced when l == nilfunc (l *List) Sum() int { if l == nil { return 0 } return l.Val + l.Next.Sum()}
func main() { // &T{}, new(T), and var t T; &t all produce an equivalent zero value p1 := &Config{} p2 := new(Config) var v Config p3 := &v fmt.Println(*p1 == *p2, *p2 == *p3) // true true fmt.Println(p1 == p2) // false — two distinct allocations
// Calling a method on a nil pointer receiver is legal Go var l *List fmt.Println(l.Sum()) // 0
l = &List{Val: 1, Next: &List{Val: 2}} fmt.Println(l.Sum()) // 3
// &s[i] after append can point at a stale backing array s := make([]int, 3, 3) // len == cap: no room to grow in place s[0], s[1], s[2] = 1, 2, 3 old := &s[1]
s = append(s, 4) // reallocates: s now points at a new backing array s[1] = 99 // written to the NEW array only
fmt.Println(*old, s) // old still reads from the abandoned array} Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”flowchart LR
subgraph Stack["Stack (per-goroutine, freed on return)"]
x["x := 42"]
p["p := &x"]
cfg["cfg"]
end
subgraph Heap["Heap (GC-managed)"]
c["Config{Workers: 4}"]
end
p -.->|"&x: does not escape, stays on stack"| x
cfg -->|"cfg points into the heap"| c
newConfig["newConfig() returns &c"] -->|"c escapes: outlives the function"| c
Note["stack alloc is fast and GC-free; heap alloc costs GC-managed latency; escape analysis decides which"] ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
| pointer to large struct | หลีกเลี่ยง copy — ส่งแค่ 8 bytes (pointer size) | อาจ escape to heap → GC pressure เพิ่ม |
| value receiver | ชัดเจนว่าไม่ mutate, cache-friendly | copy struct ทุกครั้งที่เรียก method |
| pointer receiver | mutate ได้, ไม่ต้อง copy | ต้อง nil-check, อาจเกิด aliasing bug |
*T = nil เป็น optional | แทน “ไม่มีค่า” ได้โดยไม่ต้องใช้ sentinel | ต้อง nil-check ก่อน dereference ทุกครั้ง |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- pointer ใน Go อันตรายเหมือน C — Go ไม่มี pointer arithmetic และ GC จัดการ lifetime ให้ — use-after-free เป็นไปไม่ได้ แต่ pointer ที่ยังชี้ไปยัง element เก่าหลัง
appendreallocate backing array ใหม่นั้นยังเกิดได้ (ดูหัวข้อ “&s[i]หลัง append อาจกลายเป็นของเก่า” ด้านบน) - pointer เสมอเร็วกว่า value — pointer อาจทำให้ variable escape to heap → GC ต้องทำงานเพิ่ม → latency สูงขึ้น;
go build -gcflags=-mแสดงให้เห็นตรง ๆ ว่าเรื่องนี้เกิดขึ้นเมื่อไหร่ - nil pointer = ปัญหาเหมือน NullPointerException ใน Java — Go panic มี stack trace ชัดเจน และตรวจสอบ nil ได้ง่ายด้วย
if p == nilการเรียก method บน nil pointer receiver ไม่ panic ในตัวมันเองด้วยซ้ำ — panic ก็ต่อเมื่อ dereference field ผ่านมันเท่านั้น - closure ที่ capture loop variable เป็น bug เสมอ — ขึ้นกับ
godirective ของ module ถ้าgo 1.22ขึ้นไป แต่ละ iteration มีตัวแปรของตัวเอง การ capture จึงถูกต้อง แต่ถ้าgo 1.21หรือเก่ากว่า ทุก iteration แชร์ตัวแปรเดียวกัน จึงเป็น bug คลาสสิก ดูgo.modก่อนตัดสิน uintptrเป็นแค่อีกชื่อของ pointer — มันคือ integer ธรรมดาในสายตาของ garbage collector; GC ไม่ update ค่าuintptrตอนย้าย memory ซึ่งเป็นเหตุผลที่ rawuintptrarithmetic เป็นunsafe
💡 ตัวอย่างจากของจริง
Go standard library ใช้ pointer receiver สำหรับ stateful type เช่น
(*bytes.Buffer).Write()— เพื่อ maintain internal state ระหว่าง call
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”Bug ที่เกี่ยวกับ pointer มักไม่ crash ตรงจุดที่พลาด — มันโผล่ออกมาที่อื่นเสมอ เช็คสิ่งเหล่านี้:
- Function return
&LocalStruct{...}จากทุก code path “เผื่อ caller ต้อง mutate” ทั้งที่ caller ส่วนใหญ่แค่อ่าน field เดียวหรือเปล่า? นั่นคือ heap allocation ทุกครั้งที่เรียก — เช็คด้วยgo build -gcflags=-mถ้า function นี้เป็น hot path - Method ที่มี pointer receiver dereference field ก่อนเช็ค receiver ว่าเป็น
nilหรือเปล่า? nil-receiver check ต้องมาก่อน และต้องreturn/continueจริง ๆ ก่อนแตะ field ใด ๆ - มีอะไรถือ
&slice[i]หรือ sub-slice ข้าม call ที่อาจappendเข้า slice เดียวกันหรือเปล่า? Reallocation จะตัดขาด pointer นั้นจาก backing array ปัจจุบันของ slice แบบเงียบ ๆ - Closure ที่สร้างใน loop สมมติว่าต้องใช้ workaround แบบ Go-1.21 (
i := iในตัว loop body) ทั้งที่go.modของ codebase ประกาศgo 1.22ขึ้นไปแล้วหรือเปล่า? Workaround นั้นเป็น dead code บน language version สมัยใหม่ — ไม่เป็นอันตราย แต่เป็นสัญญาณว่าผู้เขียน (หรือ AI) ไม่รู้ว่า fix นี้ ship ไปแล้ว - มีโค้ดที่เปรียบเทียบหรือ format
uintptrราวกับ garbage collector ยังรู้ว่ามันชี้ไปไหนหรือเปล่า?uintptrที่ได้จาก pointer valid แค่ชั่วขณะที่ดึงมันมาเท่านั้น — object ข้างใต้ย้ายได้ก่อนบรรทัดถัดไปจะรัน
🐛 หา bug ในโค้ดนี้
type Cache struct { mu sync.Mutex data map[string]int}
func (c *Cache) Get(key string) (int, bool) { if c == nil { return 0, false } c.mu.Lock() defer c.mu.Unlock() v, ok := c.data[key] return v, ok}
var globalCache *Cache
func main() { v, ok := globalCache.Get("x") fmt.Println(v, ok)}เฉลย
โค้ดนี้ถูกต้องจริง ๆ และนั่นคือประเด็นของแบบฝึกหัดนี้ — nil check ของ Get มาก่อนและ return ออกไปก่อนแตะ c.mu หรือ c.data ดังนั้นการเรียกมันบน *Cache ที่เป็น nil (แบบที่ main ทำกับ globalCache ที่เป็น zero value) ถูกกฎหมายสมบูรณ์และ print 0 false รูปแบบ bug ที่ต้องระวังคือโครงเดียวกันแต่ check อยู่ผิดที่หรือหายไป: ถ้า c.mu.Lock() รันก่อน nil check การเรียกแบบเดียวกันเป๊ะจะ panic ด้วย nil pointer dereference เพราะ Lock() ต้องอ่าน address ของ c.mu ซึ่งต้องการให้ c ไม่เป็น nil ก่อน เวลา review โค้ดที่ generate มาซึ่งมี nil-receiver guard ให้ยืนยันว่า guard นั้นเป็น statement แรกสุดใน method จริง ๆ ไม่ใช่แค่มีอยู่ที่ไหนสักแห่งใน function