บทเรียน 11 · Day 4 · Interface (ต่อ)

any, Type Assertion และการดึงของจริงออกมาจากกล่อง

~14 นาที · ต้องมี: บทเรียน 10

any รับได้ทุกอย่าง — ฟังดูสะดวก แต่พอรับเข้ามาแล้วคุณ ทำอะไรกับมันไม่ได้เลย จนกว่าจะดึง type จริงออกมา บทนี้ว่าด้วยสามวิธีดึง (assertion, comma-ok, type switch) ว่าอันไหน panic อันไหนปลอดภัย และทำไม fmt.Println ถึงรู้จักวิธีพิมพ์ struct ของคุณ

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
จุดที่เจ๋งของ type switch

ใน case string: ตัวแปร value เป็น string จริง ๆ ไม่ใช่ any — เรียก len(value) หรือ strings.ToUpper(value) ได้ทันที ไม่ต้อง assert ซ้ำ compiler เปลี่ยน type ให้ในแต่ละ branch (= แต่ละ case)

ข้อยกเว้นที่ต้องรู้: case ที่ใส่หลาย type
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 อะไร”

กับดัก: String() ที่เรียกตัวเอง
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 ได้ error type Storage has no field or method Saver) · คำที่ถูกคือ “embed Saver ไว้ใน 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 ที่รับอะไรก็ได้

  1. เขียน 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)
  2. เพิ่ม case fmt.Stringer: ไว้ก่อน default แล้วคืน value.String() — ใช้ value ไม่ใช่ v ถ้าเผลอเขียน v.String() จะได้ v.String undefined (type any has no field or method String)
  3. สร้าง type Temp float64 ที่มี String() คืน "36.5°C" แล้วส่งเข้า Format — เข้า case ไหน?
  4. ลำดับของ 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 แรกที่ตรง ไม่ใช่ที่ตรงที่สุด
  5. ลองสั่ง fmt.Println(FieldError{Field: "age"}) ตรง ๆ ดูว่า fmt เลือกอันไหน — เฉลย: ได้ invalid field: age แปลว่า fmt เช็ค error ก่อน Stringer
  6. ท้าทาย: เพิ่ม 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) เป็นข้อที่ตั้งใจให้ผลลัพธ์ "แปลก" ถ้าได้ผลแล้วอธิบายไม่ได้ว่าทำไม ส่งโค้ดกับผลลัพธ์มา จะอธิบายให้

อ้างอิง