บทเรียน 04 · Day 2 · Clean Code

Guard Clause: ฆ่า if ซ้อน 5 ชั้น

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

นี่คือเทคนิคเดียวที่ทำให้โค้ดคุณ "ดูเป็น Go" มากที่สุด และเป็นข้อที่ reviewer จะทักเป็นอันดับแรก ถ้าคุณไม่ทำ — ทีม Go เขียนไว้เป็นกฎในเอกสารทางการเลยว่า "keep the normal code path at a minimal indentation"

1 · ปัญหา: บันไดที่ไต่ไปเรื่อย ๆ

โค้ดอนุมัติสินเชื่อจากสไลด์ Day 2 — ลองอ่านแล้วตอบว่า "เงื่อนไขไหนทำให้อนุมัติ"

if age >= 20 {
    if hasJob {
        if income >= 15000 {
            if creditScore >= 600 {
                fmt.Println("อนุมัติ")
            } else {
                if creditScore >= 500 {
                    fmt.Println("อนุมัติแบบมีเงื่อนไข")
                } else {
                    fmt.Println("ปฏิเสธ: เครดิตต่ำ")
                }
            }
        } else {
            fmt.Println("ปฏิเสธ: รายได้ไม่ถึง")
        }
    } else {
        fmt.Println("ปฏิเสธ: ไม่มีงาน")
    }
} else {
    fmt.Println("ปฏิเสธ: อายุไม่ถึง")
}

สังเกตอาการ 3 อย่าง:

2 · ทางแก้: พลิกเงื่อนไข แล้วออกก่อน

if age < 20 {
    fmt.Println("ปฏิเสธ: อายุไม่ถึง")
    return
}
if !hasJob {
    fmt.Println("ปฏิเสธ: ไม่มีงาน")
    return
}
if income < 15000 {
    fmt.Println("ปฏิเสธ: รายได้ไม่ถึง")
    return
}
if creditScore < 500 {
    fmt.Println("ปฏิเสธ: เครดิตต่ำ")
    return
}
if creditScore < 600 {
    fmt.Println("อนุมัติแบบมีเงื่อนไข")
    return
}

fmt.Println("อนุมัติ")   // ← ชั้นเดียว อยู่ล่างสุด อ่านเจอทันที

โค้ดนี้วางไว้ใน main ได้เลย — return ใน main แปลว่า "จบโปรแกรม" ส่วนใน func อื่นแปลว่า "จบเฉพาะฟังก์ชันนั้น"

สิ่งที่เปลี่ยนไป
🛫 เทียบให้เห็นภาพ — ด่านตรวจก่อนขึ้นเครื่อง

ที่สนามบิน เขาไม่ได้พาเราเดินเข้าไปถึงตัวเครื่องบินก่อน แล้วค่อยมาไล่ออกทีหลัง
เขาตั้งด่านเรียงกันเป็นแถว — เช็คอิน → ตรวจบัตรประชาชน → สแกนกระเป๋า → ตรวจตั๋วที่ประตู
ตกด่านไหน ก็จบตรงด่านนั้น ไม่ต้องเดินต่อ · ส่วนคนที่เดินมาถึงประตูได้ แปลว่าผ่านครบทุกด่านแน่นอน ไม่ต้องตรวจซ้ำอีก

guard clause คือการเรียงด่านแบบนี้ · if ... { return } หนึ่งก้อน = ด่านหนึ่งด่าน
ส่วน if ซ้อน 5 ชั้นในหัวข้อ 1 คือการพาทุกคนเดินเข้าไปให้ลึกที่สุดก่อน แล้วค่อยตัดสินว่าจะไล่กลับตรงไหน

ที่ไม่เหมือนกัน: ผู้โดยสารที่ตกด่านยังเดินกลับไปแก้ของ แล้วเข้าคิวใหม่ได้ แต่ return คือจบแล้วจบเลย ไม่มีย้อนกลับ — บรรทัดที่เหลือในเส้นทางปกติของฟังก์ชันนั้นจะไม่ถูกรันอีก · ข้อยกเว้นเดียวคือ defer ที่ลงทะเบียนไว้ก่อน return — ก้อนนั้นยังทำงานเสมอ แม้จะออกไปแล้ว (เรื่องนี้อยู่ใน บทเรียน 08)

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

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

guard clause
“ด่านตรวจ” ที่วางไว้ต้นฟังก์ชัน — เช็คเคสที่ไม่ผ่านแล้ว return ออกทันที · เวลาขอความช่วยเหลือให้พูดว่า “ช่วย refactor ก้อนนี้เป็น guard clause ให้หน่อย” จะได้ตรงกว่าพูดว่า “ทำให้มันอ่านง่ายขึ้นหน่อย”
early return
การ return ออกกลางทาง ก่อนถึงบรรทัดสุดท้ายของฟังก์ชัน · เป็นกลไกที่ทำให้ guard clause ทำงานได้
happy path
“เส้นทางปกติ” ที่ผ่านทุกด่าน — ในตัวอย่างนี้คือบรรทัด อนุมัติ · ประโยคที่จะได้ยินบ่อยคือ “เอา happy path ไว้ชั้นนอกสุด”
nesting
“ซ้อนกันกี่ชั้น” = จำนวนชั้นของโครงสร้าง (if ใน if ใน if) · เป็นเรื่องของตรรกะ · แทนที่จะบอกว่า “มันซ้อนกันเยอะ” ให้พูดว่า “ตรงนี้ nesting ลึก 5 ชั้น”
indentation level
“ระยะเยื้องที่ตาเห็น” — เป็นเรื่องของการแสดงผล ที่ปกติสะท้อน nesting · ระวังอย่าใช้สลับกัน: ถ้าอยากให้เขาช่วยลดชั้นตรรกะแต่ไปพูดว่า “ช่วยลด indentation หน่อย” อาจได้คำตอบเป็นเรื่องจัด format แทน
refactor
แก้โครงของโค้ดให้ดีขึ้น โดยผลลัพธ์ต้องเหมือนเดิมเป๊ะ · ถ้าผลลัพธ์เปลี่ยนไป อันนั้นไม่เรียกว่า refactor แล้ว แต่คือเปลี่ยนพฤติกรรมของโปรแกรม

3 · สูตรแปลง 3 ขั้น

1. หา if ที่ครอบทุกอย่างไว้ → if age >= 20 { 2. พลิกเงื่อนไขให้ตรงข้าม → if age < 20 { 3. เอาโค้ดใน else มาไว้ข้างใน แล้วปิดท้ายด้วย return → if age < 20 { ...; return } ทำซ้ำกับ if ชั้นถัดไป จนหมด
เงื่อนไขเดิมพลิกเป็น
x >= 20x < 20
x > 0x <= 0
hasJob!hasJob
err == nilerr != nil
a && b!a || !b
a || b!a && !b

สองแถวล่างมีชื่อเรียกว่า กฎเดอมอร์แกน — คิดเป็นภาษาคนจะเห็นเหตุผลทันที:

จำสั้น ๆ: พลิก "และ" เป็น "หรือ" เสมอ แล้วใส่ ! ให้ทุกตัว ถ้าพลิกผิดตรงนี้ logic จะกลับด้านทั้งหมดโดย compiler ไม่ฟ้อง

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

จะกดเงินได้ต้องมีทั้งบัตร และ รหัสที่ถูก — เขียนเป็นเงื่อนไขคือ hasCard && pinOK
ทีนี้ลองถามกลับด้านว่า “แบบไหนที่กดไม่ได้”
คำตอบคือ ขาดอย่างใดอย่างหนึ่งก็จบแล้ว — ไม่มีบัตรก็จบ · มีบัตรแต่ใส่รหัสผิดก็จบ
ไม่ใช่ว่าต้อง “ไม่มีบัตรและรหัสผิดพร้อมกัน” ถึงจะกดไม่ได้ — นี่แหละคือเหตุผลที่ && พลิกแล้วต้องกลายเป็น ||

ที่ไม่เหมือนกัน: ตู้ ATM จอขึ้นบอกเราได้ว่าพลาดเพราะอะไร แต่เงื่อนไขที่รวบเป็นก้อนเดียวอย่าง if !hasCard || !pinOK บอกได้แค่ว่า “ไม่ผ่าน” ไม่รู้ว่าตกข้อไหน — เป็นเหตุผลที่ guard clause นิยมแยกเป็น if ทีละข้อ จะได้บอกสาเหตุได้ตรงจุด

🗣 ศัพท์ที่เพิ่งเรียนไป
กฎเดอมอร์แกน (De Morgan's law)
ชื่อทางการของกฎพลิกเงื่อนไขที่มี && / || · ใช้ได้ทุกภาษา ไม่ใช่แค่ Go · เวลาไม่แน่ใจให้ถามตรง ๆ ว่า “ช่วยพลิกเงื่อนไขนี้ด้วยกฎเดอมอร์แกนให้หน่อย”
negate (พลิกเงื่อนไข)
ทำให้เงื่อนไขกลับด้าน เช่น x >= 20 เป็น x < 20 · เครื่องหมาย ! อ่านว่า “not” หน้าที่ของมันคือสลับ true ↔ false
logic bug
บั๊กที่ compile ผ่าน รันได้ แต่คำตอบผิด (compiler = ตัวแปลโค้ดให้เครื่องรัน · มันตรวจแค่ว่าเขียนถูกไวยากรณ์ไหม ไม่ได้ตรวจว่าเราคิดถูกไหม) · เวลาขอให้ช่วยดู ให้พูดว่า “น่าจะเป็น logic bug ไม่ใช่ compile error” อีกฝ่ายจะรู้ทันทีว่าต้องไล่ตรรกะ ไม่ใช่ไล่ syntax

4 · นี่คือมาตรฐานทางการของ Go

Go Code Review Comments มีหัวข้อชื่อ Indent Error Flow เขียนไว้ว่า:

"Try to keep the normal code path at a minimal indentation, and indent the error handling, dealing with it first."

แปลว่า เส้นทางปกติต้องอยู่ชิดซ้ายที่สุด ส่วน error/ข้อยกเว้น ให้จัดการก่อนแล้วออกไป นี่คือเหตุผลที่โค้ด Go ทุกที่หน้าตาแบบนี้:

ตัวอย่างจริงที่จะเจอทุกไฟล์ — ตอนนี้ยังไม่ต้องเข้าใจทุกบรรทัด ให้ดูแค่รูปทรง:

f, err := os.Open(name)   // เปิดไฟล์ — คืนสองค่า: ตัวไฟล์ กับ "ผิดพลาดไหม"
if err != nil {          // ถ้าผิดพลาด...
    return err          // ...จัดการแล้วออกทันที ← นี่คือ guard clause
}

// เส้นทางปกติอยู่ชั้นนอกสุด ต่อไปยาว ๆ ได้เลย

เรื่อง err, nil และการคืนค่าสองตัวจะสอนเต็ม ๆ ใน บทเรียน 05 (บทหน้า) — ในบทนี้ให้จับแค่ว่า รูปทรง "เช็คเคสผิด → ออก" เป็นสำนวนมาตรฐานของ Go จริง ๆ
⚠️ โค้ดจริงยังขาดอีกบรรทัด: เปิดไฟล์แล้วต้องปิดด้วย ตัดออกจากตัวอย่างนี้เพราะยังไม่ถึงเวลาสอน — เรื่องปิด resource (resource = ของที่เรายืมจากระบบมาถือไว้ เช่น ไฟล์ที่เปิดค้าง) ให้ครบทุกทางออก อยู่ใน บทเรียน 08

5 · เพื่อนของ guard clause: switch

ถ้าเงื่อนไขทั้งหมดคือ "เทียบตัวแปรตัวเดียวกับหลายค่า" ใช้ switch จะสะอาดกว่า guard clause อีก

สถานการณ์ใช้
คำถาม true/false เดียวif
เลือกสองทางif-else
ตรวจหลายช่วง / หลายเงื่อนไขต่างกันguard clause
เทียบตัวแปรเดียวกับหลายค่าswitch
// ก่อน — if ซ้อน
if status == "pending" { ... } else {
    if status == "approved" { ... } else {
        if status == "rejected" { ... } else { ... }
    }
}

// หลัง — switch
switch status {
case "pending":  fmt.Println("Waiting for approval")
case "approved": fmt.Println("Request approved")
case "rejected": fmt.Println("Request rejected")
default:          fmt.Println("Unknown status")
}
switch ของ Go ต่างจากภาษาอื่น 3 อย่าง
  1. ไม่ต้องเขียน break — จบ case แล้วออกเอง (ตรงข้ามกับ C/Java ที่ลืม break แล้วบั๊ก)
  2. ใส่หลายค่าใน case เดียวได้: case "Tue", "Wed", "Thu":
  3. อยากให้ไหลลง case ถัดไปต้องสั่ง fallthrough เอง
switch status := strings.ToUpper("approved"); status {
case "PENDING":
    fmt.Println("Waiting for approval")
case "APPROVED":
    fmt.Println("Request approved")
    fallthrough
case "REJECTED":
    fmt.Println("Request rejected")
default:
    fmt.Println("Unknown status")
}

พิมพ์อะไรออกมาบ้าง?

Request approved
Request rejected

fallthrough ทำให้ไหลลง case ถัดไป โดยไม่เช็คเงื่อนไขของ case นั้นเลย — และหยุดแค่ case เดียว ไม่ไหลต่อไปถึง default

สังเกตอีกอย่าง: switch x := ...; x { ใส่ short statement ได้เหมือน if — ตัวแปรมีชีวิตเฉพาะใน switch นั้น

โบนัส: if ใส่คำสั่งนำหน้าได้

if n := len(name); n > 10 {
    fmt.Println("ชื่อยาวไป", n)
} else {
    fmt.Println("ok", n)      // ← n ใช้ใน else ได้ด้วย
}
fmt.Println(n)                // ✗ undefined: n — ปิดปีกกาแล้ว n ตายไปเลย

อ่านว่า "ทำ n := len(name) ก่อน แล้วค่อยเช็คเงื่อนไข"
n มีชีวิตเฉพาะใน if กับ else ของก้อนนั้น พอปิดปีกกาก็หายไป
ประโยชน์คือจำกัดอายุตัวแปรให้สั้นที่สุด ลดโอกาสเผลอเอาไปใช้ผิดที่

🎫 เทียบให้เห็นภาพ — บัตรผู้มาติดต่อ

เข้าออฟฟิศคนอื่น เราแลกบัตรประชาชนกับบัตรผู้มาติดต่อที่ป้อมยาม
บัตรใบนั้นใช้ได้เฉพาะในตึกนั้น ในวันนั้น · เดินออกจากตึกเมื่อไหร่ ก็ต้องคืน ใช้ต่อไม่ได้

ตัวแปรที่ประกาศแบบ if n := len(name); n > 10 { ก็คือบัตรผู้มาติดต่อ —
ปีกกาเปิด = รับบัตร · ปีกกาปิด = คืนบัตร · เอาไปใช้ข้างนอกจะโดนฟ้องว่า undefined: n

ที่ไม่เหมือนกัน: บัตรผู้มาติดต่อเราต้องเดินไปคืนเอง แต่ Go ตัดสิทธิ์ให้เองทันทีที่ปิดปีกกา ไม่มีทางลืมคืน · ที่ต่างอีกอย่างคือสิ่งที่หมดอายุคือชื่อ n ไม่ใช่ค่า — เรียกชื่อข้างนอกไม่ได้อีก แต่ถ้ายังมีใครถือค่านั้นอยู่ (เช่นคืน pointer ออกไป) ค่าก็ยังอยู่ได้ · อีกอย่างที่ต่างกัน: ถ้าข้างนอกมีตัวแปรชื่อ n อยู่แล้ว ตัวข้างในจะบังตัวข้างนอกไว้ชั่วคราว ไม่ได้ทับของเดิม — เรื่องนี้ชื่อว่า shadowing อยู่ใน บทเรียน 05

🗣 ศัพท์ที่เพิ่งเรียนไป
scope
“ขอบเขตที่ตัวแปรมีชีวิตอยู่” · แทนที่จะถามว่า “ตัวแปรนี้ใช้ได้ถึงไหน” ให้พูดว่า “ตัวแปรนี้ scope แค่ใน if block ใช่ไหม” จะได้คำตอบเร็วกว่ามาก
block
โค้ดที่อยู่ในปีกกาคู่หนึ่ง { ... } · ปกติตัวแปรมีชีวิตแค่ใน block ที่ประกาศมัน · ข้อยกเว้นที่เจอบ่อย คือตัวแปรที่ประกาศใน short statement หน้า if — ตัวนั้น scope ครอบทั้ง if และ else (คือข้ามปีกกาสองคู่) อย่างที่เห็นในโค้ดข้างบน
short statement
คำสั่งสั้น ๆ ที่แปะไว้หน้าเงื่อนไขแล้วคั่นด้วย ; · ใช้ได้กับ if และ switch · for ก็มีช่องเดียวกันนี้ (for i := 0; i < n; i++) แต่ตรงนั้นคนเรียกว่า init statement — จะเจอในบทหลัง · ถ้าอยากได้โค้ดสำนวนนี้ให้ขอว่า “เขียนแบบ if with short statement ให้หน่อย”
no fallthrough by default
“case ของ Go ไม่ไหลลง case ถัดไปเอง เหมือน Go ใส่ break ให้” · นี่คือสำนวนที่ตรงกับเอกสารทางการที่สุด · เวลาคุยกับคนที่มาจาก C/Java ให้บอกว่า “switch ของ Go ไม่ fallthrough โดยปริยาย” ประโยคเดียวจบ · บางคนเรียกสั้น ๆ ว่า implicit break ซึ่งสื่อกันรู้เรื่อง แต่ไม่ใช่คำใน spec

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

1. หลักการของ guard clause คืออะไร?

พลิกเงื่อนไขให้เป็นเคสที่ "ไม่ผ่าน" → จัดการ → return ออกไปเลย ทำให้เส้นทางปกติเหลืออยู่ชั้นนอกสุด ตรงกับ Indent Error Flow ในเอกสารทางการ

2. พลิก if hasTicket && hasID { เป็น guard clause ต้องเขียนยังไง?

กฎเดอมอร์แกน: !(a && b) = !a || !b — "ต้องมีทั้งตั๋วและบัตร" พลิกเป็น "ขาดอย่างใดอย่างหนึ่ง" ไม่ใช่ "ขาดทั้งสองอย่าง"

ถ้าพลิกผิดเป็น && คนที่มีตั๋วแต่ไม่มีบัตร จะผ่านเข้าไปได้ — บั๊กที่ compiler จับไม่ได้

3. switch ของ Go ที่ไม่เขียน break จะเป็นยังไง?

Go ใส่ break ให้โดยปริยาย — กลับด้านกับ C/Java ถ้าอยากให้ไหลต่อต้องสั่ง fallthrough เอง ซึ่งเป็นดีไซน์ที่ตั้งใจ เพราะ "ลืม break" เป็นบั๊กคลาสสิกของ C

4. ตัวแปรที่ประกาศใน if x := f(); cond { ใช้ได้ถึงไหน?

scope ครอบคลุมทั้ง if และ else แต่หลังปิดปีกกาแล้วหายไป — เรียกใช้ต่อจะได้ undefined: x

ประโยชน์: จำกัดอายุตัวแปรให้สั้นที่สุด ลดโอกาสเผลอใช้ผิดที่

7 · ลงมือทำ

ภารกิจ: refactor ระบบเข้างาน (workshop จากสไลด์)

โจทย์: ทำให้เหลือ condition ชั้นเดียว และ ไม่มี else เลย

age := 22
hasTicket := true
hasID := true
isVIP := false

if age >= 18 {
    if hasTicket {
        if hasID {
            if isVIP {
                fmt.Println("เข้างานได้: ผ่านประตู VIP")
            } else {
                fmt.Println("เข้างานได้: ผ่านประตูทั่วไป")
            }
        } else {
            fmt.Println("ปฏิเสธ: ไม่มีบัตรประชาชน")
        }
    } else {
        fmt.Println("ปฏิเสธ: ไม่มีตั๋ว")
    }
} else {
    fmt.Println("ปฏิเสธ: อายุไม่ถึง 18 ปี")
}
  1. รันโค้ดเดิมก่อน จดผลลัพธ์ไว้ — จะได้เทียบทีหลัง
  2. refactor เป็น guard clause ทีละชั้น (อย่าทำรวดเดียว)
  3. รันใหม่ ต้องได้ผลเหมือนเดิมเป๊ะ
  4. ทดสอบให้ครบ: ลองเปลี่ยนค่าให้ครบทั้ง 5 เส้นทาง (อายุไม่ถึง / ไม่มีตั๋ว / ไม่มีบัตร / ทั่วไป / VIP)
  5. นับบรรทัดและนับชั้นย่อหน้าเทียบก่อน–หลัง
  6. ท้าทาย: โค้ดข้างบนอยู่ใน main ซึ่ง return ใช้ได้อยู่แล้ว (แต่มันจะจบโปรแกรมทั้งโปรแกรม) — ลองย้ายมาเป็นฟังก์ชันแยกที่ คืน string ออกมาแทนการ Println: func checkEntry(age int, hasTicket, hasID, isVIP bool) string
    ข้อดี: return จะจบแค่ฟังก์ชันนี้ ไม่จบทั้งโปรแกรม และเรียกซ้ำหลายเคสรวดเดียวได้ ทำให้เทสต์ครบ 5 เส้นทางในการรันครั้งเดียว
ชัยชนะของบทนี้

เห็นโค้ดย่อหน้าลึกเกิน 2 ชั้นเมื่อไหร่ มือคุณจะคันอยากพลิกเป็น guard clause ทันที — และนั่นคือนิสัยที่ทำให้โค้ดผ่าน review รอบเดียว

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

แหล่งหลักที่แนะนำ: Go Code Review Comments — หัวข้อ Indent Error Flow คือที่มาของบทนี้ทั้งบท อ่านทั้งหน้าไปเลยเพราะสั้น และเป็นเกณฑ์ที่ reviewer จริงใช้

💬 ติดตรงไหนถามได้เลย — ถ้า refactor แล้วผลลัพธ์เพี้ยนจากเดิม ส่วนใหญ่มาจากพลิก && / || ผิด ส่งโค้ดมาได้ เดี๋ยวช่วยไล่ทีละเงื่อนไข

อ้างอิง