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

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 ใด​เตือน​จนกว่า​จะ​รัน​จริง

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 ยังเป็น null
logger.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 แยก​แต่ละ​ขั้น เพื่อ​ทำให้​ลำดับ​ผิดกลาย “เขียน​ไม่​ได้” แทนที่​จะ “เขียน​ได้​แต่​พัง”