slice map string และ rune
สามบทแรกทำให้ orderctl อ่านออเดอร์จาก JSON แล้วบอกยอดรวมได้ · บทนี้เริ่มทำใบสรุป
แยกตามหมวด และนั่นคือจุดที่ collection ของ Go เริ่มกัด
กับดักของบทนี้ไม่ใช่ syntax ที่แปลก · มันคือบรรทัดที่หน้าตาเหมือนภาษาที่คุณเขียนอยู่ แต่ทำคนละอย่าง
internal/pricing — TotalsByCategory รวมยอดต่อหมวด · SortLines เรียงบรรทัดออเดอร์
· Render ผลิตใบสรุปเป็นข้อความ
ข้อกำหนดข้อเดียวที่บังคับทั้ง package คือ ใบสรุปต้องเหมือนเดิมทุกครั้งที่รัน และข้อนั้นเองที่บังคับให้เราจัดการกับลำดับของ map
slice ไม่ใช่ array
หัวข้อที่มีชื่อว่า “slice ไม่ใช่ array”array คัดลอกทั้งก้อน slice คัดลอกแค่สามช่องที่ชี้ไปที่ก้อนเดิม บรรทัดมอบหมายค่าจึงให้ผลตรงข้ามกัน
slice headerslice headerค่าสามช่อง (pointer, ความยาว, ความจุ) ที่แทน slice — การคัดลอก slice คือการคัดลอกสามช่องนี้ ไม่ใช่คัดลอกข้อมูล สอง slice จึงแชร์ที่เก็บเดียวกันได้ คือค่าสามช่อง — pointer ไปยังที่เก็บ, ความยาว และความจุ · ตัว slice ไม่ได้ถือข้อมูลเอง มันถือที่อยู่ของข้อมูล
[3]int กับ []int ต่างกันแค่ตัวเลขในวงเล็บ แต่พฤติกรรมตอนมอบหมายค่าตรงข้ามกันสนิท
package main
import "fmt"
func main() { arr := [3]int{1, 2, 3} brr := arr // array เป็น value คัดลอกทั้งก้อน brr[0] = 99 fmt.Println(arr, brr) // [1 2 3] [99 2 3] ← ต่างกัน
s := []int{1, 2, 3} t := s // slice คัดลอกแค่สามช่องของ header t[0] = 99 fmt.Println(s, t) // [99 2 3] [99 2 3] ← ตัวเดียวกัน}คุณคุ้นกับกฎที่ว่า collection เป็น reference เสมอ · Go ไม่มีกฎนั้น
[3]int เป็น value เต็มตัว การส่งเข้าฟังก์ชันคือการคัดลอกทั้งก้อน · []int เป็น header
ที่ชี้ออกไป การส่งเข้าฟังก์ชันคัดลอกแค่สามช่อง
ผลคือฟังก์ชันที่รับ []int แก้ข้อมูลของผู้เรียกได้ แต่ฟังก์ชันที่รับ [3]int แก้ไม่ได้เลย
backing array ที่สอง slice แชร์กัน
หัวข้อที่มีชื่อว่า “backing array ที่สอง slice แชร์กัน”
appendเขียนทับที่ว่างในความจุเดิมได้ ซึ่งเป็นที่ที่ slice ตัวอื่นยังใช้อยู่ และ full slice expression คือตัวปิดช่องนั้น
base[:2] ไม่คัดลอกอะไรเลย · มันสร้าง header ใหม่ที่ชี้ไปที่ array เดิม ความยาวเป็น 2
แต่ ความจุยังเป็น 5
append ตัดสินใจจากความจุ · ถ้ายังเหลือที่ มันเขียนลงที่เดิม ไม่จองที่ใหม่
package main
import ( "fmt" "slices")
func main() { base := []int{1, 2, 3, 4, 5} head := base[:2] fmt.Println(len(head), cap(head)) // 2 5 ← ความจุยาวกว่าความยาว head = append(head, 99) fmt.Println(base) // [1 2 99 4 5] ← base โดนเขียนทับ
safe := []int{1, 2, 3, 4, 5} tail := safe[:2:2] // full slice expression ตรึงความจุไว้ที่ 2 tail = append(tail, 99) fmt.Println(safe, tail) // [1 2 3 4 5] [1 2 99]
clipped := slices.Clip(base[:2]) // เขียนแบบอ่านง่ายกว่า ได้ผลเดียวกัน clipped = append(clipped, 7) fmt.Println(base, clipped) // [1 2 99 4 5] [1 2 7]}บรรทัด base[:2:2] มีตัวเลขสามตัว — เริ่ม, จบ, และเพดานความจุ · เมื่อความจุเท่ากับความยาว
append ไม่มีที่ว่างให้เขียน มันจึงต้องจองที่ใหม่เสมอ
flowchart LR S1["slice head<br/>pointer · len 2 · cap 5"] --> ARR S2["slice base<br/>pointer · len 5 · cap 5"] --> ARR ARR["backing array ก้อนเดียว<br/>[1 2 3 4 5]<br/>ไม่มีการคัดลอกตอนตัด"] ARR --> W["append ผ่าน head<br/>ยังเหลือความจุ จึงเขียนช่องที่สาม<br/>ซึ่ง base ยังใช้อยู่"]
คำบรรยายภาพ: การตัด slice สร้าง header ใหม่ที่ชี้ไปที่ backing array ก้อนเดิม · append ที่ยังมีความจุเหลือจะเขียนลงก้อนนั้น จึงกระทบ slice ตัวอื่นที่ชี้อยู่ที่เดียวกัน
append คืนค่าเสมอ และคุณต้องรับค่านั้นกลับไปใส่ตัวแปรเดิม
append(s, x) ที่ไม่รับค่าคืน คือบรรทัดที่ compiler ปฏิเสธ เพราะผลลัพธ์ถูกทิ้ง
ส่วนบรรทัดที่อันตรายกว่าคือ t := append(s, x) แล้วใช้ทั้ง s และ t ต่อ — มันคอมไพล์ผ่าน
และให้ผลถูกบ้างผิดบ้างตามความจุที่เหลือ ณ ตอนนั้น
map: อ่านคีย์ที่ไม่มีได้ zero value เงียบๆ
หัวข้อที่มีชื่อว่า “map: อ่านคีย์ที่ไม่มีได้ zero value เงียบๆ”การอ่านไม่เคยล้ม ต้องใช้รูปสองค่าเพื่อแยก “ไม่มี” ออกจาก “มีแต่เป็นศูนย์” และ map ที่ยังไม่ถูก make เขียนไม่ได้
Go ไม่โยนอะไรออกมาเมื่อคุณอ่านคีย์ที่ไม่มี · มันคืน zero value ของชนิดค่า
· รูปสองค่า v, ok := m[k] คือทางเดียวที่แยกสองกรณีนี้ออกจากกัน
nil mapnil mapmap ที่ยังไม่ถูก `make` อ่านได้ วนได้ `len` ได้ แต่การเขียนทำให้ panic — ต่างจาก nil slice ที่ `append` ได้ตามปกติ คือ map ที่ประกาศแล้วแต่ยังไม่ make · อ่านได้ วนได้ len ได้ แต่การเขียนทำให้ panic
package main
import "fmt"
func main() { qty := map[string]int{"phad-thai": 2}
n, ok := qty["tom-yum"] fmt.Println(n, ok) // 0 false ← ไม่มีคีย์ ก็ยังไม่ล้ม
qty["tom-yum"] += 3 // อ่าน zero value แล้วบวก สำนวนคิดยอดของ Go delete(qty, "phad-thai") fmt.Println(qty, len(qty)) // map[tom-yum:3] 1
var empty map[string]int fmt.Println(empty["x"], len(empty)) // 0 0 ← อ่าน nil map ได้ตามปกติ empty["x"] = 1 // panic: assignment to entry in nil map}บรรทัด m[k] += v ทำงานได้เพราะการอ่านคีย์ที่ยังไม่มีให้ศูนย์ · คุณจึงไม่ต้องเช็กก่อนบวก
และนั่นคือทั้งหมดของ TotalsByCategory
| สิ่งที่คุณคาดจากภาษาเดิม | Go ทำจริง |
|---|---|
KeyNotFoundException หรือ KeyError ตอนอ่านคีย์ที่ไม่มี | คืน zero value เงียบๆ ต้องใช้ v, ok := m[k] เพื่อรู้ |
null แยกจาก 0 ได้ | zero value ของ int คือ 0 แยกไม่ออกถ้าไม่ใช้รูปสองค่า |
| ประกาศแล้วใช้ได้เลย | var m map[string]int เขียนแล้ว panic ต้อง make หรือใช้ literal ก่อน |
| ลบคีย์ที่ไม่มีแล้วล้ม | delete กับคีย์ที่ไม่มีคือ no-op ไม่ล้ม |
var s []int ที่เป็น nil รับ append ได้ทันที เพราะ append จองที่ใหม่ให้เอง
var m map[string]int ที่เป็น nil เขียนไม่ได้ เพราะไม่มีฟังก์ชันไหนที่จองที่ให้แทนคุณ
ผลในทางปฏิบัติคือ struct ที่มี field เป็น slice ใช้ zero value ได้เลย แต่ struct ที่มี field
เป็น map ต้อง make ก่อนเขียนเสมอ
ลำดับการวน map ไม่ถูกกำหนด
หัวข้อที่มีชื่อว่า “ลำดับการวน map ไม่ถูกกำหนด”Go สุ่มลำดับโดยตั้งใจ กฎที่ต้องจำมีข้อเดียวคือเรียงคีย์ก่อนทุกครั้งที่ผลไหลออกไปเป็น output
สเปกของภาษาเขียนไว้ตรงๆ ว่าลำดับของการวน map ไม่ถูกกำหนด และไม่รับประกันว่าจะเหมือนเดิม ระหว่างการวนสองครั้ง
นี่ไม่ใช่ผลข้างเคียงของการจัดเก็บ · runtime สุ่มจุดเริ่มต้นให้ตั้งใจ เพื่อไม่ให้ใครเผลอพึ่งลำดับ
package main
import ( "fmt" "maps" "slices")
func main() { byCat := map[string]int{"food": 3, "drink": 2, "dessert": 1}
for cat, n := range byCat { fmt.Println("ไม่รับประกันลำดับ:", cat, n) }
for _, cat := range slices.Sorted(maps.Keys(byCat)) { fmt.Println("คงที่ทุกครั้ง:", cat, byCat[cat]) }}ลูปแรกให้ลำดับที่คุณเดาไม่ได้ · ลูปที่สองให้ dessert drink food ทุกครั้งบนทุกเครื่อง
คนที่รันลูปแรกสองสามรอบแล้วเห็นผลซ้ำ มักสรุปว่า map เล็กมีลำดับจำกัดไม่กี่แบบ แล้วเขียน test ที่ยอมรับลำดับเหล่านั้น
โมเดลนั้นพังทันทีที่ map โตขึ้น และมันพังในรูปของ test ที่แดงเดือนหน้าโดยไม่มีใครแก้อะไร
กฎที่ใช้ได้จริงมีข้อเดียว — ผลที่ไหลออกไปเป็น output ต้องผ่านการเรียงคีย์ก่อนเสมอ ไม่ว่า map จะเล็กแค่ไหน
slices maps cmp คือสำนวนปัจจุบัน
หัวข้อที่มีชื่อว่า “slices maps cmp คือสำนวนปัจจุบัน”สามชุดนี้มากับ Go 1.21 ·
slices.SortStableFuncคือตัวที่มาแทนsort.Sliceและ comparator ของมันคืน int ไม่ใช่ bool
Go 1.21 เพิ่ม slices maps และ cmp เข้า standard library · Go 1.22 เพิ่ม cmp.Or
ที่คืนค่าแรกที่ไม่ใช่ zero value
cmp.Or ทำให้การเรียงด้วยกุญแจสองชั้นเขียนได้โดยไม่ต้องมี if ซ้อน
package main
import ( "cmp" "fmt" "slices")
type line struct { SKU string Category string}
func main() { lines := []line{ {"tom-yum", "food"}, {"cola", "drink"}, {"phad-thai", "food"}, } slices.SortStableFunc(lines, func(a, b line) int { return cmp.Or( // ชั้นแรกเท่ากันเมื่อไร ค่อยดูชั้นที่สอง cmp.Compare(a.Category, b.Category), cmp.Compare(a.SKU, b.SKU), ) }) fmt.Println(lines) // [{cola drink} {phad-thai food} {tom-yum food}]
i := slices.IndexFunc(lines, func(l line) bool { return l.SKU == "tom-yum" }) fmt.Println(i, slices.Contains([]string{"food", "drink"}, "dessert")) // 2 false}slices.SortStableFunc แก้ slice ในที่ และไม่คืนค่า · มันไม่ใช่ chain แบบ LINQ หรือ Stream
ที่คืนของใหม่ให้ต่อท้าย
comparator ของมันคืน int ไม่ใช่ bool — ติดลบเมื่อ a มาก่อน, ศูนย์เมื่อเท่ากัน, บวกเมื่อ a มาทีหลัง
sort.Slice ยังใช้ได้และยังไม่ถูกประกาศเลิกใช้ แต่เอกสารของมันแนะนำเองว่าให้ไปใช้
slices.SortFunc แทน เพราะเขียนง่ายกว่าและเร็วกว่าในหลายกรณี
ตั้งแต่ Go 1.23 maps.Keys คืน iter.Seq[K] ซึ่งเป็นฟังก์ชัน ไม่ใช่ slice
คุณจึงส่งมันเข้า slices.Sorted ได้ตรงๆ แต่จะ len() มันไม่ได้ และ index มันไม่ได้
ตัวอย่างเก่าจาก golang.org/x/exp/maps คืน slice จริง · ถ้าคุณลอกมาแล้วเจอ error
เรื่องชนิด นั่นคือสาเหตุ
ของที่มีใน standard library แล้ว อย่าเขียนเอง — slices.Index, slices.Contains
และ maps.Clone ครอบคลุมงานที่คนเขียนลูปเองบ่อยที่สุด
BinarySearchFunc ใช้ได้เฉพาะเมื่อ comparator ที่ใช้ค้น เป็นตัวเดียวกับที่ใช้เรียง
ในบทนี้เราเรียงด้วยกุญแจสองชั้น (Category แล้ว SKU) แต่การค้นจริงมักค้นด้วย SKU
อย่างเดียว · การผสมสองแบบนี้ให้คำตอบผิดโดยไม่มี error ทั้งที่รายการนั้นมีอยู่จริง
ใช้ slices.IndexFunc แทน · มันไม่ต้องการ slice ที่เรียงแล้ว จึงไม่มีเงื่อนไขซ่อนให้ผิด
ใบสรุปของ orderctl
หัวข้อที่มีชื่อว่า “ใบสรุปของ orderctl”package
pricingรวมยอดต่อหมวด เรียงคีย์ก่อนพิมพ์ และมี test ที่เรียกRenderซ้ำเพื่อบังคับว่าผลต้องเหมือนเดิม
นี่คือ internal/pricing/report.go ทั้งชิ้น · มันใช้ทุกอย่างในบทนี้พร้อมกัน
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · ชนิด order.Order มาจาก package ของบทที่ 2package pricing
import ( "cmp" "fmt" "maps" "slices" "strings"
"example.com/orderctl/internal/order")
func TotalsByCategory(o order.Order) map[string]order.Baht { out := make(map[string]order.Baht, len(o.Lines)) for _, l := range o.Lines { out[l.Category] += l.Unit * order.Baht(l.Qty) // ใช้ได้เพราะค่าเป็นตัวเลข } return out}
func SortLines(lines []order.Line) { slices.SortStableFunc(lines, func(a, b order.Line) int { return cmp.Or( cmp.Compare(a.Category, b.Category), cmp.Compare(a.SKU, b.SKU), ) })}
func Render(o order.Order) string { var b strings.Builder // zero value ใช้ได้ทันที ห้ามคัดลอกหลังเขียนแล้ว fmt.Fprintf(&b, "order %s (โต๊ะ %d)\n", cmp.Or(o.ID, "-"), o.Table)
byCat := TotalsByCategory(o) for _, cat := range slices.Sorted(maps.Keys(byCat)) { // เรียงคีย์ก่อนเสมอ fmt.Fprintf(&b, " %-10s %10s\n", cat, byCat[cat]) } fmt.Fprintf(&b, " %-10s %10s\n", "TOTAL", o.Subtotal()) return b.String()}strings.Builder เป็น zero value ที่ใช้ได้ทันที ไม่ต้อง make · มันจองที่เพิ่มเป็นช่วงๆ
แทนการจองใหม่ทุกครั้งที่ต่อข้อความด้วย +
ถ้าคุณต่อ slice ของข้อความด้วยตัวคั่นตัวเดียว strings.Join ตรงกว่า เพราะมันรู้ความยาวรวม
ก่อนเริ่ม จึงจองที่ครั้งเดียว
test ที่บังคับข้อกำหนดของ package นี้ ไม่ได้เทียบกับข้อความที่เขียนตายตัว · มันเรียกซ้ำแล้วเทียบกับผลของตัวเอง
// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · demo() สร้างออเดอร์ตัวอย่างที่มีสามหมวดfunc TestRenderIsStable(t *testing.T) { first := Render(demo()) for range 50 { if got := Render(demo()); got != first { t.Fatalf("Render() ไม่คงที่:\n%s\nเทียบกับ\n%s", got, first) } }}ถอดบรรทัด slices.Sorted ออกจาก Render แล้ว test นี้จะแดง · นั่นคือหลักฐานว่ามันคุมของจริง
ไม่ใช่ผ่านเพราะไม่ได้ตรวจอะไร
string เก็บไบต์ ไม่ใช่ตัวอักษร
หัวข้อที่มีชื่อว่า “string เก็บไบต์ ไม่ใช่ตัวอักษร”
lenให้จำนวนไบต์ การวน range ให้ทีละ rune และข้อความไทยแสดงให้เห็นว่าสองเลขนี้ไม่เท่ากัน
string ใน Go คือลำดับไบต์ที่แก้ไม่ได้ · มันไม่ได้บังคับว่าต้องเป็น UTF-8 แต่ทุกอย่าง ในภาษาและ standard library ทำงานบนสมมติฐานนั้น
runeruneชื่อสั้นของ Unicode code point เป็น alias ของ `int32` การวน `range` บน string เดินทีละ rune ไม่ใช่ทีละไบต์ คือชื่อของ Unicode code point · บล็อกทางการของ Go เขียนว่า “The Go language defines the word rune as an alias for the type int32”
package main
import ( "fmt" "strconv" "unicode/utf8")
func main() { s := "ผัดไทย" fmt.Println(len(s)) // 18 ← จำนวนไบต์ fmt.Println(utf8.RuneCountInString(s)) // 6 ← จำนวน rune fmt.Println(s[0]) // 224 เป็น uint8 ไม่ใช่ ผ fmt.Println(string([]rune(s)[0])) // ผ
for i, r := range s { fmt.Printf("%d U+%04X ", i, r) } fmt.Println() // 0 U+0E1C 3 U+0E31 6 U+0E14 9 U+0E44 12 U+0E17 15 U+0E22
for i, r := range "a\xffb" { fmt.Printf("%d U+%04X ", i, r) } fmt.Println() // 0 U+0061 1 U+FFFD 2 U+0062 ← ไบต์เสียได้ตัวแทน ไม่ panic
fmt.Println(string(rune(65)), strconv.Itoa(65)) // A 65}สังเกตว่า index ของลูป range กระโดดทีละ 3 ไม่ใช่ทีละ 1 · มันคือตำแหน่งไบต์
ไม่ใช่ลำดับที่ของตัวอักษร
"ผัดไทย" มี 18 ไบต์ และ 6 rune · แต่ตาคนไทยนับช่องได้ 5 ช่อง
เพราะ ั ที่ U+0E31 เป็น combining mark มันเกาะอยู่บนพยัญชนะตัวหน้า ไม่กินที่ของตัวเอง
ผลคือ “จำนวนตัวอักษรที่ผู้ใช้เห็น” ไม่มีอยู่ในภาษาเลย · ถ้าคุณต้องตัดข้อความให้พอดีช่อง คุณต้องหา library ที่รู้จัก grapheme cluster ไม่ใช่นับ rune
ใน Java, C# หรือ Python การแปลงตัวเลขเป็นข้อความให้ "65" · Go ให้ "A"
เพราะมันตีความตัวเลขเป็น code point
go vet จับรูปนี้ให้ทั้งเมื่อเป็นตัวแปรและเมื่อเป็นค่าคงที่ แต่ข้อความเรียกชื่อชนิดต้นทางตามจริง
จึงไม่ใช่ข้อความเดียวกัน
string(n) ที่ n เป็น int ได้ conversion from int to string · string(65) ที่เป็น
ค่าคงที่ยังไม่มีชนิด ได้ conversion from untyped int to string · ท่อนท้ายเหมือนกันทั้งคู่คือ
yields a string of one rune, not a string of digits
และเพราะตัวตรวจนี้อยู่ในชุดปริยายของ go test การเขียนแบบนี้ทำให้ go test ล้มด้วย
build failed ไม่ใช่แค่เตือน · ใช้ strconv.Itoa เมื่อต้องการตัวเลขเป็นข้อความ
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้จบด้วย package
pricingที่ให้ผลคงที่ และกฎสามข้อที่ใช้ได้กับทุกงานที่มี collection
สามข้อที่ควรติดตัวไปบทหน้า:
- slice คือ header สามช่อง การตัดไม่คัดลอก · ใช้
s[:n:n]หรือslices.Clipเมื่อไม่อยากให้ผู้รับเขียนทับของเดิม - map ไม่มีลำดับ เรียงคีย์ก่อนทุกครั้งที่ผลไหลออกไปเป็น output และ
makeก่อนเขียนเสมอ - string เป็นไบต์ ใช้
rangeหรือ[]runeเมื่อต้องการ code point และอย่าเชื่อว่าlenบอกจำนวนตัวอักษร
บทหน้าเป็น interface · Render ตอนนี้เรียก o.Subtotal() ตรงๆ บทที่ 5 จะเปลี่ยน
บรรทัดนั้นเป็น Total(o, rules...) หนึ่งครั้ง
หลังการเปลี่ยนครั้งเดียวนั้น กฎส่วนลดกฎใหม่จะเพิ่มได้โดยไม่ต้องแตะ Render อีกเลย
· นั่นคือสิ่งที่ interface ซื้อให้
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- The Go Programming Language Specification (2026-05-26) — หัวข้อ Slice types, Slice expressions (รวม Full slice expressions ที่นิยาม
a[low:high:max]), Appending and copying slices และ For statements ที่ระบุว่าลำดับการวน map ไม่ถูกกำหนด - Go 1.21 Release Notes (2023-08-08) —
slicesmapscmpเข้า standard library พร้อม builtinminmaxclear - Go 1.22 Release Notes (2024-02-06) —
cmp.Orและการเคลียร์หางของslices.Deleteกับslices.Compact - pkg.go.dev — maps (go1.23.0, 2024-08-13) —
maps.Keysคืนiter.Seq[K]ไม่ใช่ slice - Strings, bytes, runes and characters in Go (2013-10-23) — “The Go language defines the word rune as an alias for the type int32”
- pkg.go.dev — sort (เข้าถึง 2026-08-27) — เอกสารของ
sort.Sliceแนะนำเองให้ไปใช้slices.SortFunc - stringintconv analyzer (เข้าถึง 2026-08-27) — ตัวตรวจที่จับ
string(int)และอยู่ในชุดปริยายของgo test
เช็กความเข้าใจ — บทที่ 4
ข้อ 1 / 3`base := []int{1,2,3,4,5}` แล้ว `head := base[:2]` ตามด้วย `head = append(head, 99)` เกิดอะไรขึ้นกับ base