Separate Query from Modifier
จุดประสงค์
หัวข้อที่มีชื่อว่า “จุดประสงค์”เมื่อ function หนึ่งตอบคำถาม และ เปลี่ยนแปลงโลกในฐานะผลข้างเคียงไปด้วย caller ก็สูญเสียอิสระที่จะแค่ถามเฉย ๆ ให้แยกงานสองอย่างนี้ออกจากกัน: query ที่คืนค่าและไม่แตะต้องอะไรเลย และ command ที่ทำงานเปลี่ยนแปลงและไม่คืนค่าอะไร ตอนนี้ caller ที่ต้องการแค่คำตอบก็สามารถเรียก query ได้โดยไม่ไปกระตุ้นผลข้างเคียง
Code Smell
หัวข้อที่มีชื่อว่า “Code Smell”คุณอยากเรียก function เพื่ออ่านผลลัพธ์เฉย ๆ อาจจะใน test ใน log หรือใน assertion แต่ทำไม่ได้ เพราะเรียกทีไรก็พ่วงการส่งอีเมล การเปลี่ยนค่า field หรือการเลื่อน cursor ไปด้วยทุกที signature จึงโกหกเรื่องต้นทุน คือหน้าตาเหมือนคำถาม แต่แอบเป็นการกระทำ function ไหนที่คืนค่าพร้อมกับสร้าง side effect ที่สังเกตเห็นได้ ตัวนั้นคือเป้าหมายของท่านี้
ก่อน → หลัง
หัวข้อที่มีชื่อว่า “ก่อน → หลัง”function ตัวนี้สแกนคะแนน คืนค่าสูงสุด และแอบยิงการแจ้งเตือนเมื่อคะแนนสูงสุดข้ามเกณฑ์ เราแยกออกเป็น query บริสุทธิ์ชื่อ highScore กับ command ชื่อ alertOnHighScore
// Beforefunction findHighScore(scores: number[]): number { let highest = 0; for (const s of scores) { if (s > highest) highest = s; } if (highest > 9000) { sendAlert(`New record: ${highest}`); } return highest;}
// Afterfunction highScore(scores: number[]): number { return scores.reduce((highest, s) => (s > highest ? s : highest), 0);}
function alertOnHighScore(scores: number[]): void { if (highScore(scores) > 9000) { sendAlert(`New record: ${highScore(scores)}`); }}# Beforedef find_high_score(scores): highest = 0 for s in scores: if s > highest: highest = s if highest > 9000: send_alert(f"New record: {highest}") return highest
# Afterdef high_score(scores): return max(scores, default=0)
def alert_on_high_score(scores): if high_score(scores) > 9000: send_alert(f"New record: {high_score(scores)}")// Beforefunc FindHighScore(scores []int) int { highest := 0 for _, s := range scores { if s > highest { highest = s } } if highest > 9000 { sendAlert(fmt.Sprintf("New record: %d", highest)) } return highest}
// Afterfunc HighScore(scores []int) int { highest := 0 for _, s := range scores { if s > highest { highest = s } } return highest}
func AlertOnHighScore(scores []int) { if h := HighScore(scores); h > 9000 { sendAlert(fmt.Sprintf("New record: %d", h)) }}// Beforefn find_high_score(scores: &[i32]) -> i32 { let mut highest = 0; for &s in scores { if s > highest { highest = s; } } if highest > 9000 { send_alert(&format!("New record: {highest}")); } highest}
// Afterfn high_score(scores: &[i32]) -> i32 { scores.iter().copied().max().unwrap_or(0)}
fn alert_on_high_score(scores: &[i32]) { let h = high_score(scores); if h > 9000 { send_alert(&format!("New record: {h}")); }}flowchart LR
subgraph Before["Before"]
A["findHighScore()<br/>returns the max<br/>AND logs an alert"]
end
subgraph After["After"]
B["highScore()<br/>pure query"]
C["alertOnHighScore()<br/>command"]
end
Before -.->|"Separate Query from Modifier"| After กลไกการทำงาน
หัวข้อที่มีชื่อว่า “กลไกการทำงาน”- คัดลอก function แล้วตั้งชื่อสำเนาให้เป็น query ที่บริสุทธิ์ — ตามธรรมเนียมจะเป็นคำนามหรือชื่อสไตล์
get/isที่สัญญาว่าให้แค่คำตอบ - ตัด side effect ทั้งหมดออกจากสำเนาฝั่ง query ให้เหลือแค่การคำนวณค่า แล้วคืนค่านั้นออกไป
- หา caller แต่ละจุดของ function เดิม จุดที่ caller ใช้ค่าที่คืนกลับมา ให้สลับไปใช้ query
- ปล่อยให้ function เดิมเหลือแค่ side effect และให้เรียก query ตัวใหม่แทนการคำนวณซ้ำเอง
- รัน test หลังจากย้าย caller แต่ละจุดข้ามไปแล้ว
- ถ้าตอนนี้ function เดิมคืนค่าที่ไม่มีใครใช้แล้ว ให้เปลี่ยนเป็นไม่คืนค่าเลย signature ของ command จะได้ซื่อสัตย์
ใช้เมื่อไหร่ / ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ใช้เมื่อไหร่ / ข้อแลกเปลี่ยน”ใช้ท่านี้เมื่อใดก็ตามที่ function ที่มีค่าคืนกลับยังเปลี่ยนสถานะ ทำ I/O หรือยิงอีเวนต์ด้วย — และโดยเฉพาะก่อนคุณจะเขียน test เพราะ query ที่บริสุทธิ์ตรวจ assert ได้ง่ายมาก การแยกที่สะอาดยังทำให้ query ปลอดภัยที่จะเรียกซ้ำ ๆ หรือ cache ได้
ต้นทุนคือ caller ที่ต้องการทั้งสอง behavior จริง ๆ ต้องเรียกสองครั้ง และค่าอาจถูกคำนวณสองรอบ ปกติการคำนวณซ้ำนั้นราคาถูก ถ้าไม่ถูกก็คำนวณครั้งเดียวแล้วส่งผลลัพธ์ต่อไป บาง operation เป็น atomic โดยธรรมชาติ เช่น pop ที่คืนค่าและลบในขั้นเดียว การฝืนแยกจะเปิดช่องให้เกิด race condition กรณีแบบนั้นให้จงใจเก็บไว้ด้วยกัน
เนื้อหาที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “เนื้อหาที่เกี่ยวข้อง”| ใช้ Separate Query from Modifier เมื่อ | หลีกเลี่ยงเมื่อ |
|---|---|
| function คืนค่าพร้อมกับมี side effect | operation นั้นเป็น atomic โดยธรรมชาติ เช่น pop |
| ต้องการ query ใน assertion หรือ test โดยไม่ trigger side effect | การแยกทำให้ต้องคำนวณซ้ำแบบ expensive |
| side effect ซ่อนอยู่และ caller ไม่รู้ | แยกแล้วมี race condition ที่ atomic เคยป้องกัน |
⚠️ ไม่ควร Separate Query from Modifier เมื่อ:
- operation นั้น atomic โดยเจตนา เช่น
compare-and-swap,pop,dequeue- การคำนวณ query ซ้ำมี cost สูงและ caching ซับซ้อน
- แยกแล้วเปิดช่องให้ caller เรียก query แต่ลืมเรียก command ทำให้ data ไม่สอดคล้อง