Hidden Temporal Coupling
method ต้องถูกเรียกตามลำดับ แต่ไม่มีอะไรบังคับหรือเตือน
กลิ่นนี้คืออะไร
หัวข้อที่มีชื่อว่า “กลิ่นนี้คืออะไร”Hidden Temporal Coupling (บางแหล่งเรียก sequential coupling) คือกลิ่นที่ method ของ class หนึ่ง ต้องถูกเรียกตามลำดับที่ถูกต้อง จึงจะทำงานถูก แต่ signature ของ method หรือ type system ไม่ได้บังคับลำดับนั้นเลย ผู้เรียกสามารถเขียน code ที่ compile ผ่านได้แม้เรียกผิดลำดับ ความรู้เรื่อง “ต้องเรียก A ก่อน B เสมอ” อยู่แค่ใน comment เอกสาร หรือความจำของทีมเท่านั้น
Wikipedia อธิบายผ่านตัวอย่างรถยนต์ไว้ชัดเจนว่า ถ้าคนขับเหยียบคันเร่งโดยไม่สตาร์ทเครื่องก่อน รถจะไม่พัง ไม่ throw exception ไม่มีเสียงเตือนใด ๆ — มันแค่ไม่ไปไหน นี่คือธรรมชาติของ temporal coupling ที่อันตราย: มันไม่ได้พังทันทีเสมอไป บางครั้งแค่เงียบและทำงานผิดโดยไม่มีใครสังเกต
จุดที่ทำให้กลิ่นนี้ต่างจาก coupling ทั่วไปคือคำว่า hidden — coupling ที่มองเห็นได้ในลายเซ็น method (เช่น constructor ที่ require parameter) ไม่ใช่ปัญหา เพราะ compiler ช่วยตรวจให้ แต่ temporal coupling ที่ซ่อนอยู่ในลำดับการเรียก method แบบ imperative ธรรมดา ไม่มีกลไกใดช่วยตรวจให้เลย
วิธีสังเกต
หัวข้อที่มีชื่อว่า “วิธีสังเกต”- ชื่อ method ขึ้นต้นด้วย
Init,Initialize,Begin,Setup,Open,Startและต้องถูกเรียกก่อน method “จริง” อย่างProcess,Write,Executeเสมอ - comment หรือเอกสารเขียนไว้ทำนอง “ต้องเรียก
Connect()ก่อนSend()มิฉะนั้นจะเกิดข้อผิดพลาด” - มี boolean flag ภายใน (เช่น
_isInitialized) ที่ใช้เช็คว่า setup เสร็จหรือยัง ก่อนจะยอมทำงานจริง - เรียกผิดลำดับแล้วได้
NullReferenceException,InvalidOperationExceptionตอน runtime — ไม่ใช่ compile-time error - unit test ต้อง arrange โดยเรียกหลาย method เรียงลำดับตายตัวก่อนถึงจะ assert ได้ ทั้งที่ไม่มีอะไรใน code production บังคับลำดับนั้น
- method บางตัว “เงียบ” เมื่อถูกเรียกผิดจังหวะ แทนที่จะ error — เช่น เรียก
Commit()ก่อนBeginTransaction()แล้วไม่มีอะไรเกิดขึ้นเลย ซึ่งอันตรายกว่า exception เพราะไม่มีใครรู้ตัว
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”ปัญหาหลักคือ สิ่งที่ compile ผ่านได้ กับสิ่งที่ถูกต้องจริง ไม่ใช่เซตเดียวกัน compiler ยืนยันได้แค่ว่า code เรียก method ที่มีอยู่จริงด้วย type ที่ตรง แต่ยืนยันไม่ได้ว่าลำดับการเรียกสมเหตุสมผล นักพัฒนาใหม่ที่ไม่รู้ “กฎที่ไม่ได้เขียนไว้” นี้ ย่อมเรียกผิดลำดับได้ง่าย ๆ โดยไม่มีสัญญาณเตือนจน bug ไปโผล่ที่ production
ยิ่งไปกว่านั้น temporal coupling ผูกอยู่กับ mutable state ที่ object เก็บไว้ระหว่าง call แต่ละครั้ง — พอมี shared state ก็เปิดช่องให้เกิด race condition ได้ทันทีในบริบท multi-thread เพราะ2 thread อาจแทรกลำดับกันจนตัวแปรภายในอยู่ใน state ที่ไม่มีใครออกแบบไว้ ยิ่งเพิ่มความเปราะบางเข้าไปอีกชั้น
มันยังขัดกับหลักการปกป้อง invariant ของ object (protect your invariants) เพราะ object ยอมให้ตัวเองอยู่ใน “สถานะที่ยังใช้งานไม่ได้” ได้ตั้งแต่ตอนสร้าง แล้วค่อยพึ่งวินัยของผู้เรียกให้พาไปสู่สถานะที่ใช้ได้ — ตรงข้ามกับแนวคิด Make Illegal States Unrepresentable ที่อยากให้ type system เองเป็นคนป้องกัน ไม่ใช่ผู้เรียก
สุดท้ายมันทำให้ refactor เสี่ยงขึ้นมาก เพราะเปลี่ยนลำดับ code เพียงเล็กน้อย (ย้ายบรรทัด, สลับ statement, เพิ่ม early return) อาจทำลายลำดับที่ต้องพึ่งพา โดยไม่มี compiler หรือ test ใดเตือนจนกว่าจะรันจริง
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”flowchart LR
Created --> Initialized
Initialized --> Written
Written --> Closed
Created -.->|invalid order still compiles| Written
เส้นประในแผนภาพคือเส้นทางที่ compiler ไม่รู้ว่าห้ามไป แต่ code กลับปล่อยให้ผู้เรียกไปถึงได้
code ต่อไปนี้มี logger ที่ต้องเรียก Initialize ก่อน Write เสมอ แต่ไม่มีอะไรบังคับไว้เลย:
public class FileLogger{ private StreamWriter? _writer;
public void Initialize(string path) { _writer = new StreamWriter(path, append: true); }
public void Write(string message) { // ถ้าลืมเรียก Initialize ก่อน จะได้ NullReferenceException ตอนรัน ไม่ใช่ตอน compile _writer!.WriteLine(message); }
public void Close() { _writer?.Dispose(); }}
// ผู้เรียกสลับลำดับได้อย่างอิสระ compiler ไม่ทักท้วงอะไรเลยvar logger = new FileLogger();logger.Write("เริ่มระบบ"); // พังตอนรัน เพราะ _writer ยังเป็น nulllogger.Initialize("app.log");refactor ด้วย Constructor Injection ร่วมกับ Immutability (แนวทางเดียวกับที่ Mark Seemann เสนอใน ที่มา · blog.ploeh.dk) — ย้ายค่าที่จำเป็นเข้า constructor เพื่อไม่ให้ object ที่ยังใช้งานไม่ได้ถูกสร้างขึ้นมาได้เลย:
public sealed class FileLogger : IDisposable{ private readonly StreamWriter _writer;
public FileLogger(string path) { _writer = new StreamWriter(path, append: true); }
public void Write(string message) => _writer.WriteLine(message);
public void Dispose() => _writer.Dispose();}
// ตอนนี้ไม่มีทางเรียก Write ก่อนพร้อมได้อีกแล้ว เพราะ object ใช้งานได้ตั้งแต่สร้างเสร็จusing var logger = new FileLogger("app.log");logger.Write("เริ่มระบบ"); // ถูกลำดับเสมอ ไม่มี state กึ่งพร้อมให้เรียกผิดสำหรับลำดับที่ซับซ้อนกว่านี้ เช่น ต้องเรียก SetCustomer แล้วค่อย AddLine แล้วค่อย Submit ตามลำดับตายตัว การคืนค่า void จากแต่ละ step ทำให้ผู้เรียกสลับลำดับได้อิสระ ทางแก้ที่ yegor256 และ DevIQ แนะนำตรงกันคือให้แต่ละ step คืน object ใหม่ที่มีเฉพาะ method ของ step ถัดไป ทำให้ compiler เองเป็นคนบังคับลำดับ:
public interface IWithCustomer{ IWithLines SetCustomer(string customerId);}
public interface IWithLines{ IWithLines AddLine(string sku, int qty); ISubmittable Done();}
public interface ISubmittable{ OrderId Submit();}
// เขียนลำดับผิดจะ compile ไม่ผ่านทันที ไม่ใช่พังตอนรันvar orderId = orderBuilder .SetCustomer("cust-42") .AddLine("sku-1", 2) .Done() .Submit();รูปแบบนี้คือแก่นของ Builder แบบ staged/step builder — ใช้ interface แยกแต่ละขั้น เพื่อทำให้ลำดับผิดกลาย “เขียนไม่ได้” แทนที่จะ “เขียนได้แต่พัง”
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Make Illegal States Unrepresentable
- Single Point of Enforcement
- Required Setup/Teardown
- Builder
- State