ข้ามไปยังเนื้อหา

Pointers

Pointer คือตัวแปรที่เก็บ memory address ของตัวแปรอื่น type ของ pointer ไปยัง T เขียนว่า *T:

x := 42
p := &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 100
fmt.Println(x) // 100
Operatorอ่านว่าผล
&x”address of x”คืน *T ที่ชี้ไป x
*p”value at p”dereference p อ่านหรือเขียน T

1. Mutation ข้ามขอบเขตของ function. Go ส่ง argument ทุกตัวเป็น value หาก function ต้องแก้ไขตัวแปรของ caller ให้ส่ง pointer:

func double(n *int) {
*n = *n * 2
}
x := 5
double(&x)
fmt.Println(x) // 10

2. 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 สำหรับการเปรียบเทียบนั้น

“ส่ง 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 heap
func 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 ได้

เมื่อมี pointer ไปยัง struct Go จะ dereference ให้อัตโนมัติสำหรับการเข้าถึง field คุณไม่ต้องเขียน (*cfg).Debug:

cfg := &Config{Workers: 4}
cfg.Debug = true // shorthand for (*cfg).Debug = true

Built-in new(T) allocate zero value ของ type T และคืน *T:

n := new(int) // *int pointing to 0
*n = 7

ตั้งแต่ 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 36
q := Person{Name: "Bob", Age: new(yearsSince(born))} // any expression works

&T{}, new(T), และ var t T; &t ทั้งหมดให้ pointer ไปยัง zero value ที่เท่ากัน — แต่ถ้าเทียบด้วย identity มันคือสอง allocation ที่แยกกัน:

p1 := &Config{}
p2 := new(Config)
var v Config
p3 := &v
fmt.Println(*p1 == *p2, *p2 == *p3) // true true — all zero-valued
fmt.Println(p1 == p2) // false — two distinct allocations

verify บน 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 ด้านบน

Zero value ของ pointer type ทุกชนิดคือ nil การ dereference nil pointer ทำให้เกิด runtime panic:

var p *int
fmt.Println(p == nil) // true
// *p = 1 would panic: nil pointer dereference

ควรตรวจสอบ pointer ว่าเป็น nil ก่อน dereference เสมอ เมื่อ pointer นั้นอาจไม่ได้ถูกกำหนดค่า

การเรียก 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 == nil
func (l *List) Sum() int {
if l == nil {
return 0
}
return l.Val + l.Next.Sum()
}
var l *List
fmt.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

Pointer ที่ชี้เข้าไปใน backing array ของ slice จะ valid ก็ต่อเมื่อ backing array นั้นยังเป็นตัวที่ slice ใช้อยู่ ถ้า append ทำให้เกิด reallocation pointer เก่าจะยังชี้ไปยัง array ที่ถูกทิ้งแล้ว:

s := make([]int, 3, 3) // len == cap: no room to grow in place
s[0], s[1], s[2] = 1, 2, 3
p := &s[1] // points at index 1 of the current backing array
s = append(s, 4) // len(3) == cap(3) -> reallocates to a new backing array
s[1] = 99 // written to the NEW backing array
fmt.Println(*p, s) // p still points at the OLD backing array

verify บน 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)

ก่อน 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 หรือไม่

fmt.Printf("%p", p) format address ของ pointer เป็น hex มีประโยชน์สำหรับ logging และ debugging:

x := 42
p := &x
fmt.Printf("%p\n", p) // 0x... — the actual runtime address, format only

uintptr หน้าตาคล้ายกัน (มันคือ 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 ที่แคบมาก

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 == nil
func (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
}
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 จะบังคับให้เกิด heap allocation ก็ต่อเมื่อ escape analysis พิสูจน์ได้ว่า value ต้องมีชีวิตอยู่เกิน function ของมัน
สิ่งที่ได้ประโยชน์ต้นทุน
pointer to large structหลีกเลี่ยง copy — ส่งแค่ 8 bytes (pointer size)อาจ escape to heap → GC pressure เพิ่ม
value receiverชัดเจนว่าไม่ mutate, cache-friendlycopy struct ทุกครั้งที่เรียก method
pointer receivermutate ได้, ไม่ต้อง 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 เก่าหลัง append reallocate 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 เสมอ — ขึ้นกับ go directive ของ module ถ้า go 1.22 ขึ้นไป แต่ละ iteration มีตัวแปรของตัวเอง การ capture จึงถูกต้อง แต่ถ้า go 1.21 หรือเก่ากว่า ทุก iteration แชร์ตัวแปรเดียวกัน จึงเป็น bug คลาสสิก ดู go.mod ก่อนตัดสิน
  • uintptr เป็นแค่อีกชื่อของ pointer — มันคือ integer ธรรมดาในสายตาของ garbage collector; GC ไม่ update ค่า uintptr ตอนย้าย memory ซึ่งเป็นเหตุผลที่ raw uintptr arithmetic เป็น unsafe

💡 ตัวอย่างจากของจริง

Go standard library ใช้ pointer receiver สำหรับ stateful type เช่น (*bytes.Buffer).Write() — เพื่อ maintain internal state ระหว่าง call

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

คุณส่ง x (เป็น int) ไปยัง func add(n int) ซึ่งกำหนด n = n + 1 หลังเรียก function แล้ว ค่าของ x คืออะไร?
เกิดอะไรขึ้นถ้าคุณ dereference nil pointer ใน Go?
โค้ดนี้ print อะไร? func newConfig() *Config { c := Config{Workers: 4} return &c } fmt.Println(newConfig().Workers)
โค้ดนี้ print อะไร? s := make([]int, 3, 3) s[0], s[1], s[2] = 1, 2, 3 p := &s[1] s = append(s, 4) s[1] = 99 fmt.Println(*p, s)
go.mod ของ module ประกาศ go 1.21 แล้ว build ด้วย toolchain ปัจจุบัน โค้ดนี้ print อะไร? 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()) } sort.Ints(got) fmt.Println(got)