บทเรียน 05 · Day 2 · Function Design

error pattern และกับดัก := ที่ทำให้ค่าหาย

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

Go ไม่มี try/catch — มันใช้ "คืน error ออกมาเป็นค่าธรรมดา" แทน ซึ่งทำให้โค้ดตรงไปตรงมาแต่ก็เปิดช่องให้บั๊กชนิดหนึ่งที่compiler ไม่เตือน เทสต์ผ่าน แต่ค่าที่คุณคิดว่าเซ็ตแล้วกลับหายไปเฉย ๆ

1 · Go คืน error เป็นค่า ไม่ใช่ throw

ก่อนอื่น: ฟังก์ชัน Go คืนค่าได้มากกว่าหนึ่งตัว — เขียน type ที่จะคืนไว้ในวงเล็บ คั่นด้วยจุลภาค เช่น (float64, error) แปลว่า "คืนตัวเลขหนึ่งตัว และสถานะข้อผิดพลาดอีกหนึ่งตัว"
ตอนเรียกใช้ก็รับด้วยตัวแปรสองตัวคั่นจุลภาคเช่นกัน: result, err := divide(10, 2) — ตัวซ้ายรับค่าแรก ตัวขวารับค่าที่สอง เรียงตามลำดับใน signature เสมอ
signature = บรรทัดหัวของฟังก์ชัน ที่บอกว่ารับอะไรเข้าไปและคืนอะไรออกมา (ในที่นี้คือ func divide(a, b float64) (float64, error) · พูดเคร่งครัดตามสเปกของ Go คำว่า signature หมายถึงเฉพาะส่วน (a, b float64) (float64, error) ไม่นับคำว่า func และไม่นับชื่อฟังก์ชัน)

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("cannot divide by zero")
    }
    return a / b, nil
}

// เรียกใช้ — สำนวนมาตรฐานที่จะเจอทุกวัน
result, err := divide(10, 2)
if err != nil {
    fmt.Println("Error:", err)
    return
}
fmt.Println("Result:", result)
nil คืออะไร

บท 02 บอกว่าทุก type มี zero value — int ได้ 0, string ได้ ""
nil คือ zero value ของ error แปลว่า "ไม่มีอะไร / ว่างเปล่า"
ดังนั้น err != nil อ่านว่า "มี error อยู่จริงใช่ไหม" ส่วน return a/b, nil อ่านว่า "คืนผลลัพธ์ และบอกว่าไม่มี error" — ไม่ใช่การไม่คืนค่า แต่คืนค่าว่าง ๆ ที่แปลว่าสำเร็จ

🏧 เทียบให้เห็นภาพ — กดเงินที่ตู้ ATM

กดถอนเงิน 5,000 บาท ตู้จะตอบกลับมา สองอย่างเสมอ:
· ซองเงิน — ของที่เราอยากได้
· สลิป — ใบที่บอกว่าเรียบร้อยไหม หรือติดตรงไหน

ถอนไม่สำเร็จ = ได้ซองเปล่า + สลิปที่เขียนว่า “ยอดเงินไม่พอ” ตู้ไม่ได้ส่งเสียงเตือนทั้งธนาคาร — มันแค่ยื่นใบแจ้งกลับมาให้เราอ่านเอง
Go คิดแบบเดียวกันเป๊ะ ๆ: return a / b, nil คือ “เงิน + สลิปสะอาด” · return 0, errors.New(...) คือ “ซองเปล่า + สลิปที่มีข้อความ” · ส่วน if err != nil คือ การก้มอ่านสลิปก่อนเดินออกจากตู้

ที่ไม่เหมือนกัน: ตู้ ATM ยัดสลิปใส่มือเราไม่ว่าจะอยากได้หรือไม่ แต่ Go ไม่บังคับให้อ่าน — เขียน result, _ := divide(10, 0) แล้วใช้ result ต่อ (เช่น fmt.Println(result)) ก็ยัง compile ผ่าน ทั้งที่ทิ้งสลิปลงถังไปแล้ว
(ถ้าไม่ใช้ result ต่อเลย Go จะฟ้องว่า declared and not used: result — นั่นเป็นคนละเรื่อง มันบ่นเรื่อง ตัวแปรที่ประกาศแล้วไม่ได้ใช้ ไม่ใช่บ่นเรื่องไม่ได้เช็ค error) นี่แหละคือเหตุผลที่ต้องท่องกฎข้างล่างนี้ให้ขึ้นใจ

กฎ 3 ข้อของ error ใน Go
  1. error เป็น return value ตัวสุดท้ายเสมอ
  2. สำเร็จ = คืน nil เป็น error
  3. เช็ค err != nil ทันทีที่รับค่ามา — ไม่มีข้อยกเว้น

ถ้าฟังก์ชันไม่มีผลลัพธ์อื่นนอกจากสำเร็จ/ไม่สำเร็จ ก็คืนแค่ error ตัวเดียว แล้วรวมกับ short statement ได้เลย
short statement = ท่าที่เขียนคำสั่งหนึ่งบรรทัดไว้ในหัว if เลย แล้วคั่นด้วย ; ก่อนเงื่อนไข — เรียกฟังก์ชันกับเช็คผลจบในบรรทัดเดียว:

func saveUser(u User) error { ... }

if err := saveUser(user); err != nil {
    fmt.Println("save failed:", err)
    return
}

แบบนี้ err มีชีวิตแค่ในบล็อก if — ไม่ไปรกด้านนอก และกันเผลอเอา err เก่ามาใช้ซ้ำ
บล็อก = ช่วงโค้ดที่อยู่ในปีกกา { } คู่หนึ่ง · ตัวแปรที่เกิดในบล็อกไหน จะตายพร้อมปีกกาปิดของบล็อกนั้น
· นอกจากปีกกาที่เห็นด้วยตา หัวของ if / for / switch ที่มี short statement ก็นับเป็นบล็อกซ่อนอีกชั้นหนึ่งด้วย — err ตัวข้างบนจึงเกิดในบล็อกซ่อนนั้น ทำให้ทั้งฝั่ง if และฝั่ง else (ถ้ามี) มองเห็นมันได้ แต่โค้ดที่อยู่นอก if มองไม่เห็น
จำคำนี้ไว้ให้ดี เพราะหัวข้อ 3 ทั้งหัวข้อวนอยู่รอบเรื่องนี้

🗣 ศัพท์ที่เพิ่งเรียนไป

คำพวกนี้คือชื่อที่วงการใช้เรียกสิ่งที่เพิ่งเข้าใจไป — ใช้คำให้ตรง จะได้คำตอบที่ตรง ทั้งตอนถามรุ่นพี่และตอนถาม AI

errors are values
แนวคิด “ข้อผิดพลาดคือค่าธรรมดาที่ถูกคืนออกมา” = สลิปจากตู้ ATM · พูดว่า “Go จัดการ error แบบ return value ไม่ใช่ exception” คนฟังจะรู้ทันทีว่าเราเข้าใจโครงแล้ว · ค้นด้วยคำนี้เป๊ะ ๆ (ไม่ใช่ “errors as values”) จะเจอบทความต้นทางบน blog ของ Go
exception / try-catch
วิธีของภาษาอื่น (Java, Python, JS) ที่ “โยน” ข้อผิดพลาดขึ้นไปให้ใครข้างบนรับ · Go ไม่มีท่านี้ — พูดได้เลยว่า “Go ไม่มี exception”
panic
ตัวที่ใกล้ “โยน error” ที่สุดใน Go แต่สงวนไว้ตอนโปรแกรมไปต่อไม่ได้จริง ๆ · ถามว่า “เคสนี้ควร return error หรือควร panic”
signature
บรรทัดหัวของฟังก์ชัน — ส่วนที่บอกว่ารับอะไรและคืนอะไร (พูดเคร่งครัดคือส่วน (a, b float64) (float64, error) ไม่นับชื่อฟังก์ชัน — เวลาคุยเรื่อง function type หรือ interface จะยึดนิยามเคร่งครัดนี้) · ถามว่า “signature ของฟังก์ชันนี้เป็นยังไง” จะสื่อกว่า “ฟังก์ชันนี้ใช้ยังไง”
idiomatic Go
“เขียนตามสำนวนที่ชาว Go เขาเขียนกัน” · ลองถาม AI ว่า “โค้ดนี้ idiomatic Go หรือยัง” จะได้คำแนะนำคนละแบบกับถามว่า “โค้ดนี้ผิดไหม”

2 · Named return & naked return

// ตั้งชื่อ return value ได้
func divide(a, b float64) (result float64, err error) {
    if b == 0 {
        err = errors.New("cannot divide by zero")
        return          // ← naked return: คืน result, err ที่มีอยู่ตอนนั้น
    }
    result = a / b
    return
}

divide(1, 0) คืนค่าอะไรออกมา?

0, "cannot divide by zero"

result ไม่เคยถูกเซ็ต จึงเป็น zero value = 0 — นี่คือจุดแข็งของ named return: ไม่ต้องเขียน return 0, err ให้ครบทุกจุด

🧾 เทียบให้เห็นภาพ — ใบสั่งอาหารที่วางไว้บนโต๊ะตั้งแต่ยังไม่สั่ง

พอเรานั่งลง พนักงานฉีกใบสั่งอาหารมาวางบนโต๊ะก่อนที่เราจะสั่งอะไรเลย ในใบมีสองช่องพิมพ์หัวไว้แล้ว: “รายการที่ได้” กับ “หมายเหตุ/ปัญหา”
ระหว่างนั้นเขาเดินมาเติมช่องไหนก็ได้ · พอถึงเวลาส่งเข้าครัว เขาแค่หยิบใบนั้นยื่นออกไป ไม่ต้องคัดลอกใส่กระดาษใหม่

(result float64, err error) คือใบที่มีสองช่องนี้ · err = errors.New(...) คือการเดินมาเติมช่อง “ปัญหา” · ส่วน return เปล่า ๆ คือ “ยื่นใบนี้ออกไปตามที่กรอกไว้ ณ ตอนนั้น” ไม่ต้องเขียน return 0, err ซ้ำทุกจุด

ที่ไม่เหมือนกัน: ช่องว่างบนกระดาษจริงคือ “ว่าง” ที่คนอ่านรู้ว่ายังไม่ได้กรอก แต่ช่องที่ไม่ได้กรอกใน Go ไม่ได้ว่าง — มันมีค่าเริ่มต้นอยู่แล้ว (float64 ได้ 0, error ได้ nil) ปลายทางจึงอ่านได้ว่า “ราคา 0 บาท และไม่มีปัญหา” ทั้งที่ความจริงคือเราลืมกรอก

ใช้ naked return เท่าที่ฟังก์ชันสั้น

ถ้าฟังก์ชันยาว 40 บรรทัดแล้วเจอ return เปล่า ๆ คนอ่านต้องเลื่อนขึ้นไปดู signature ว่าคืนอะไรบ้าง — Effective Go บอกให้ใช้กับฟังก์ชันสั้นเท่านั้น ฟังก์ชันยาวให้เขียนค่าออกมาให้ครบ

แต่ named return มีพลังพิเศษอีกอย่างที่คุ้มมาก — defer เข้าไปแก้มันได้ (เรื่องนี้อยู่ใน บทเรียน 08) ใช้ห่อ error ทุกจุด return ในครั้งเดียว:

func loadConfig(path string) (err error) {
    defer func() {
        if err != nil {
            err = fmt.Errorf("loadConfig %s: %w", path, err)
        }
    }()
    // ...มี return err อยู่ 5 จุด ทุกจุดถูกห่อให้อัตโนมัติ
}
// ได้: loadConfig app.yaml: file missing

%w ใน fmt.Errorf คือ "wrap" — เก็บ error เดิมไว้ข้างในให้ errors.Is / errors.As ตรวจได้ทีหลัง (ต่างจาก %v ที่แปลงเป็นข้อความอย่างเดียว)

🗣 ศัพท์ที่เพิ่งเรียนไป
named return value
“ใบที่มีช่องพิมพ์หัวไว้แล้ว” — ตั้งชื่อค่าที่จะคืนไว้ตั้งแต่ใน signature · เวลารีวิวโค้ดกันจะได้ยินว่า “ฟังก์ชันนี้ใช้ named return นะ ระวังตอนแก้”
naked return
return เปล่า ๆ ที่ไม่ระบุค่า = “ยื่นใบนั้นออกไปเลย” · ถ้ารุ่นพี่ทักว่า “อย่าใช้ naked return กับฟังก์ชันยาว” เขากำลังพูดถึงเรื่องนี้
zero value
ค่าเริ่มต้นที่ Go ใส่ให้อัตโนมัติเมื่อยังไม่ได้กำหนดค่า (0, "", false, nil) · ถามว่า “zero value ของ type นี้คืออะไร” จะตรงกว่าถามว่า “มันเริ่มที่เท่าไหร่”
error wrapping (%w)
ห่อ error เดิมไว้ข้างในพร้อมเติมบริบทว่าพังตอนไหน · ประโยคที่ใช้ได้จริง: “ผม wrap error ด้วย %w เพื่อให้ errors.Is เช็คต้นตอได้”

3 · 💀 กับดักใหญ่: := ไม่ได้แปลว่า "สร้างใหม่" เสมอไป

var sum = 0

if true {
    sum := sum + 20
    fmt.Println("ข้างใน  =", sum)
}

fmt.Println("ข้างนอก =", sum)

พิมพ์อะไรออกมา?

ข้างใน  = 20
ข้างนอก = 0     ← ไม่ใช่ 20!

sum := sum + 20 สร้างตัวแปร sum ตัวใหม่ ที่มีชีวิตแค่ใน block นั้น (ค่าเริ่มต้นคำนวณจาก sum ตัวนอก = 20) พอออกจาก block ตัวใหม่ตาย sum ตัวนอกไม่เคยถูกแตะเลย
อาการนี้เรียกว่า variable shadowing — ตัวในบัง ตัวนอก

🧊 เทียบให้เห็นภาพ — กระดาษโน้ตที่แปะตู้เย็น

ที่บ้านมีกระดาษแปะตู้เย็นอยู่ใบหนึ่ง หัวกระดาษเขียนว่า “เงินกองกลาง” ตัวเลข 0 ใครใช้เงินก็เดินมาแก้เลขบนใบนี้ — ทั้งบ้านดูใบเดียวกัน

วันหนึ่งคุณเข้าไปในห้องนอน หยิบกระดาษใบใหม่มาเขียนหัวว่า “เงินกองกลาง” เหมือนกันเป๊ะ แล้วจดเลข 20 ไว้ · ทำงานเสร็จ เดินออกจากห้อง กระดาษใบนั้นถูกทิ้งไปพร้อมกับที่คุณออกจากห้อง
กลับมาดูตู้เย็น — ยังเป็น 0 เหมือนเดิม เพราะไม่เคยมีใครไปแตะใบที่ตู้เย็นเลย

· sum := ... ในห้องอื่น (คนละบล็อก) = หยิบกระดาษใบใหม่มาเขียนหัวชื่อเดิม
· sum = ... = เดินไปแก้เลขบนใบที่แปะตู้เย็น
ต่างกันแค่จุดเดียว (:) แต่คนละเรื่องกันเลย
อย่าเพิ่งเหมาว่า := แปลว่า “ใบใหม่” เสมอ — ถ้าชื่อซ้ำนั้น อยู่ห้องเดียวกัน Go จะไม่หยิบใบใหม่ แต่แก้เลขบนใบเดิมให้ เงื่อนไขเต็ม ๆ อยู่ในกล่อง “ทำไมถึงหลบตาคุณได้” ข้างล่าง

ที่ไม่เหมือนกัน: ในชีวิตจริง ถ้ามีกระดาษสองใบ เราเดินไปหยิบใบไหนก็ได้ แต่ในโค้ด นับจากบรรทัดที่หยิบใบใหม่ขึ้นมาเป็นต้นไป คุณเรียกถึงใบข้างนอกในห้องนั้นไม่ได้อีก — ชื่อ sum ถูกใบในห้องยึดไปจนกว่าจะออกจากปีกกา
สังเกตคำว่า “นับจากบรรทัดนั้นเป็นต้นไป” ให้ดี — นี่คือเหตุผลที่ sum := sum + 20 ยังอ่านค่า 0 จากใบข้างนอกมาบวกได้ เพราะฝั่งขวาถูกคำนวณก่อนที่ใบใหม่จะเริ่มมีผล
รายละเอียดยิบ ๆ ระดับนี้แหละที่ทำให้บั๊กแบบนี้มองด้วยตาไม่ค่อยเห็น

แก้ง่ายมาก — เปลี่ยน := เป็น =

if true {
    sum = sum + 20    // = ไม่ใช่ := → แก้ตัวนอกจริง
}
// ข้างนอก = 20 ✓

ทำไมถึงอันตราย: เวอร์ชัน err

อาการเดียวกันนี้เกิดกับ err ได้ และตรงนี้แหละที่ทำให้ production พัง

func process() error {
    var err error

    if needFile {
        file, err := os.Open("demo.txt")   // 💀 := สร้าง err ตัวใหม่!
        if err != nil {
            fmt.Println("Cannot open:", err)
        }
        _ = file
    }

    return err   // ← คืน nil เสมอ! error ที่เกิดจริงหายไปแล้ว
}
ทำไมถึงหลบตาคุณได้

ไล่ทีละสเต็ป:

  1. บรรทัดนั้นมี file ซึ่งเป็นตัวแปรใหม่จริง ๆ จึงต้องใช้ :=
  2. Go มีกฎว่า := ใช้ได้ถ้ามีตัวแปรใหม่อย่างน้อย 1 ตัวทางซ้าย — ส่วนตัวที่ชื่อซ้ำ (err) จะถูก "ใช้ตัวเดิม" ให้
  3. แต่กฎข้อ 2 ใช้ได้เฉพาะตอนที่ตัวชื่อซ้ำอยู่ในบล็อกเดียวกัน — ในโค้ดนี้ err ตัวเดิมอยู่นอกปีกกาของ if ส่วนบรรทัดนี้อยู่ข้างใน ถือว่าคนละบล็อก
  4. ผลคือ Go สร้าง err ตัวใหม่ขึ้นมาในบล็อก if ซ้อนทับตัวนอก และตัวนอกยังเป็น nil อยู่จนจบฟังก์ชัน

แล้วทำไม compiler (ตัวแปลโค้ดของเราให้เป็นไฟล์ที่เครื่องรันได้ — คนที่คอยฟ้องว่าเราเขียนผิด) ถึงเงียบ? เพราะ Go จะบ่นก็ต่อเมื่อประกาศตัวแปรแล้วไม่ได้ใช้ — ในที่นี้ err ตัวใหม่ถูกใช้ในบรรทัดถัดไปเรียบร้อย ในสายตา compiler จึงไม่มีอะไรผิด

ทางแก้ 2 แบบ:

// 1) ประกาศตัวแปรใหม่แยกไว้ก่อน แล้วบรรทัดที่เรียกฟังก์ชันใช้ = ล้วน (ไม่มี :=)
var file *os.File     // type ของ "ไฟล์ที่เปิดค้างไว้" — เครื่องหมาย * จะอธิบายตอนเรียน pointer
file, err = os.Open("demo.txt")   // = ล้วน → err ตัวนอกถูกเซ็ตจริง

// 2) จัดการ error ให้จบในที่เดียวกับที่มันเกิด (แนะนำ — ตรงกับ guard clause)
file, err := os.Open("demo.txt")
if err != nil {
    return err       // ส่งออกไปเลย ไม่ต้องเก็บไว้ใช้ทีหลัง
}

หัวใจของแบบที่ 1 ไม่ได้อยู่ที่ *os.File แต่อยู่ที่ เมื่อไม่มีตัวแปรใหม่ทางซ้ายแล้ว ก็ใช้ = ได้ และ = ไม่มีวันสร้างตัวแปรใหม่มาบัง — ถ้าจำได้ข้อเดียว จำข้อนี้

ตรวจจับอัตโนมัติ

ไม่ต้องพึ่งสายตาอย่างเดียว — เครื่องมือ errcheck และ shadow analyzer จับได้
analyzer = โปรแกรมที่อ่านโค้ดเราแทนสายตา แล้วฟ้องจุดที่น่าสงสัย โดยไม่ต้องรันโปรแกรมจริง:

go vet -vettool=$(which shadow) ./...
🗣 ศัพท์ที่เพิ่งเรียนไป

หัวข้อนี้เป็นบั๊กที่ต้องขอความช่วยเหลือคนอื่นบ่อยที่สุด — ถ้าเรียกชื่ออาการถูก จะประหยัดเวลาไปได้เป็นชั่วโมง

variable shadowing
อาการ “ตัวในบังตัวนอก” = กระดาษใบใหม่ในห้องนอน · แทนที่จะบอกว่า “ค่ามันหายไปเฉย ๆ ไม่รู้ทำไม” ให้พูดว่า “ตรงนี้ err โดน shadow หรือเปล่า” — รุ่นพี่จะรู้ทันทีว่าต้องดูตรงไหน และ AI จะตอบตรงจุดขึ้นมาก
scope
“ขอบเขตที่ชื่อตัวแปรนั้นใช้ได้” = “ห้อง” ที่กระดาษใบนั้นแปะอยู่ · แต่ละ block (ปีกกา { } คู่หนึ่ง) สร้าง scope ใหม่ขึ้นมาหนึ่งชั้น · หัวของ if/for/switch ที่มี short statement ก็เป็น block อีกชั้นแม้ไม่มีปีกกาให้เห็น — เวลาไล่บั๊ก shadow อย่าลืมนับชั้นนี้ด้วย · คำถามมาตรฐานเวลาไล่บั๊กคือ “ตัวแปรตัวนี้อยู่ scope ไหน”
short variable declaration (:=)
ชื่อทางการของ := = “ประกาศตัวแปรใหม่พร้อมให้ค่า” · ต่างจาก assignment (=) ที่แปลว่า “เอาค่าไปใส่ตัวที่มีอยู่แล้ว”
redeclaration rule
กฎที่ว่า := ใช้ซ้ำได้ถ้ามีตัวแปรใหม่อย่างน้อย 1 ตัวทางซ้าย และตัวที่ชื่อซ้ำอยู่บล็อกเดียวกัน · ถามได้เลยว่า “สองบรรทัดนี้อยู่ block เดียวกันไหม ถ้าคนละ block มันจะ redeclare ใช่หรือเปล่า”
static analysis
การให้เครื่องมืออ่านโค้ดหาจุดผิดโดยไม่ต้องรันโปรแกรม · go vet, shadow, errcheck อยู่ในกลุ่มนี้หมด · พูดว่า “มี static analysis ตัวไหนจับเคสนี้ได้บ้าง”

4 · ทดสอบความจำ

1. ฟังก์ชัน Go ที่อาจล้มเหลว ควรออกแบบ signature ยังไง?

สำนวนมาตรฐานคือ (result T, err error) — panic สงวนไว้สำหรับ "โปรแกรมพังจนไปต่อไม่ได้" เท่านั้น (Don't Panic ใน Go Code Review Comments)

ส่วน bool ใช้กับ comma-ok ของ map/type assertion ซึ่งไม่ได้บอก "ทำไมถึงล้มเหลว"

2. var sum = 0 แล้วใน if เขียน sum := sum + 20 — ข้างนอกได้เท่าไหร่?

:= ใน block ใหม่ = สร้างตัวแปรใหม่ที่บังตัวนอก ตัวนอกไม่เคยถูกแตะ ถ้าอยากแก้ตัวนอกต้องใช้ =

3. file, err := os.Open(f) ในที่ที่มี err อยู่แล้วข้างนอก — เกิดอะไรขึ้น?

ถ้าอยู่ คนละ scope จะสร้างใหม่ทั้งคู่ → err ข้างนอกยังเป็น nil อยู่ ทำให้ return err ตอนท้ายคืน nil ทั้งที่เกิด error จริง

(ถ้าอยู่ scope เดียวกัน Go จะ reuse err ตัวเดิมให้ ขอแค่มีตัวแปรใหม่อย่างน้อย 1 ตัวทางซ้าย — สองกรณีนี้ต่างกันตรงนี้)

4. func f() (result int, err error) เขียน return เปล่า ๆ ได้อะไร?

naked return — คืนค่าปัจจุบันของ named return value ถ้ายังไม่เคยเซ็ตก็ได้ zero value (0 และ nil)

5 · ลงมือทำ

ภารกิจ: calculatePrice (workshop จากสไลด์ Day 2)

เขียน func calculatePrice(price, discountPercent float64) (float64, error)

  1. เขียนด้วย guard clause จากบทที่แล้ว (ไม่มี else เลย)
  2. ทดสอบ 3 เคส: (1000, 10) → 900.00 · (-500, 10) → error · (1000, 120) → error
  3. ฝั่งผู้เรียก ต้องเช็ค err != nil ก่อนใช้ค่า และพิมพ์ด้วย fmt.Printf("Final price: %.2f\n", v)
  4. เขียนใหม่อีกรอบด้วย named return + naked return เทียบดูว่าชอบแบบไหน
  5. ท้าทาย 1 (ลอกแบบก่อน เข้าใจทีหลัง): ทำให้ error ทุกจุดขึ้นต้นด้วย "calculatePrice: " โดยไม่ต้องไปแก้ทีละจุด return — เปลี่ยน signature เป็น named return แล้ววางก้อนนี้ไว้บรรทัดแรกของฟังก์ชัน:
    func calculatePrice(price, discountPercent float64) (final float64, err error) {
        defer func() {
            if err != nil {
                err = fmt.Errorf("calculatePrice: %w", err)
            }
        }()
        // ...guard clause เดิมทั้งหมด
    }
    รู้ได้ยังไงว่าถูก: เรียกด้วย (-500, 10) แล้วพิมพ์ error ออกมา ต้องได้ calculatePrice: price cannot be negative (มีคำนำหน้าแล้ว) ส่วนเคส (1000, 10) ต้องยังได้ 900.00 เหมือนเดิม
    ก้อน defer func(){...}() ทำงานยังไง จะอธิบายเต็ม ๆ ใน บทเรียน 08 — ตอนนี้ให้จับแค่ว่า "โค้ดในนี้ทำงานตอนฟังก์ชันกำลังจะจบ ไม่ว่าจะ return จากจุดไหน"
  6. ท้าทาย 2 (สร้างบั๊กเอง): จงใจเขียนโค้ดที่มี shadowing ของ err ให้ฟังก์ชันคืน nil ทั้งที่ควรเป็น error — แล้วรัน go vet ./... ดูว่ามันจับได้ไหม
    สปอยล์: go vet เปล่า ๆ จับไม่ได้ — ผมรันทดสอบแล้ว เงียบสนิท ต้องลง shadow analyzer แยกต่างหาก บทเรียนของข้อนี้คือ เครื่องมือ default ไม่ได้กันให้ทุกอย่าง สายตาคุณยังต้องทำงาน
    go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest
    go vet -vettool=$(which shadow) ./...
ชัยชนะของบทนี้

ต่อไปเวลาเห็น := ข้างใน if/for ที่มี err อยู่ทางซ้าย คุณจะหยุดคิดหนึ่งวินาทีว่า "นี่กำลังสร้างตัวใหม่หรือใช้ตัวเดิม" — วินาทีนั้นแหละที่กันบั๊กระดับ production

อ่านต่อจากต้นทาง

แหล่งหลักที่แนะนำ: Error handling and Go — บทความทางการที่อธิบายว่าทำไม Go ถึงเลือกคืน error เป็นค่า ต่อด้วย Working with Errors in Go 1.13 ถ้าอยากเข้าใจ %w, errors.Is, errors.As

💬 ติดตรงไหนถามได้เลย — โดยเฉพาะข้อ 3 ของ quiz (:= scope เดียวกัน vs คนละ scope) เป็นจุดที่ละเอียดมาก ถ้ายังไม่ชัดถามมาได้ เดี๋ยวทำตัวอย่างเทียบให้ดูทีละบรรทัด

อ้างอิง