Go Fundamentals
Type System & Zero Values
Go เป็น statically typed และทุก type มี zero value (ค่าเริ่มต้นเมื่อไม่ได้กำหนดค่า) เช่น
0 สำหรับ numeric, "" สำหรับ string, nil สำหรับ pointer/slice/map/channel/interface/func
var i int // 0
var s string // ""
var p *int // nil
var sl []int // nil (แต่ len(sl) == 0 ใช้ได้ปลอดภัย)
var m map[string]int // nil (อ่านได้ แต่เขียนจะ panic!)
nil map อ่านค่าได้ (m["x"] จะได้ zero value) แต่ m["x"] = 1 จะ panic ต้อง
make(map[string]int) ก่อนเขียนเสมอ
"ภาชนะว่าง อ่านได้ เขียนไม่ได้" — nil slice/map เหมือนแก้วเปล่าที่มองเห็น (อ่าน/len ได้) แต่
เทน้ำใส่ (เขียน) ไม่ได้จนกว่าจะมีแก้วจริง (make) ส่วน nil channel ถ้า send/receive จะ
block ตลอดกาล ไม่ panic — คนละพฤติกรรมกับ map จำแยกไว้: "map เขียนพัง, channel เขียนค้าง"
Zero value ของ struct คือ zero value ของทุก field รวมกัน ซึ่งทำให้บาง struct "ใช้งานได้ทันที
โดยไม่ต้อง constructor" ถ้าออกแบบดีๆ เช่น sync.Mutex{} หรือ bytes.Buffer{} ใช้ได้เลยโดยไม่ต้อง
New...() — นี่คือหลักการที่เรียกว่า "useful zero value" ที่ Go team ออกแบบ API ไว้ตั้งใจ
เวลาออกแบบ struct ของตัวเอง ลองถามตัวเองว่า "zero value ของ struct นี้ใช้งานได้เลยไหม" จะช่วยให้
API เรียบง่ายขึ้นมาก
Structs & Composition
Go ไม่มี inheritance แต่ใช้ composition ผ่าน embedding แทน
type Animal struct {
Name string
}
func (a Animal) Speak() string {
return a.Name + " makes a sound"
}
type Dog struct {
Animal // embedded struct — ได้ method Speak() มาด้วย
Breed string
}
d := Dog{Animal: Animal{Name: "Rex"}, Breed: "Labrador"}
fmt.Println(d.Speak()) // "Rex makes a sound"
"Embedding = ยืมของมาใช้ ไม่ใช่เป็นลูกหลาน" — Dog ไม่ได้ "เป็น" Animal แบบ inheritance
(is-a ที่ runtime รู้จัก) มันแค่ "มี" Animal อยู่ข้างในแล้ว Go เอื้อให้เรียก field/method ผ่าน
ไปถึงได้แบบไม่ต้องพิมพ์ชื่อกลาง (d.Speak() แทนที่จะต้องเขียน d.Animal.Speak()) — เรียกว่า
"promotion" ไม่ใช่ inheritance จริง
ถ้า Dog ประกาศ method ชื่อ Speak() ของตัวเองด้วย จะ shadow (บัง) method ที่ promote มา
จาก Animal โดยอัตโนมัติ ไม่มี error ไม่มี warning — และถ้า struct embed สอง type ที่มี
method/field ชื่อซ้ำกัน (ambiguous) จะ compile error ก็ต่อเมื่อถูกเรียกใช้งานจริงเท่านั้น
ไม่ error ตอน struct ถูกประกาศ เป็นจุดที่ debug งงมากเวลาเจอครั้งแรก เพราะ error message จะชี้
ไปที่บรรทัดที่เรียกใช้ ไม่ใช่บรรทัดที่ประกาศ struct
Interfaces — Implicit Satisfaction
Interface ใน Go ถูก satisfy แบบ implicit (ไม่ต้องเขียน implements) — ถ้า type มี method
ตรงตาม signature ก็ถือว่า implement interface นั้นโดยอัตโนมัติ นี่คือหัวใจของการออกแบบโค้ด Go
ที่ยืดหยุ่นและ testable
type Writer interface {
Write(p []byte) (n int, err error)
}
// os.File, bytes.Buffer, strings.Builder ฯลฯ ล้วน satisfy Writer โดยไม่ต้องประกาศอะไรเพิ่ม
หลักออกแบบที่สำคัญ: "Accept interfaces, return structs" — รับ parameter เป็น interface (เพื่อความยืดหยุ่นและ testability) แต่ return เป็น concrete type (ให้ caller เข้าถึง method เฉพาะของ type นั้นได้เต็มที่)
"รับกว้าง คืนแคบ" (accept wide, return narrow) — นึกภาพกรวย: ปากกว้างรับ interface อะไรก็ได้ ที่ match เข้ามา แต่ปลายแคบคืนของจริงที่ชัดเจนออกไป ถ้าจำ "accept interfaces, return structs" ไม่ได้ ให้จำแค่คำนี้คำเดียว
nil interface ≠ nil pointer ที่ถูกเก็บใน interface — นี่คือกับดักที่แม้แต่ Go dev ที่เขียน มาหลายปียังพลาด:
type MyError struct{}
func (e *MyError) Error() string { return "boom" }
func doSomething() error {
var err *MyError // nil
return err // return nil pointer ที่ถูกห่อใน interface error
}
err := doSomething()
fmt.Println(err == nil) // false! เพราะ interface เก็บทั้ง (type, value) และ type ไม่ใช่ nil
Interface ภายในคือ pair (type, value) — err == nil จะ true ก็ต่อเมื่อทั้ง type และ value
เป็น nil พร้อมกันเท่านั้น ถ้า concrete type ถูกเก็บไว้ (แม้ value จะเป็น nil pointer) interface
นั้นจะไม่เท่ากับ nil วิธีป้องกัน: ฟังก์ชันที่ return error ควร return nil ตรงๆ ไม่ใช่
return ตัวแปร pointer ที่อาจเป็น nil
Pointers vs Values
func increment(n int) { n++ } // แก้ copy ไม่มีผลกับตัวต้นฉบับ
func incrementP(n *int) { *n++ } // แก้ค่าจริงผ่าน pointer
เมื่อไหร่ควรใช้ pointer receiver:
- ต้องการแก้ไข state ของ struct นั้นจริงๆ
- struct มีขนาดใหญ่ (หลีกเลี่ยงการ copy ที่สิ้นเปลือง)
- struct มี mutex หรือ field ที่ copy ไม่ได้ (เช่น
sync.Mutex)
กฎง่ายๆ: ถ้า method หนึ่งของ type ใช้ pointer receiver ให้ทุก method ของ type นั้นใช้ pointer receiver ให้สอดคล้องกัน
"ตัวใหญ่ชี้ ตัวเล็กส่ง" — struct ใหญ่/มี state ที่ต้องแก้ ให้ "ชี้" ด้วย pointer, struct เล็กๆ
ไม่กี่ field (เช่น time.Duration, จุดพิกัด {X, Y}) ส่งเป็น "value" ตรงๆ ได้เลย เร็วกว่าด้วย
ซ้ำเพราะไม่ต้อง allocate บน heap
Go compiler ตัดสินใจเองว่าตัวแปรจะอยู่ stack หรือ heap ผ่าน escape analysis — ไม่ใช่กฎตายตัว
ว่า "pointer = heap เสมอ" ถ้า compiler พิสูจน์ได้ว่าตัวแปรที่ถูก pointer ชี้ไม่ "หนี" ออกจาก
scope ของฟังก์ชัน (ไม่ถูก return, ไม่ถูกเก็บไว้ที่อื่น) มันจะอยู่บน stack แม้จะส่งผ่าน pointer
ก็ตาม เช็คได้จริงด้วย go build -gcflags="-m" — เป็นจุดที่แยกคนเข้าใจ Go ตื้นๆ กับคนเข้าใจลึกได้
ชัดเจนมากในสัมภาษณ์
Error Handling Idiom
Go ไม่มี exception — error คือ ค่า return ปกติ ที่ต้องเช็กทุกครั้ง
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
result, err := divide(10, 0)
if err != nil {
return fmt.Errorf("divide failed: %w", err) // %w wrap error เก็บ chain ไว้
}
ใช้ errors.Is / errors.As เพื่อตรวจสอบ error แบบ chain ได้:
if errors.Is(err, sql.ErrNoRows) { /* handle not found */ }
var valErr *ValidationError
if errors.As(err, &valErr) { /* handle specific type */ }
"Is แปลว่า เท่ากับ, As แปลว่า แปลงร่าง" — errors.Is ใช้เช็คว่า error "คือ"
sentinel ตัวไหนในสาย chain (เหมือน ==) ส่วน errors.As ใช้ "แปลงร่าง" error กลับเป็น concrete
type เพื่อดึง field พิเศษออกมาใช้ (เหมือน type assertion ที่ไล่ลงไปทั้ง chain)
%w ใน fmt.Errorf ทำงานได้เพราะ error ที่ wrap แล้ว implement interface ที่ไม่มีใครเห็นชัดๆ
คือ interface { Unwrap() error } — errors.Is/errors.As เดินตาม chain นี้ไปเรื่อยๆ ด้วยการ
เรียก Unwrap() ซ้ำๆ จนเจอหรือหมด chain ถ้าใช้ %v แทน %w จะไม่มี Unwrap() ถูกสร้างให้ —
error จะ "หลุด chain" ทันที คนที่ไม่รู้เรื่องนี้มักจะงงว่าทำไม errors.Is ถึง return false
ทั้งที่ "เห็น" ข้อความ error เดิมอยู่ในสตริง (เพราะมันดู string ไม่ได้ มันดู chain ของ
Unwrap())
Slices: len, cap, และ Shared Backing Array
Slice คือ "view" บน array ภายใน (ptr, len, cap) — การ append อาจสร้าง array ใหม่
หรือใช้ของเดิมก็ได้ ขึ้นกับว่า cap พอหรือไม่ นี่คือบั๊กคลาสสิกที่ควรรู้:
a := make([]int, 3, 5)
b := a[:2]
b = append(b, 99) // cap พอ → เขียนทับ a[2] โดยไม่รู้ตัว!
ถ้าต้องการ slice ใหม่จริงๆ ที่ไม่ share memory กับต้นฉบับ ใช้ copy() หรือ append([]T{}, src...)
"Slice คือรีโมท ไม่ใช่ทีวี" — slice เป็นแค่ตัวชี้ไปยัง array จริงที่อยู่ข้างหลัง (ptr+len+cap)
สอง slice อาจ "ชี้" ไปที่ทีวี (array) เครื่องเดียวกัน เปลี่ยนช่องจากรีโมทไหนก็กระทบจอเดียวกันหมด
— ถ้าไม่อยากแชร์จอ ต้องซื้อทีวีเครื่องใหม่ (copy/append([]T{}, ...))
a[low:high:max] คือ full slice expression ที่คนส่วนใหญ่ไม่เคยใช้ — พารามิเตอร์ตัวที่ 3
กำหนด cap ของ slice ใหม่ให้แคบลงได้ (max - low) ทำให้ append ครั้งถัดไป บังคับให้สร้าง
array ใหม่แทนที่จะเขียนทับของเดิม เป็นเทคนิคที่ใช้ป้องกันบั๊ก "เขียนทับโดยไม่รู้ตัว" ข้างบน
ได้โดยไม่ต้อง copy ทั้งก้อน:
b := a[:2:2] // cap ของ b ถูกบังคับให้เป็น 2 พอดี
b = append(b, 99) // cap ไม่พอ → สร้าง array ใหม่ทันที ไม่กระทบ a
Go has no inheritance — composition via struct embedding is the idiom. Interfaces are satisfied
implicitly ("duck typing" at compile time): "accept interfaces, return structs." Every type has
a zero value; a nil map panics on write but not on read. Errors are ordinary return values,
not exceptions — wrap with %w and inspect with errors.Is/errors.As. Slices share a backing
array, so append on a sub-slice can silently overwrite the original if capacity allows it —
a classic interview gotcha. Two deep-dive interview differentiators: the nil-interface-vs-nil-
pointer trap (an interface is a (type, value) pair, so a nil pointer wrapped in an interface
is not == nil), and the three-index full slice expression a[low:high:max] for capping
capacity to force a fresh backing array on the next append.