1 · interface{} / any — กล่องที่ใส่อะไรก็ได้
interface{} คือ interface ที่ไม่ขอ method อะไรเลย
→ ทุก type ในโลกจึงผ่านเงื่อนไขนี้อัตโนมัติ ตั้งแต่ Go 1.18 มีชื่อสั้นกว่าให้ใช้
คือ any (จำจาก บทเรียน 09:
type any = interface{} — เป็น alias คือชื่อเรียกแทน
ของชิ้นเดียวกันที่มีสองชื่อ ไม่ใช่ type ใหม่ เขียนอันไหนก็ได้ผลเท่ากันเป๊ะ)
func describe(v any) {
fmt.Printf("Value: %v, Type: %T\n", v, v)
}
describe("hello") // Value: hello, Type: string
describe(42) // Value: 42, Type: int
describe(true) // Value: true, Type: bool
describe(Score(95.5)) // Value: 95.5, Type: main.Score
ตัวแปร any เก็บ สองอย่าง เสมอ:
┌───────────────┬───────────────┐
│ type จริง │ ค่าจริง │
├───────────────┼───────────────┤
│ string │ "hello" │
└───────────────┴───────────────┘
↑ %T อ่านช่องนี้ ↑ %v อ่านช่องนี้
type assertion = ถามช่องซ้าย แล้วขอช่องขวาถ้าตรง
พัสดุที่มาส่งคือกล่องปิดเทป มองไม่เห็นข้างใน แต่มีใบปะหน้าบอกว่าข้างในคืออะไร
ยังไม่แกะ ก็ใช้ของข้างในไม่ได้ ถือได้อย่างเดียว
ตัวแปร any คือกล่องแบบนั้น: ใบปะหน้า = type จริง (%T อ่านช่องนี้)
· ของข้างใน = ค่าจริง (%v อ่านช่องนี้)
· การส่องดูแล้วขอสำเนาของข้างใน = หัวข้อ 2 กับ 3
ที่ไม่เหมือนกัน: (1) ใบปะหน้าพัสดุคนเขียนผิดได้ แต่ของ any
Go เขียนให้เองตอนใส่ จึงไม่มีทางผิด — แกะแล้วพังเมื่อไหร่
ไม่ใช่เพราะป้ายผิด แต่เพราะเราเดาผิดเอง
(2) กล่องนี้แกะแล้วไม่ว่าง — i.(string) คืนสำเนาของค่าออกมา
ตัว i ยังถือของเดิมไว้ครบ จะ assert ซ้ำกี่รอบก็ได้
any คือการปิดตา compiler — บั๊กที่เคยถูกจับตอน
compile จะไปโผล่ตอน runtime แทน ใช้เมื่อจำเป็นจริง ๆ เท่านั้น
เช่นตอนแกะ JSON ที่ไม่รู้โครงสร้างล่วงหน้า หรือเขียน logger
ถ้ารู้ type อยู่แล้วก็เขียน type นั้นตรง ๆ
ของที่เพิ่งเข้าใจไป วงการเรียกแบบนี้ — ใช้คำให้ตรง คำตอบจาก senior หรือจาก AI จะตรงตาม
- any
- “กล่องที่ใส่อะไรก็ได้” · เวลาติดให้พูดว่า “ฟังก์ชันนี้รับเป็น any ต้อง assert เองก่อนใช้ใช่ไหม” จะสื่อกว่า “ตัวแปรนี้ใช้ไม่ได้”
- empty interface
- ชื่ออังกฤษของ
interface{}· เสิร์ชเป็นภาษาอังกฤษให้ใช้คำนี้ จะเจอตรงกว่าคำว่า any - static type / dynamic type
- static type = type ที่ compiler เห็น (ตรงนี้คือ
any) · dynamic type = type จริงที่อยู่ในกล่องตอนรัน (string) · พูดว่า “static type เป็น any แต่ dynamic type เป็น string” แล้ว senior จะรู้ทันทีว่าคุณติดตรงไหน
2 · Type Assertion — แบบดิบ (เสี่ยง panic)
var i any = "hello"
s := i.(string) // syntax: x.(T)
fmt.Println(s) // hello
var i any = "hello"
n := i.(int)
fmt.Println(n)
เกิดอะไรขึ้น — compile error, พิมพ์ 0 หรือ panic?
Panic ตอน runtime — compile ผ่านสบาย แล้วพังตอนรัน ข้อความจริงที่ผมรันได้:
panic: interface conversion: interface {} is string, not int
อ่านข้อความนี้ให้ออก: "กล่องนี้เก็บ string อยู่ ไม่ใช่ int"
compiler ช่วยอะไรไม่ได้เลย เพราะตอน compile มันเห็นแค่ว่า
i เป็น any — จะเป็นอะไรก็ได้
3 · Comma-ok — แบบปลอดภัย ใช้อันนี้
var i any = "hello"
s, ok := i.(string)
if ok {
fmt.Println("String value:", s)
} else {
fmt.Println("Not a string")
}
| เขียนแบบ | ok | ค่าที่ได้ | ถ้าไม่ตรง |
|---|---|---|---|
s := i.(string) | — | ค่าจริง | panic 💥 |
s, ok := i.(string) | true/false | ค่าจริง หรือ zero value | ไปต่อได้ ไม่พัง |
// ผลรันจริงเมื่อ i เก็บ string อยู่:
s, ok := i.(string) // s = "hello", ok = true
n, ok := i.(int) // n = 0, ok = false ← zero value ไม่ใช่ขยะ
คุณไปที่เคาน์เตอร์รับพัสดุแล้วบอกว่า “ขอกล่องที่เป็นรองเท้า”
ทั้งสองแบบพนักงานเปิดดูให้เหมือนกัน ไม่มีแบบไหนข้ามการเช็ค —
ต่างกันแค่ตอนที่ข้างในไม่ตรง:
· แบบดิบ (s := i.(string)) = พนักงานโวยวายจนงานล่มทั้งร้าน
· comma-ok (s, ok := i.(string)) = พนักงานแค่บอกว่า
ไม่ใช่ · คุณกลับมือเปล่า แต่ยังเดินต่อได้
“มือเปล่า” ที่ว่าไม่ใช่ของมั่ว ๆ — มันคือ zero value
ค่าเริ่มต้นประจำ type ที่ขอไป (int→0 ·
string→"") พร้อม ok = false
ที่ไม่เหมือนกัน: (1) การ “ถามก่อน” ในชีวิตจริงเป็นขั้นตอนเพิ่มที่กินเวลา
แต่ comma-ok ไม่ได้เพิ่มขั้นตอนอะไรเลย — Go เช็ค type ตอนรันเท่ากันทั้งสองแบบ
แค่แบบดิบทิ้งผลเช็คไปโดยไม่ให้เรารับไว้ · และแบบดิบไม่เคยคืนค่ามั่วให้ ถ้าไม่ตรงคือ panic เท่านั้น
(2) พนักงานโวยวายในร้านก็แค่เรื่องวุ่นวายชั่วคราว แต่ panic แรงกว่านั้น —
ไม่ได้พังแค่บรรทัดนั้น แต่ทั้งโปรแกรมหยุดทำงานทันที
รูปแบบ comma-ok คุณเคยเจอแล้ว — ตอนอ่าน map ใน
บทเรียน 07
(v, ok := m["key"]) และตอนอ่าน channel
Go ใช้สำนวนนี้ทุกที่ที่ "อาจจะไม่มี" — เจอ , ok เมื่อไหร่
แปลว่า Go กำลังบอกว่าอย่าเชื่อว่าจะได้ของ
- comma-ok
- ชื่อจริงของสำนวนรับค่าสองตัว
ค่า, ok := ...· ถูก review แล้วโดนบอกว่า “ใช้ comma-ok เถอะ” แปลว่า “อย่า assert แบบดิบ” - panic
- อาการที่โปรแกรมไปต่อไม่ไหวแล้วหยุดกลางคัน พร้อมพ่นข้อความออกจอ ·
เวลาไปถามใคร แปะบรรทัดที่ขึ้นต้นว่า
panic:ไปด้วยเสมอ — บรรทัดนั้นบอกไปแล้วครึ่งหนึ่งว่าพังเพราะอะไร ·panicยังเป็นคำสั่งที่เราสั่งเองก็ได้ (panic("...")) และดักไว้ได้ด้วยrecover()ในdefer(จะเจอในบทหลัง) — panic จึงไม่ได้แปลว่าโปรแกรมต้องตายเสมอไป แค่โดยดีฟอลต์มันตาย - runtime / compile time
- compile time = ตอนแปลโค้ด ยังไม่ได้รัน · runtime = ตอนโปรแกรมทำงานอยู่ ·
ประโยคที่ใช้ได้เลย: “อันนี้ compile ผ่านนะ แต่ panic ตอน runtime” ·
ระวังคำนี้มีสองความหมาย — อีกความหมายคือ “ตัว Go runtime”
ที่คอยจัดการ goroutine กับ GC ให้ (มี package ชื่อ
runtimeจริง ๆ และ error หลายอันขึ้นต้นว่าruntime error:) ดูจากบริบทว่าพูดถึงช่วงเวลา หรือพูดถึงตัวระบบ - zero value
- ค่าเริ่มต้นประจำ type · พูดให้ชัดว่า “assert ไม่ผ่านแล้วได้ zero value ไม่ใช่ค่าขยะ”
4 · Type Switch — เช็คหลาย type ทีเดียว
ถ้าต้องเช็ค 5 type ด้วย comma-ok ก็จะได้ if ซ้อนกัน 5 ชั้น —
ซึ่งเป็นสิ่งที่ บทเรียน 04
บอกให้เลิกทำ Go มีไวยากรณ์เฉพาะให้:
func printType(v any) {
switch value := v.(type) { // ← .(type) เขียนได้เฉพาะใน switch
case string:
fmt.Println("string:", value) // value เป็น string ตรงนี้
case int:
fmt.Println("int:", value) // value เป็น int ตรงนี้
case bool:
fmt.Println("bool:", value)
default:
fmt.Println("unknown type")
}
}
// ผลรันจริง:
printType("hello") // string: hello
printType(42) // int: 42
printType(true) // bool: true
printType(3.14) // unknown type ← float64 ไม่มี case
ใน case string: ตัวแปร value เป็น
string จริง ๆ ไม่ใช่ any — เรียก
len(value) หรือ strings.ToUpper(value) ได้ทันที
ไม่ต้อง assert ซ้ำ compiler เปลี่ยน type ให้ในแต่ละ branch (= แต่ละ case)
case int, int64:
value + 1 // ✗ compile error
ผมรันแล้วได้ invalid operation: value + 1 (mismatched types any and untyped int)
— เหตุผลตรงไปตรงมา: compiler จะเปลี่ยน type ให้ได้ ก็ต่อเมื่อ case นั้นมี type
เดียว ถ้าใส่สอง type มันไม่รู้จะเปลี่ยนเป็นอันไหน จึงปล่อยเป็น any ไว้
ทางแก้: แยกเป็นคนละ case (case int: กับ
case int64:) หรือ assert ซ้ำข้างใน case ด้วย v.(int)
5 · Stringer — ทำไม fmt.Println ถึงรู้จัก struct ของคุณ
fmt ทำ type switch อยู่ข้างในนั่นเอง — มันเช็คว่าค่าที่รับมา
implement interface นี้ไหม (implement = มี method ครบตามที่ interface ขอ):
// นิยามจริงใน package fmt:
type Stringer interface {
String() string
}
มี String()
type Person struct {
Name string
Surname string
}
func (p Person) String() string {
return fmt.Sprintf("%s %s",
p.Name, p.Surname)
}
fmt.Println(p)
// Alice Wonder
ไม่มี String()
type Person struct {
Name string
Surname string
}
fmt.Println(p)
// {Alice Wonder}
ไม่ต้องบอก fmt เลยว่าคุณมี String() —
duck typing จาก บทเรียน 10
ทำงานให้อัตโนมัติ นี่คือตัวอย่างที่ชัดที่สุดว่า implicit interface มีประโยชน์ยังไง:
fmt เขียนขึ้นก่อนโค้ดคุณเกิดเป็นสิบปี แต่ยังทำงานกับ type ใหม่ได้
งานเลี้ยงงานหนึ่ง พิธีกรต้องแนะนำแขกทุกคนที่เดินเข้ามา
· แขกที่แนะนำตัวเองเป็น → ยื่นไมค์ให้พูดเอง ได้ยินว่า “Alice Wonder”
· แขกที่แนะนำตัวเองไม่เป็น → พิธีกรอ่านจากใบลงทะเบียนแทน ทีละช่อง
fmt.Println คือพิธีกร · String() คือความสามารถแนะนำตัวเอง
· {Alice Wonder} คือผลของการอ่านใบลงทะเบียน (ไล่พิมพ์ field ตามมี)
และพิธีกรคนนี้ไม่ต้องรู้จักแขกมาก่อน — ถามแค่ “พูดเองได้ไหม”
ที่ไม่เหมือนกัน: พิธีกรตัวจริงจำแขกได้ แต่ fmt ไม่จำใครเลย
มันเช็คใหม่ทุกครั้งที่สั่งพิมพ์ ว่า “ค่านี้มี String() ไหม”
ไม่ได้เช็คว่า “ค่านี้เป็น type อะไร”
func (p Person) String() string {
return fmt.Sprintf("%v", p) // ✗ วนไม่รู้จบ → stack overflow
}
%v บน p จะไปเรียก String() อีกรอบ →
วนไม่จบจน stack เต็ม (stack = พื้นที่ที่ Go ใช้จำว่าตอนนี้เรียกฟังก์ชันซ้อนกันอยู่กี่ชั้น
· ปกติ Go ขยายให้เองได้เรื่อย ๆ แต่มีเพดาน ชนเมื่อไหร่ขึ้น
fatal error: stack overflow แล้วตายเลย — อันนี้ไม่ใช่ panic ธรรมดา
recover() ดักไม่ได้ด้วยซ้ำ) ทางแก้: อ้าง field ตรง ๆ
(p.Name) หรือ convert เป็น type อื่นก่อน
- implement / satisfy an interface
- “มี method ครบตามที่ interface ขอ” · พูดว่า “Person implement fmt.Stringer แล้ว” จะสื่อกว่า “ผมใส่ String() ไว้แล้ว”
- Stringer
- interface ที่ขอแค่
String() string· ธรรมเนียม Go คือ interface method เดียวมักลงท้าย -er (Reader,Writer,Stringer) — แต่กลับกันไม่ได้นะ ชื่อลงท้าย -er ไม่ได้แปลว่าเป็น interface เสมอ (bufio.Writer,strings.Reader,strings.Builderเป็น struct ทั้งนั้น) ถ้าไม่แน่ใจให้เปิด pkg.go.dev ดู - implicit interface / duck typing
- ไม่ต้องประกาศว่าจะ implement อะไร มี method ครบก็ถือว่าใช่ · เวลาสงสัยให้ถามว่า “ตรงนี้มันแมตช์ interface แบบ implicit ใช่ไหม”
- type switch
- ชื่อของไวยากรณ์
switch v := x.(type)· คำถามที่ถามแล้วได้คำตอบทันที: “ใน case ที่ใส่สอง type ทำไมตัวแปรยังเป็น any อยู่”
6 · Interface Embedding — ประกอบ interface จากชิ้นเล็ก
บทเรียน 10 บอกให้ทำ interface ให้เล็ก แล้วถ้าบางที่อยากได้ครบทุกอันล่ะ — ไม่ต้องพิมพ์ซ้ำ ใช้ embedding:
type Saver interface { Save(data []byte) error }
type Loader interface { Load(id string) ([]byte, error) }
type Deleter interface { Delete(id string) error }
type Storage interface {
Saver // ← ไม่ใช่ field แต่คือ "รวม method ของ Saver มาด้วย"
Loader
Deleter
}
Storage จึงมี 3 method โดยไม่ต้องเขียนซ้ำ และ type ที่ implement
ครบทั้งสามก็เป็น Storage อัตโนมัติ:
type FileStorage struct{}
func (FileStorage) Save(data []byte) error { return nil }
func (FileStorage) Load(id string) ([]byte, error) { return []byte("example"), nil }
func (FileStorage) Delete(id string) error { return nil }
var s Storage = FileStorage{} // ✓ ผ่าน
ของจริงใน standard library: io.ReadWriter คือ
io.Reader + io.Writer ประกอบกันแบบนี้เป๊ะ ๆ —
เปิดอ่านได้ที่ pkg.go.dev/io
ซอร์สจริงสั้นแค่ 4 บรรทัด
- embedding / embedded interface
- การเขียนชื่อ interface อื่นไว้ข้างใน interface ของเรา = รวม method
ของอันนั้นเข้ามาเป็นของเรา · อย่าเรียกว่า field — พูดว่า
“เอา
Saverไปใส่เป็น field ในStorage” แล้ว senior จะแย้งทันที เพราะเข้าถึงแบบ field ไม่ได้จริง ๆ (s.Saverได้ errortype Storage has no field or method Saver) · คำที่ถูกคือ “embedSaverไว้ในStorage” - method set
- “ชุด method ทั้งหมดที่ type หนึ่งมี” · เป็นคำที่ spec ของ Go ใช้ตัดสินว่า type ไหน implement interface ไหน — embedding คือการรวม method set ของ interface ที่ embed เข้ามา · ประโยคที่ใช้ได้เลย: “method set ของ Storage = Save + Load + Delete”
7 · ทดสอบความจำ
1. var i any = "hello" แล้ว n := i.(int) เกิดอะไร?
compiler เห็นแค่ any จึงปล่อยผ่าน แล้วพังตอนรันด้วย
panic: interface conversion: interface {} is string, not int
ใช้ n, ok := i.(int) แทนเสมอถ้าไม่แน่ใจ 100%
2. n, ok := i.(int) เมื่อ i เก็บ string — n ได้อะไร?
รูปแบบ comma-ok ไม่เคยคืนค่าขยะ — ถ้าไม่ตรงจะได้
zero value ของ type ที่ขอ (0, "", false,
nil) พร้อม ok = false เสมอ ปลอดภัยกว่าแบบดิบมาก
3. ใน case string: ของ type switch ตัวแปรมี type อะไร?
compiler เปลี่ยน type ของตัวแปรให้ในแต่ละ branch แยกกัน
→ len(value), strings.ToUpper(value) เรียกได้เลย
นี่คือเหตุผลหลักที่ type switch อ่านง่ายกว่า if ซ้อน
4. fmt.Println(p) รู้ได้ยังไงว่าต้องเรียก String()?
fmt ทำ type assertion ข้างในว่า v.(Stringer) ได้ไหม
— ไม่ต้องลงทะเบียนอะไรเลย นี่คือ duck typing ในทางปฏิบัติ
และเป็นเหตุผลที่ error interface ทำงานแบบเดียวกันได้ใน
บทเรียน 12
8 · ลงมือทำ
ภารกิจ: เขียน formatter ที่รับอะไรก็ได้
- เขียน
func Format(v any) stringที่ใช้ type switch — เขียนหัว switch เป็นswitch value := v.(type)แล้วใช้ชื่อvalueข้างใน case ทุกอัน (ไม่ใช่vเพราะvยังเป็นanyอยู่) คืนค่าดังนี้:string→ ครอบด้วย"เช่น"hello"intและint64→ เติม comma คั่นหลักพัน (1,234,567) — แยกเป็นคนละcaseนะ ถ้าเขียนcase int, int64:จะ compile ไม่ผ่าน (ดูกล่องเตือนหัวข้อ 4) · คำใบ้เรื่องการเติม comma: แปลงเป็นข้อความด้วยstrconv.FormatIntก่อน แล้วค่อยวนใส่,ทุก 3 ตัวจากขวาไปซ้ายbool→"ใช่"/"ไม่ใช่"nil→"(ว่าง)"— คำใบ้:case nil:ใช้ได้- อย่างอื่น →
fmt.Sprintf("%v", v)
- เพิ่ม
case fmt.Stringer:ไว้ก่อนdefaultแล้วคืนvalue.String()— ใช้valueไม่ใช่vถ้าเผลอเขียนv.String()จะได้v.String undefined (type any has no field or method String) - สร้าง
type Temp float64ที่มีString()คืน"36.5°C"แล้วส่งเข้าFormat— เข้า case ไหน? - ลำดับของ case มีผลจริง — สร้าง type ที่ตรงสอง case พร้อมกัน:
เพิ่มtype FieldError struct{ Field string } func (e FieldError) Error() string { return "invalid field: " + e.Field } func (e FieldError) String() string { return "FieldError(" + e.Field + ")" }case error:เข้าไป แล้วลองสลับว่าใครอยู่ก่อน ระหว่างcase error:กับcase fmt.Stringer:
สปอยล์ผลรันจริง:errorอยู่ก่อน →invalid field: age·Stringerอยู่ก่อน →FieldError(age)
Go เลือก case แรกที่ตรง ไม่ใช่ที่ตรงที่สุด - ลองสั่ง
fmt.Println(FieldError{Field: "age"})ตรง ๆ ดูว่าfmtเลือกอันไหน — เฉลย: ได้invalid field: ageแปลว่าfmtเช็คerrorก่อนStringer - ท้าทาย: เพิ่ม
case []any:ที่วนเรียกFormatกับสมาชิกทุกตัวแล้วต่อด้วย", "— recursion (ฟังก์ชันเรียกตัวเอง) กับ type switch ทำงานด้วยกันได้สบาย
อ่านต่อจากต้นทาง
แหล่งหลักที่แนะนำ: A Tour of Go — Type assertions ถึงหน้า 18 (จบที่ Stringer พร้อมแบบฝึกหัดให้เขียนเอง) ประมาณ 10 นาที ส่วนถ้าอยากรู้ว่า interface เก็บ type+value ยังไงในหน่วยความจำจริง ๆ Go Data Structures: Interfaces ของ Russ Cox (หนึ่งในทีมหลักของ Go) อธิบายระดับ bit เลย — ยากกว่าที่ต้องรู้ตอนนี้ แต่เก็บไว้อ่านตอนสงสัยว่าทำไม interface ถึงเร็ว
💬 ติดตรงไหนถามได้เลย — ข้อ 4 ของภารกิจ (สลับลำดับ case) เป็นข้อที่ตั้งใจให้ผลลัพธ์ "แปลก" ถ้าได้ผลแล้วอธิบายไม่ได้ว่าทำไม ส่งโค้ดกับผลลัพธ์มา จะอธิบายให้
อ้างอิง
- A Tour of Go — Type assertions & switches
- Go spec — Type assertions — กฎว่าเมื่อไหร่ panic
- Go spec — Type switches
- pkg.go.dev — fmt.Stringer — นิยามจริง 3 บรรทัด
- pkg.go.dev — io.ReadWriter — ตัวอย่าง interface embedding ในของจริง
- Russ Cox — Go Data Structures: Interfaces — interface หน้าตายังไงในหน่วยความจำ