Structs
การนิยาม struct
หัวข้อที่มีชื่อว่า “การนิยาม struct”Struct คือการรวม field ที่เกี่ยวข้องกันไว้ภายใต้ชื่อ type เดียว แต่ละ field มีชื่อและ type:
type Point struct { X, Y int}
type User struct { Name string Email string Age int}Field ที่ขึ้นต้นด้วยตัวพิมพ์ใหญ่จะถูก export (มองเห็นได้จากภายนอก package) ส่วน field ที่ขึ้นต้นด้วยตัวพิมพ์เล็กจะเป็น unexported
Struct tags
หัวข้อที่มีชื่อว่า “Struct tags”Struct tag คือ string literal ธรรมดาที่แนบไว้กับ field เขียนต่อท้าย type ของมันเลย:
type User struct { Name string `json:"name" db:"user_name"` Age int `json:"age,omitempty"`}Compiler ไม่ทำอะไรกับ tag เลยนอกจากเก็บมันไว้เฉย ๆ — มันไม่มีผลต่อการ compile หรือการเข้าถึง field ใด ๆ tag จะมีความหมายก็ต่อเมื่อมีอะไรมาอ่านมันผ่าน reflection ตอน runtime ซึ่งนี่คือสิ่งที่ encoding/json, ORM ส่วนใหญ่, และ validation library ทำกัน:
t := reflect.TypeOf(User{})f, _ := t.FieldByName("Name")fmt.Println(f.Tag.Get("json")) // namefmt.Println(f.Tag.Get("db")) // user_nameเพราะ tag เป็นแค่ string จนกว่าจะมีอะไรมา parse มัน การพิมพ์ชื่อ key ผิดจึง compile ผ่านสบาย ๆ และไม่มี warning เลย — มันแค่หยุดทำสิ่งที่ตั้งใจไว้แบบเงียบ ๆ:
type Broken struct { Name string `jso:"name"` // typo: "jso" instead of "json" Age int `json:"age"`}b, _ := json.Marshal(Broken{Name: "Ada", Age: 30})fmt.Println(string(b)) // {"Name":"Ada","age":30} — Name fell back to the Go field nameverify บน 1.26.5 แล้ว: compile ผ่าน go vet ./... ไม่รายงานอะไรเลย และตอนรันจะ marshal Name ด้วยชื่อ Go field ของมัน เพราะ encoding/json ไม่เจอ tag ภายใต้ key "json" เลย สิ่งที่ go vet’s structtag check เช็คได้จริง คือ tag ที่ไม่ใช่ syntax ที่ valid ของ reflect.StructTag — เช่นขาด space ระหว่างคู่ key:"value":
type Bad struct { Name string `json:"name",omitempty`}main.go:6:2: struct field tag `json:"name",omitempty` not compatible with reflect.StructTag.Get: key:"value" pairs not separated by spacesดังนั้น go vet ป้องกันได้แค่ tag syntax ที่ผิดรูป แต่ป้องกันไม่ได้ถ้า tag ใช้ ชื่อ key ผิดสำหรับ library ที่จะมาอ่านมัน — ความผิดพลาดแบบนั้นจับได้ก็ต่อเมื่อรันโค้ด path การ marshal/unmarshal จริง ๆ (test)
Struct literals
หัวข้อที่มีชื่อว่า “Struct literals”คุณสามารถสร้างค่า struct ได้สองแบบ:
// Positional — order must match the field declaration orderp := Point{3, 4}
// Named fields — order does not matter; unset fields get zero valuesรูปแบบ named-field เป็นที่นิยมมากกว่า เพราะอ่านง่ายและทนต่อการเปลี่ยนลำดับ field
การเข้าถึง field
หัวข้อที่มีชื่อว่า “การเข้าถึง field”ใช้จุด (dot) เพื่ออ่านหรือเขียน field:
fmt.Println(u.Name) // readu.Age = 30 // writeAnonymous structs
หัวข้อที่มีชื่อว่า “Anonymous structs”สำหรับ shape ที่ใช้ครั้งเดียว — configuration, test fixture, หรือการจัดกลุ่มภายใน — คุณสามารถนิยาม struct type แบบ inline โดยไม่ตั้งชื่อ:
cfg := struct { Host string Port int}{Host: "localhost", Port: 8080}Zero-value-useful design
หัวข้อที่มีชื่อว่า “Zero-value-useful design”Struct ที่ออกแบบดีควรทำงานถูกต้องตั้งแต่ declare เสร็จ โดยไม่ต้องเรียก constructor เลย — var b bytes.Buffer หรือ var mu sync.Mutex พร้อมใช้งานอยู่แล้ว pattern นี้เรียกว่าทำให้ zero value มีประโยชน์ (useful) และเป็น idiomatic Go ไม่ใช่เรื่องบังเอิญ: zero value ของ bytes.Buffer คือ buffer ว่าง, zero value ของ sync.Mutex คือ mutex ที่ยัง unlock อยู่ เทียบกับ type ที่ panic หรือทำงานผิดจนกว่าจะเรียก Init() หรือ New() ก่อน — caller ทุกคนต้องจำขั้นตอนเพิ่มนี้ และการลืมมันคือ runtime bug ไม่ใช่ compile error
เวลาออกแบบ struct ของตัวเอง ให้เลือก field type ที่ zero value ของมันมีความหมายที่สมเหตุสมผลอยู่แล้ว: slice ที่เป็น nil แต่ append ได้อย่างปลอดภัย, sync.Mutex (ไม่ใช่ *sync.Mutex ที่ต้อง allocate), time.Time ที่ zero value ให้ IsZero() เป็น true แทนที่จะเป็น *time.Time ที่ต้อง nil check ทุกที่ เก็บ New/constructor function ไว้ใช้เฉพาะกรณีที่จำเป็นจริง ๆ (field ที่ required, value ที่ต้อง validate, หรือ field แบบ map ที่รู้อยู่แล้วว่าจะถูกเขียนทันที)
Empty struct struct{}
หัวข้อที่มีชื่อว่า “Empty struct struct{}”struct{} เป็น type จริง ๆ ที่มีคุณสมบัติพิเศษ: มันใช้พื้นที่ ศูนย์ byte
var s struct{}fmt.Println(unsafe.Sizeof(s)) // 0เพราะไม่มีต้นทุนในการเก็บเลย struct{} จึงเป็น value type ที่ idiomatic สำหรับสอง pattern: set (map[K]struct{} เมื่อสนใจแค่ว่า key มีอยู่หรือไม่ ไม่สนใจ value ที่ผูกมาด้วย) และ channel แบบ signal-only (chan struct{} เมื่อการ send/receive หมายถึง “สิ่งนี้เกิดขึ้นแล้ว” ไม่ใช่ “นี่คือ payload”):
set := map[string]struct{}{"a": {}, "b": {}}_, ok := set["a"]fmt.Println(len(set), ok) // 2 true
done := make(chan struct{})close(done)_, open := <-donefmt.Println(open) // false — closed and drained, the classic "done" signalmap[K]bool ก็ใช้เป็น set ได้เหมือนกัน แต่ map[K]struct{} สื่อเจตนาได้ชัดเจนกว่า (ไม่มี entry ที่เป็น false ให้เผลอไปเช็ค) และใช้ memory น้อยกว่าเมื่อข้อมูลเยอะ
การเปรียบเทียบ struct
หัวข้อที่มีชื่อว่า “การเปรียบเทียบ struct”ค่า struct สองค่าสามารถเปรียบเทียบด้วย == ได้ ถ้า ทุก field เป็น comparable type การเปรียบเทียบจะทำทีละ field:
a := Point{1, 2}b := Point{1, 2}fmt.Println(a == b) // trueStruct ที่มี field เป็น slice, map, หรือ function ไม่สามารถ เปรียบเทียบด้วย == — นี่เป็น compile error ไม่ใช่ runtime error ดังนั้น compiler จับได้ทันที มีกับดักเรื่อง comparability ที่ subtle กว่านั้นอีกสองแบบที่เป็น runtime-only:
NaN ไม่เท่ากับตัวมันเองเลย แม้แต่ตัวแปรเดียวกันเป๊ะ ๆ เพราะการเปรียบเทียบ floating-point เป็นไปตาม IEEE 754 ไม่ใช่ value identity:
type WithNaN struct{ F float64 }w := WithNaN{F: math.NaN()}fmt.Println(w == w) // false — NaN != NaN, even the same variableStruct field ที่เป็น interface type จะโยนเรื่อง comparability ไปให้สิ่งที่เก็บอยู่ข้างในแทน struct type-check ผ่านว่า comparable ตอน compile time (interface field comparable แบบ static เสมอ) แต่การเปรียบเทียบจริงยัง panic ตอน runtime ได้ ถ้า dynamic value ที่มันเก็บอยู่กลายเป็น slice, map, หรือ function:
var sx, sy any = []int{1}, []int{1}s3 := struct{ V any }{V: sx}s4 := struct{ V any }{V: sy}fmt.Println(s3 == s4) // panics: comparing uncomparable type []intverify บน 1.26.5 แล้ว:
panic: runtime error: comparing uncomparable type []intfield any ที่เก็บ int เทียบกันได้สบาย ๆ แต่ field เดียวกันที่เก็บ []int panic compiler บอกไม่ได้ว่าเราอยู่ใน case ไหน — มันรู้แค่ static type คือ any ซึ่ง comparable เสมอ นี่คือความเสี่ยงจริงในทุก struct ที่มี field เป็น any/interface{} แล้วถูกเอาไปเทียบด้วย == หรือใช้เป็น map key
Unkeyed literal กับ go vet
หัวข้อที่มีชื่อว่า “Unkeyed literal กับ go vet”Struct literal แบบ positional (unkeyed) — Point{3, 4} แทนที่จะเป็น Point{X: 3, Y: 4} — เป็น Go ที่ valid แต่จะพังแบบเงียบ ๆ ถ้าผู้เขียน struct นั้นเรียงลำดับหรือแทรก field ใหม่ทีหลัง เพราะค่าถูก match เข้ากับ field ตามตำแหน่ง ไม่ใช่ตามชื่อ go vet’s composites check จะ flag unkeyed literal ของ struct type ที่มาจาก package อื่น:
r := shapes.Rect{10, 20} // shapes is a different packageverify บน 1.26.5 (go vet ./...) แล้ว:
main.go:10:7: example.com/t09b/shapes.Rect struct literal uses unkeyed fieldsมีข้อควรรู้อีกอย่าง: check นี้ยกเว้น type ของ stdlib ที่มั่นคงมานานตามรายการที่ hardcode ไว้ — image.Point{1, 2} เป็น Go แบบ idiomatic และ go vet ไม่พูดอะไรเลย ทั้งที่เป็น pattern เดียวกันเป๊ะ อย่าถือว่า “go vet เงียบ” คือหลักฐานว่า literal นั้นปลอดภัย ให้ถือว่า unkeyed literal จาก package ไหนก็ตาม เป็นนิสัยที่ควรหลีกเลี่ยง ไม่ว่าจะได้รับการยกเว้นจาก vet หรือไม่ก็ตาม ส่วน positional literal ของ struct ที่ declare ใน ไฟล์เดียวกัน กับที่กำลังแก้อยู่นั้นพบได้ทั่วไปและไม่มีปัญหา — เพราะจะสังเกตเห็นทันทีถ้ามีการเรียง field ใหม่
ขนาด, การจัดตำแหน่ง (alignment), และ padding
หัวข้อที่มีชื่อว่า “ขนาด, การจัดตำแหน่ง (alignment), และ padding”Go จัดวาง field ของ struct ตามลำดับที่ declare แต่ไม่จำเป็นต้องติดกันเสมอ: แต่ละ field ต้องเริ่มที่ address ที่หารด้วย alignment ของมันลงตัว (int64 align ที่ 8 byte, bool ที่ 1 byte) ดังนั้น compiler จะแทรก padding byte ที่มองไม่เห็นระหว่าง field เมื่อจำเป็น ลำดับ field เปลี่ยนขนาดรวมของ struct ได้:
type Bad struct { A bool // 1 byte, then 7 bytes of padding to align B B int64 // 8 bytes C bool // 1 byte, then 7 bytes of trailing padding (struct size rounds up to 8)}
type Good struct { B int64 // 8 bytes A bool // 1 byte C bool // 1 byte, then 6 bytes of trailing padding}flowchart TD
subgraph Bad["Bad: 24 bytes"]
direction LR
BA["A (1B)"] --- BP1["padding (7B)"] --- BB["B (8B)"] --- BC["C (1B)"] --- BP2["padding (7B)"]
end
subgraph Good["Good: 16 bytes"]
direction LR
GB["B (8B)"] --- GA["A (1B)"] --- GC["C (1B)"] --- GP["padding (6B)"]
end verify บน 1.26.5 (unsafe.Sizeof/unsafe.Offsetof) แล้ว:
fmt.Println(unsafe.Sizeof(Bad{}), unsafe.Sizeof(Good{})) // 24 16fmt.Println(unsafe.Offsetof(Bad{}.B), unsafe.Offsetof(Good{}.B)) // 8 0Bad และ Good มี field ทั้งสามตัวเหมือนกันเป๊ะ — ต่างกันแค่ลำดับ declare แต่มีผลถึง 8 byte ต่อ value กฎง่าย ๆ ที่จำได้: เรียง field จากใหญ่ไปเล็ก (หรือจัดกลุ่ม field ขนาดเท่ากันไว้ด้วยกัน) เพื่อลด padding ให้น้อยที่สุด เรื่องนี้สำคัญที่สุดกับ struct ที่ allocate จำนวนมาก — slice ของ Bad หนึ่งล้านตัวเสีย memory ไปฟรี ๆ 8 ล้าน byte unsafe.Sizeof และ unsafe.Offsetof คือเครื่องมือที่ใช้เช็คเรื่องนี้ — หยิบมาใช้เมื่อ struct ใน hot-path มีขนาดใหญ่กว่าที่ผลรวมของ field มันควรจะเป็น
Struct embedding (composition)
หัวข้อที่มีชื่อว่า “Struct embedding (composition)”Composition ผ่าน embedding — การ promote field และ method ของ embedded type และเหตุผลว่าทำไมมันคือ composition ไม่ใช่ inheritance — อธิบายไว้ละเอียดใน Embedding
package main
import ( "fmt" "math")
type Address struct { City string Country string}
type Person struct { Name string Age int}
type WithNaN struct{ F float64 }
func main() { // Named-field literal p1 := Person{Name: "Alice", Age: 30} fmt.Println(p1.Name, p1.Age)
// Struct copy: p2 is an independent value p2 := p1 p2.Name = "Bob" fmt.Println(p1.Name, p2.Name) // Alice Bob
// Anonymous struct cfg := struct { Host string Port int }{Host: "localhost", Port: 8080} fmt.Printf("%s:%d\n", cfg.Host, cfg.Port)
// Struct comparison (all fields comparable) a1 := Address{City: "Bangkok", Country: "Thailand"} a2 := Address{City: "Bangkok", Country: "Thailand"} fmt.Println(a1 == a2) // true
// Unkeyed vs keyed literal: same value, but unkeyed breaks silently on reorder unkeyed := Address{"Bangkok", "Thailand"} keyed := Address{City: "Bangkok", Country: "Thailand"} fmt.Println(unkeyed == keyed) // true
// NaN never equals itself, even the same variable w := WithNaN{F: math.NaN()} fmt.Println(w == w) // false} ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
| value semantics | predictable behavior, ไม่มี aliasing | copy cost สำหรับ struct ขนาดใหญ่ |
| zero-value-useful design | ไม่ต้องเรียก constructor, nil check น้อยลง | ไม่ใช่ทุก type จะมี zero value ที่มีความหมายได้ (เช่น type ที่มี required field) |
| struct tags | metadata สำหรับ JSON/DB/validation แบบ declarative | runtime-only — ไม่มี compile-time check ว่าชื่อ key ของ tag ถูกต้อง |
| เรียง field ตามขนาด (คำนึงถึง padding) | struct เล็กลง ใช้ memory น้อยลงเมื่อข้อมูลเยอะ | อ่านยากกว่าการจัดกลุ่ม field ตาม “ความหมาย” เล็กน้อย |
| exported vs unexported fields | control API surface ได้ชัดเจน | refactoring ที่ expose field ใหม่เป็น breaking change |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- struct copy เสมอแพง — struct เล็ก (2-4 fields) copy เร็วกว่าการใช้ pointer ที่ต้อง dereference และอาจทำให้ object escape to heap
- unexported field = private ใน type — unexported หมายถึง package-level visibility — code ใน package เดียวกันเข้าถึงได้ทั้งหมด
- struct tags มีผลตอน compile — struct tags เป็น string literal ที่ reflection อ่านตอน runtime — typo ในชื่อ key ของ tag compile ผ่านสบาย ๆ และ
go vetก็จับไม่ได้ด้วย จับได้ก็ต่อเมื่อรันโค้ด path ที่อ่าน tag นั้นจริง ๆ go vetจับความเสี่ยงเรื่อง unkeyed literal ได้ทุกกรณี — มันจับได้แค่ unkeyed literal ของ type จาก package อื่น เท่านั้น และยกเว้น type ของ stdlib ที่มั่นคงตามรายการที่ hardcode ไว้อย่างimage.Point- struct value ที่หน้าตาเท่ากันสองตัวเทียบเท่ากันเสมอ — จริงแบบ field-by-field ยกเว้น field
float64ที่เป็นNaNซึ่งไม่เท่ากับตัวมันเองเลย และ field ที่เป็นany/interface ซึ่ง panic ตอน runtime ได้ถ้า dynamic type ของมันเป็น slice, map, หรือ function
💡 ตัวอย่างจากของจริง
bytes.Buffer(stdlib) มี zero value ที่มีประโยชน์ —var b bytes.Bufferพร้อมWriteและReadได้ทันทีโดยไม่ต้องเรียก constructor เลย ตรงตาม pattern zero-value-useful ที่บทนี้แนะนำพอดี
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”Bug ที่เกี่ยวกับ struct มักซ่อนอยู่ในสิ่งที่ดูเหมือน boilerplate เช็คสิ่งเหล่านี้:
- struct field ใช้ tag key ที่ไม่ตรงกับที่ library ต้องการจริง ๆ หรือเปล่า (
jso:"name"แทนที่จะเป็นjson:"name", field ที่ doc ของ ORM เรียกชื่ออื่น)? แบบนี้ compile ผ่านและมักจะ “ทำงานได้ครึ่งหนึ่ง” — บาง field serialize ได้ บาง field ไม่ได้แบบเงียบ ๆ - literal แบบ positional (unkeyed) ถูกใช้กับ struct ที่ declare ใน package หรือไฟล์ อื่น ที่ผู้เขียนไม่ได้เพิ่งเขียนเองหรือเปล่า? การเรียง field ใหม่จากต้นทางจะทำให้ค่าสลับกันแบบเงียบ ๆ
- มีอะไรเทียบ struct สองตัวด้วย
==หรือใช้ struct เป็น map key ทั้งที่ field หนึ่งเป็นany/interface{}หรือเปล่า? นั่นคือ panic ที่ซ่อนรอ slice หรือ map value ตัวแรกที่ไหลผ่านมัน - field ของ struct ถูกเรียงตามความอ่านง่ายโดยไม่คิดเรื่องขนาดเลยหรือเปล่า — field เล็ก (bool, int8) กระจายอยู่ระหว่าง field ใหญ่ (int64, pointer, string)? ไม่ใช่ bug แต่ควร flag ไว้ถ้าเป็น type ใน hot-path หรือถูก allocate จำนวนมาก
- type ต้องเรียก
New()/Init()ก่อนถึงจะใช้ได้อย่างปลอดภัยหรือเปล่า ทั้งที่ zero value ของมันน่าจะทำให้ใช้ได้เลยได้? caller ทุกคนที่ลืมเรียกจะมี bug แฝงอยู่ และไม่มีอะไรบังคับให้เรียกตอน compile time เลย
🐛 หา bug ในโค้ดนี้
type Metrics struct { Name string `json:"name"` Count int `jso:"count"`}
func main() { m := Metrics{Name: "requests", Count: 42} b, _ := json.Marshal(m) fmt.Println(string(b))}เฉลย
Tag ของ field ที่สองพิมพ์ผิด: `jso:"count"` แทนที่จะเป็น `json:"count"` นี่เป็น tag syntax ที่ valid — go vet ./... ไม่รายงานอะไรเลย เพราะ structtag check ตรวจแค่ grammar ของ key:"value" ไม่ได้ตรวจว่า "jso" เป็น key ที่ encoding/json รู้จักหรือเปล่า struct นี้ compile และรันได้ปกติ แต่ Count จะ marshal ด้วยชื่อ Go field ของมันแทนที่จะเป็น key ที่ตั้งใจ: {"name":"requests","Count":42} ไม่มีอะไรจับความผิดพลาดนี้ได้เลยนอกจาก test ที่ assert รูปร่าง JSON ตรง ๆ — มันจะไม่ทำให้ build พัง, lint พัง, หรือแม้แต่มองเผิน ๆ ที่ตัว struct ก็ไม่เห็น