ข้ามไปยังเนื้อหา

Separate Query from Modifier

เมื่อ function หนึ่งตอบคำถาม และ เปลี่ยนแปลงโลกในฐานะผลข้างเคียงไปด้วย caller ก็สูญเสียอิสระที่จะแค่ถามเฉย ๆ ให้แยกงานสองอย่างนี้ออกจากกัน: query ที่คืนค่าและไม่แตะต้องอะไรเลย และ command ที่ทำงานเปลี่ยนแปลงและไม่คืนค่าอะไร ตอนนี้ caller ที่ต้องการแค่คำตอบก็สามารถเรียก query ได้โดยไม่ไปกระตุ้นผลข้างเคียง

คุณอยากเรียก function เพื่ออ่านผลลัพธ์เฉย ๆ อาจจะใน test ใน log หรือใน assertion แต่ทำไม่ได้ เพราะเรียกทีไรก็พ่วงการส่งอีเมล การเปลี่ยนค่า field หรือการเลื่อน cursor ไปด้วยทุกที signature จึงโกหกเรื่องต้นทุน คือหน้าตาเหมือนคำถาม แต่แอบเป็นการกระทำ function ไหนที่คืนค่าพร้อมกับสร้าง side effect ที่สังเกตเห็นได้ ตัวนั้นคือเป้าหมายของท่านี้

function ตัวนี้สแกนคะแนน คืนค่าสูงสุด และแอบยิงการแจ้งเตือนเมื่อคะแนนสูงสุดข้ามเกณฑ์ เราแยกออกเป็น query บริสุทธิ์ชื่อ highScore กับ command ชื่อ alertOnHighScore

// Before
function 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;
}
// After
function 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)}`);
}
}
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 ที่บริสุทธิ์บวกกับ command
  1. คัดลอก function แล้วตั้งชื่อสำเนาให้เป็น query ที่บริสุทธิ์ — ตามธรรมเนียมจะเป็นคำนามหรือชื่อสไตล์ get/is ที่สัญญาว่าให้แค่คำตอบ
  2. ตัด side effect ทั้งหมดออกจากสำเนาฝั่ง query ให้เหลือแค่การคำนวณค่า แล้วคืนค่านั้นออกไป
  3. หา caller แต่ละจุดของ function เดิม จุดที่ caller ใช้ค่าที่คืนกลับมา ให้สลับไปใช้ query
  4. ปล่อยให้ function เดิมเหลือแค่ side effect และให้เรียก query ตัวใหม่แทนการคำนวณซ้ำเอง
  5. รัน test หลังจากย้าย caller แต่ละจุดข้ามไปแล้ว
  6. ถ้าตอนนี้ function เดิมคืนค่าที่ไม่มีใครใช้แล้ว ให้เปลี่ยนเป็นไม่คืนค่าเลย signature ของ command จะได้ซื่อสัตย์

ใช้ท่านี้เมื่อใดก็ตามที่ function ที่มีค่าคืนกลับยังเปลี่ยนสถานะ ทำ I/O หรือยิงอีเวนต์ด้วย — และโดยเฉพาะก่อนคุณจะเขียน test เพราะ query ที่บริสุทธิ์ตรวจ assert ได้ง่ายมาก การแยกที่สะอาดยังทำให้ query ปลอดภัยที่จะเรียกซ้ำ ๆ หรือ cache ได้

ต้นทุนคือ caller ที่ต้องการทั้งสอง behavior จริง ๆ ต้องเรียกสองครั้ง และค่าอาจถูกคำนวณสองรอบ ปกติการคำนวณซ้ำนั้นราคาถูก ถ้าไม่ถูกก็คำนวณครั้งเดียวแล้วส่งผลลัพธ์ต่อไป บาง operation เป็น atomic โดยธรรมชาติ เช่น pop ที่คืนค่าและลบในขั้นเดียว การฝืนแยกจะเปิดช่องให้เกิด race condition กรณีแบบนั้นให้จงใจเก็บไว้ด้วยกัน

ใช้ Separate Query from Modifier เมื่อหลีกเลี่ยงเมื่อ
function คืนค่าพร้อมกับมี side effectoperation นั้นเป็น 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 ไม่สอดคล้อง
ลักษณะที่บ่งบอก function ที่ต้องการ Separate Query from Modifier คืออะไร?
หลังจากแยกแล้ว query ที่บริสุทธิ์ควรทำอะไร?
การรีแฟกเตอร์นี้นำหลักการใดมาปฏิบัติ?
เมื่อไรที่ยอมรับได้ที่จะคง query และ modifier ไว้ด้วยกัน?