Slice และ map ใน Go คือ collection สองแบบที่แทบทุกโปรแกรม Go ต้องใช้ ส่วน pointer เป็นตัวตัดสินว่า function ทำงานกับข้อมูลจริงหรือกับสำเนาของมัน กฎที่ผูกทั้งหมดไว้ด้วยกันนั้นง่ายมาก คือ Go ส่งค่าแบบ pass by value เสมอ แต่สิ่งที่ถูก copy ไม่เหมือนกัน การ copy array คือการ copy ทุก element การ copy slice คือการ copy header เล็ก ๆ ที่ยังชี้ไปยัง backing array เดิม และการ copy map คือการ copy reference ที่ชี้ไปยัง map ตัวเดิม
บทความนี้เริ่มจากพื้นฐาน control flow ของ Go แล้วต่อด้วย array กับ slice (len, cap, append, nil กับ empty) map และ pointer พร้อมกับดักที่ทำให้เกิด bug จริง ตัวอย่างทั้งหมดใช้ Go 1.22 ขึ้นไป
พื้นฐาน control flow ใน Go
if พร้อม init statement
ไม่ต้องมีวงเล็บรอบเงื่อนไข แต่ต้องมีปีกกาเสมอ และเขียน init statement ได้ ซึ่งตัวแปรนั้นจะมี scope อยู่แค่ใน if/else:
if n, err := strconv.Atoi(input); err != nil {
return fmt.Errorf("invalid number: %w", err)
} else if n > 100 {
return errors.New("too large")
}for คือ loop เดียวที่มี
Go ไม่มี while หรือ do-while คีย์เวิร์ด for รองรับ loop ทุกรูปแบบ:
// classic three-part loop
for i := 0; i < 3; i++ {
fmt.Println(i)
}
// "while" loop
for attempts < 5 {
attempts++
}
// forever loop: exit with break or return
for {
if done() {
break
}
}
// range over an int (Go 1.22+): 0, 1, 2
for i := range 3 {
fmt.Println(i)
}
// range over a slice: index and value
for i, name := range []string{"a", "b"} {
fmt.Println(i, name)
}ตั้งแต่ Go 1.22 แต่ละรอบของ loop จะได้ตัวแปร loop เป็นของตัวเอง closure และ goroutine ที่อ้างถึง i หรือ name จะเห็นค่าของรอบตัวเองจริง ๆ ซึ่งปิด bug ประเภทที่เจอกันมานานไปได้ทั้งกลุ่ม
switch ที่ไม่ fallthrough
Case ไม่ไหลต่อไปยัง case ถัดไปโดยอัตโนมัติ หนึ่ง case ใส่ได้หลายค่า และ switch ที่ไม่มีนิพจน์จะทำงานเหมือน if/else ที่อ่านง่ายกว่า:
switch day {
case "sat", "sun":
fmt.Println("weekend")
default:
fmt.Println("weekday")
}
switch {
case score >= 80:
grade = "A"
case score >= 70:
grade = "B"
default:
grade = "C"
}ถ้าต้องการให้ case ถัดไปทำงานต่อจริง ๆ ซึ่งเจอไม่บ่อย ให้ใช้คีย์เวิร์ด fallthrough
Function และการคืนค่าหลายค่า
Function คืนค่าได้หลายค่า และนี่คือวิธีที่ Go ใช้รายงาน error:
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}Array กับ slice ใน Go
Array มีความยาวคงที่และความยาวเป็นส่วนหนึ่งของ type [3]int กับ [4]int จึงเป็นคนละ type และการ assign หรือส่ง array เข้า function คือการ copy ทุก element:
a := [3]int{1, 2, 3}
b := a // full copy
b[0] = 99
fmt.Println(a) // [1 2 3]Array เหมาะกับข้อมูลขนาดตายตัว เช่น SHA-256 digest ([32]byte) แต่โค้ดส่วนใหญ่ใช้ slice ซึ่งเป็น header ขนาดเล็กที่มีสาม field คือ pointer ไปยัง backing array, length และ capacity
s := []int{1, 2, 3} // literal
t := make([]int, 0, 10) // len 0, cap 10
u := s[1:3] // [2 3], shares s's backing arraylen, cap และ append
len คือจำนวน element ที่ slice มีอยู่ตอนนี้ cap คือจำนวนที่ใส่ได้ก่อน backing array จะต้องขยาย ส่วน append ใช้เพิ่ม element และเมื่อ capacity ไม่พอ มันจะจอง array ใหม่ที่ใหญ่กว่าแล้ว copy element เดิมไป:
var s []int
for i := range 5 {
s = append(s, i)
fmt.Println(len(s), cap(s))
}นโยบายการขยายที่แน่นอนเป็นรายละเอียดภายในของ runtime โดยคร่าว ๆ คือเพิ่มเป็นสองเท่าสำหรับ slice เล็ก และขยายช้าลงเมื่อ slice ใหญ่ขึ้น แต่กฎสองข้อนี้สำคัญกว่า:
- Assign ผลของ
appendกลับเสมอ เพราะมันอาจคืน slice ที่ชี้ไปยัง array ใหม่ - จองพื้นที่ล่วงหน้าเมื่อรู้ขนาด
make([]T, 0, n)ช่วยเลี่ยงการจองใหม่ซ้ำหลายครั้ง
กับดัก backing array ที่ใช้ร่วมกัน
Sub-slice ใช้หน่วยความจำร่วมกับ slice ต้นฉบับ ถ้า sub-slice ยังมี capacity เหลือ append จะเขียนทับลงใน array ของต้นฉบับ:
a := []int{1, 2, 3, 4}
b := a[:2] // len 2, cap 4
b = append(b, 99) // fits in capacity: overwrites a[2]
fmt.Println(a) // [1 2 99 4]ป้องกันได้ด้วยการจำกัด capacity ด้วย full slice expression หรือ copy ออกมา:
b := a[:2:2] // len 2, cap 2: the next append must allocate
c := slices.Clone(a[:2]) // independent copyBug นี้มักโผล่เมื่อ function คืน sub-slice ของ buffer ภายใน แล้วผู้เรียกเอาไป append ต่อ
Empty slice กับ nil slice
| nil slice | empty slice | |
|---|---|---|
| ประกาศแบบ | var s []int | s := []int{} หรือ make([]int, 0) |
s == nil | true | false |
len(s), cap(s) | 0, 0 | 0, 0 (หรือมากกว่าถ้าใช้ make) |
append, range, len | ใช้ได้ทั้งหมด | ใช้ได้ทั้งหมด |
json.Marshal | null | [] |
ในโค้ด Go ทั้งสองแบบทำงานเหมือนกัน var s []int จึงเป็นค่าเริ่มต้นที่เป็น idiomatic ให้เช็กว่าว่างด้วย len(s) == 0 ไม่ใช่ s == nil จุดที่ต่างกันจริงและทำให้เกิดปัญหาคือ JSON: API ที่คืน null แทน [] อาจทำให้โค้ด frontend ที่คาดหวัง array พังได้ ถ้าค่านั้นจะถูก serialize ให้ประกาศด้วย []T{}
Package slices
ตั้งแต่ Go 1.21 standard library มี package slices สำหรับงานที่เมื่อก่อนต้องเขียน loop เอง:
slices.Contains(names, "ana")
slices.Index(names, "ana") // -1 if missing
slices.Sort(nums)
slices.Reverse(nums)
slices.Max(nums) // panics on an empty sliceMap ใน Go
Map คือ hash table ที่จับคู่ key กับ value โดย key ต้องเป็น type ที่เทียบค่ากันได้ (string, ตัวเลข, struct ที่ทุก field เทียบกันได้ แต่ไม่ใช่ slice หรือ map)
stock := map[string]int{"apple": 5, "pear": 2}
stock["mango"] = 7
delete(stock, "pear")
qty := stock["kiwi"] // 0: missing keys return the zero value
qty, ok := stock["kiwi"] // ok == false tells you the key is missing
if !ok {
fmt.Println("no kiwi")
}กฎที่ต้องจำ:
- Nil map อ่านได้แต่เขียนไม่ได้
var m map[string]int; m["a"] = 1จะ panic ให้สร้าง map ด้วยmakeหรือ literal - ลำดับการวน loop ไม่ได้ถูกกำหนดไว้ Go ตั้งใจสุ่มให้ต่างกันในแต่ละครั้งที่รัน ถ้าต้องการลำดับที่แน่นอน ให้เรียง key ก่อน:
keys := slices.Sorted(maps.Keys(stock)) // Go 1.23+
for _, k := range keys {
fmt.Println(k, stock[k])
}ถ้าใช้ Go 1.22 ให้เก็บ key ลง slice ด้วย for range แล้วเรียกใช้ slices.Sort
- Map ไม่ปลอดภัยสำหรับการเขียนพร้อมกัน ถ้ามีสอง goroutine เขียน map เดียวกันโดยไม่มี lock โปรแกรมจะล่มด้วย
fatal error: concurrent map writesให้ใช้sync.Mutexหรือใช้sync.Mapในบางกรณีที่อ่านเยอะเป็นพิเศษ รูปแบบต่าง ๆ อยู่ใน Go concurrency บน production - Set มักเขียนเป็น
map[string]struct{}เพราะ empty struct ไม่กินพื้นที่ clear(m)(Go 1.21+) ลบทุก entry แต่เก็บหน่วยความจำที่จองไว้ให้ใช้ซ้ำ
Pointer ใน Go
Pointer เก็บ address ในหน่วยความจำของค่าหนึ่ง & ใช้เอา address และ * ใช้อ่านหรือเขียนผ่าน pointer:
i := 20
p := &i // p has type *int
fmt.Println(*p) // 20
*p = 21
fmt.Println(i) // 21
var q *int // zero value is nil
fmt.Println(q == nil) // true; *q would panic: nil pointer dereference
n := new(int) // allocates a zeroed int, returns *int
fmt.Println(*n) // 0Go ไม่มี pointer arithmetic และการคืน pointer ของตัวแปร local ก็ปลอดภัย เพราะ escape analysis ของ compiler จะย้ายค่านั้นไปไว้บน heap เมื่อจำเป็น การเข้าถึง field ผ่าน pointer ไม่ต้อง dereference เอง u.Name ใช้ได้เลยแม้ u จะเป็น *User
Pass by value และผลต่อ slice กับ map
ทุก argument ถูก copy สำหรับ int หรือ struct ตัว function จะได้สำเนาของตัวเอง ถ้าต้องการแก้ค่าของผู้เรียก ต้องส่ง pointer:
func reset(n *int) { *n = 0 }
x := 5
reset(&x)
fmt.Println(x) // 0สำหรับ slice และ map ค่าที่ถูก copy มี pointer อยู่ข้างใน ผลลัพธ์จึงชวนงงอยู่บ้าง:
func setFirst(s []int) { s[0] = 100 } // caller sees this
func add(s []int) { s = append(s, 4) } // caller does NOT see this
func put(m map[string]int) { m["k"] = 1 } // caller sees this
nums := []int{1, 2, 3}
setFirst(nums) // [100 2 3]
add(nums) // still [100 2 3]setFirst เขียนผ่าน backing array ที่ใช้ร่วมกัน ส่วน add เปลี่ยน length ใน header ที่เป็นสำเนาของตัวเอง slice ของผู้เรียกจึงไม่เห็น element ใหม่ วิธีแก้คือคืน slice ใหม่ออกมาแบบเดียวกับที่ append ทำ: nums = add(nums)
ควรใช้ pointer เมื่อ:
- Function ต้องแก้ค่าของผู้เรียก
- Struct ใหญ่จนการ copy ทุกครั้งที่เรียกเป็นการสิ้นเปลือง
- ต้องการสื่อว่า "ไม่มีค่า" ด้วย
nilเช่น field ที่เป็น optional
อย่าใช้ pointer ไปยัง slice หรือ map แค่เพื่อเลี่ยงการ copy เพราะ header มีขนาดเล็กอยู่แล้ว
เกร็ดเรื่องความเรียบร้อยของโปรเจกต์: import ที่ไม่ได้ใช้ถือเป็น compile error ใน Go และ goimports หรือ gopls ใน editor จะลบให้ตอน save ส่วน dependency ของ module ที่ไม่ได้ใช้เป็นอีกเรื่องหนึ่ง ให้รัน go mod tidy เพื่อเอาออกจาก go.mod อ่านเพิ่มได้ที่ Go modules และ packages
คำถามที่พบบ่อย
Go มี while loop ไหม?
ไม่มี ใช้ for condition { } แทน while loop และ for { } สำหรับ loop ไม่รู้จบ
nil slice กับ empty slice ใน Go ต่างกันอย่างไร?
Nil slice ไม่มี backing array และเท่ากับ nil ส่วน empty slice มี backing array ที่ความยาวเป็นศูนย์ ทั้งสองทำงานเหมือนกันกับ len, append และ range แต่เมื่อแปลงเป็น JSON จะได้ null และ [] ตามลำดับ
Go เป็น pass by reference หรือเปล่า?
ไม่ใช่ Go เป็น pass by value เสมอ แต่ slice, map, channel และ pointer มี reference อยู่ข้างใน การ copy จึงยังเข้าถึงข้อมูลชุดเดิมได้
ทำไมลำดับของ map ใน Go ถึงสุ่ม?
ภาษาไม่ได้กำหนดลำดับการวน loop ของ map และ runtime ก็ตั้งใจสุ่มให้ต่างกัน ถ้าลำดับสำคัญให้เรียง key ก่อน
ทำไมต้อง assign ผลของ append กลับ?
เพราะ append อาจจอง backing array ใหม่แล้วคืน slice ที่ชี้ไปยัง array นั้น header ของ slice เดิมไม่ได้เปลี่ยนตาม
Checklist
- ใช้
forกับทุก loop และอาศัยตัวแปร loop แบบแยกต่อรอบใน Go 1.22+ - จองพื้นที่ slice ล่วงหน้าด้วย
make([]T, 0, n)เมื่อรู้ขนาด - เขียน
s = append(s, ...)เสมอ - จำกัด capacity หรือ clone sub-slice ก่อนส่งให้โค้ดส่วนอื่น
- ใช้
[]T{}แทน nil slice เมื่อค่านั้นจะกลายเป็น JSON - สร้าง map ก่อนเขียน ใช้ comma-ok ตอน lookup และป้องกัน map ที่แชร์กันด้วย mutex
- ส่ง pointer เมื่อต้องแก้ค่า และคืน slice ใหม่จาก function ที่ append
อ่านต่อที่ string, byte และ rune ใน Go เพื่อดูว่าข้อความเข้ากับโมเดลนี้อย่างไร และถ้าต้องการทีมวิศวกร Go ที่มีประสบการณ์มาช่วยรีวิวหรือพัฒนา backend Vectorkub รับงานลักษณะนี้
