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

Artificial Coupling

บังคับ​ให้​แนวคิด​ที่​ไม่​เกี่ยว​กัน​มา​มี​ความ​สัมพันธ์​กัน​โดย​ไม่​จำเป็น

Artificial Coupling คือ​กลิ่น​ที่ แนวคิด​สอง​อย่าง​ที่​ไม่​เกี่ยวข้อง​กัน​ตาม​ธรรมชาติ​ถูก​บังคับ​ให้​ผูก​ติด​กัน ทั้ง​ที่ domain หรือ​ตรรกะ​ทาง​ธุรกิจ​ไม่​ได้​เรียกร้อง​ความ​สัมพันธ์​นี้​เลย ความ​ผูกพัน​เกิด​จาก “ความ​สะดวก​ของ​ผู้​เขียน code ณ ขณะ​นั้น” ล้วน ๆ เช่น หยิบ enum ทั่วไป​ไป​ประกาศ​ซ้อน​ไว้​ใน class เฉพาะ​เจาะจง​ตัว​หนึ่ง​เพราะ​กำลัง​เขียน class นั้น​อยู่​พอดี หรือ​แปะ static method อรรถประโยชน์ (utility) ไว้​ใน class แรก​ที่​เจอ​เพราะ​ขี้เกียจ​สร้าง file ใหม่ หรือ​สืบทอด base class เพียง​เพื่อ​หยิบยืม field/method ไม่​กี่​ตัว​มา​ใช้

Robert C. Martin ใน Clean Code เรียก​กลิ่น​นี้​ว่า G13: Artificial Coupling และ​สรุป​หลักการ​ไว้​สั้น ๆ ว่า “จง​เลือก code ที่​ชัดเจน มากกว่า code ที่​สะดวก อย่า​จัด​กลุ่ม code ตาม​ความ​สะดวก​ใน​การ​จดจำ (mental mapping) แทนที่​จะ​จัด​ตาม​ความ​ชัดเจน​ของ​แนวคิด” — กล่าว​คือ ต้อง​ใช้​เวลา​คิด​ว่า function, constant, หรือ variable ตัว​หนึ่ง “ควร” ประกาศ​อยู่​ที่ไหน​ตาม​ธรรมชาติ​ของ​มัน ไม่ใช่​โยน​มัน​ไว้​ที่​ตำแหน่ง​ที่​หยิบ​ใช้​ง่าย​ที่สุด​ใน​ตอน​นั้น​แล้ว​ปล่อย​ทิ้ง​ไว้​อย่าง​นั้น​ตลอด​ไป

จุด​ที่​ทำให้ Artificial Coupling ต่าง​จาก​กลิ่น​ตระกูล Coupler อื่น ๆ (Feature Envy, Inappropriate Intimacy, Message Chains) คือ ที่​นี่​ไม่มี​เหตุผล​เชิง​พฤติกรรม​ใด ๆ รองรับ​ความ​สัมพันธ์​เลย — มัน​คือ dependency ที่​เกิด​จาก “ที่​วาง​ของ” ไม่ใช่​จาก “สิ่ง​ที่ code ต้อง​ทำ”

สัญญาณ​ที่​บอกว่า​กำลัง​เจอ Artificial Coupling:

  • ต้อง using/reference class หนึ่ง​ทั้ง class เพียง​เพื่อ​เข้าถึง enum, constant, หรือ nested type เล็ก ๆ ตัว​เดียว​ข้าง​ใน​มัน
  • static utility method ที่​ไม่​เกี่ยว​กับ​สถานะ​ของ class เลย ถูก​แปะ​ไว้​ใน class ทาง​ธุรกิจ (เช่น OrderProcessor.FormatCurrency(...)) เพียง​เพราะ​เป็น class แรก​ที่​ต้องการ formatting ตอน​นั้น
  • inheritance ที่​ไม่มี​ความ​สัมพันธ์​แบบ “is-a” จริง แต่​ทำ​ไป​เพื่อ “หยิบยืม” field หรือ method จาก base class
  • เปลี่ยน code ใน module A แล้ว​ต้อง build/แก้ module B ที่​ไม่มี​อะไร​เกี่ยวข้อง​กัน​ใน​เชิง domain เลย
  • คำ​อธิบาย​ใน​ทีม​ที่​ขึ้น​ต้น​ว่า “อ๋อ มัน​อยู่​ตรง​นั้น​เพราะ​ตอน​เขียน​ครั้ง​แรก​มัน​สะดวก” ไม่ใช่ “มัน​อยู่​ตรง​นั้น​เพราะ​มัน​ควร​อยู่​ตรง​นั้น”
  • ต้อง mock หรือ instantiate class ที่​ไม่​เกี่ยวข้อง​เลย​แค่​เพื่อ​ทดสอบ unit เล็ก ๆ ตัว​หนึ่ง

Coupling ที่​ไม่มี​เหตุผล​รองรับ​สร้าง​ต้นทุน​แฝง​หลาย​ชั้น:

  1. ริปเปิล​เอฟเฟกต์ผิด​ที่ — เปลี่ยน​แนวคิด A (ที่​ควร​เป็น​อิสระ) ดัน​ไป​กระทบ​แนวคิด B ที่​ไม่​เกี่ยวข้อง​กัน​จริง เพราะ​ทั้ง​คู่​ถูก​บังคับ​ให้​อยู่​ใน file/class เดียวกัน นี่​คือ​เมล็ด​พันธุ์​ของ Shotgun Surgery และ Divergent Change
  2. ละเมิด Separation of Concerns และ SRP — class ที่​แบก dependency ซึ่ง​ไม่ใช่​ของ​ตัวเอง​มี​เหตุผล​ให้​เปลี่ยนแปลง​มากกว่า​หนึ่ง​เหตุผล ขัด​กับ Single Responsibility โดยตรง
  3. ทำลาย encapsulation ของ​ทั้ง​สอง​ฝั่ง — ทั้ง class เจ้าของ​และ class ที่มา​ยืม​ใช้ ต่าง​เผย​รายละเอียด​ภายใน​ให้​กัน​โดย​ไม่​จำเป็น เพิ่ม​พื้นที่​เสี่ยง​ต่อ Inappropriate Intimacy
  4. reuse และ testability ต่ำ​ลง — จะนำ enum หรือ utility ไป​ใช้​ใน​บริบท​อื่น​ก็​ต้อง​ลาก class ทั้ง​ก้อน​ที่​ไม่​เกี่ยวข้อง​ติด​ไป​ด้วย ทำให้​เขียน unit test แบบ​แยก​ส่วน​ยาก​ขึ้น
  5. สับสน​ต่อ​ผู้​มา​ใหม่ — โครงสร้าง code ไม่​สื่อ​ความหมาย​ของ domain อีก​ต่อ​ไป คน​อ่าน​ต้อง​จำ (“mental mapping”) ว่า​อะไร​อยู่​ตรง​ไหน​แทนที่​จะ​อนุมาน​ได้​จาก​ชื่อ​และ​ตำแหน่ง

Artificial Coupling มัก​ไม่​ทำให้ code “พัง​ทันที” แต่​มัน​สะสม​เป็น​แรง​เสียด​ทาน​ที่​ทำให้ codebase แข็งตัว​ขึ้น​เรื่อย ๆ — ทุก​การ​เปลี่ยนแปลง​เล็ก ๆ ต้อง​เช็ค​ผลกระทบ​ข้าม​ขอบเขต​ที่​ไม่​ควร​มี​อยู่​ตั้งแต่​แรก

สมมติ​ทีม​ต้องการ enum บอก​วิธี​จัด​ส่ง แต่​ผู้​เขียน​คน​แรก​กำลัง​ทำงาน​อยู่​ใน Order เลย​ประกาศ enum นี้​ซ้อน​ไว้​ข้าง​ใน​นั้น:

Order.cs
public class Order
{
// enum นี้เป็นแนวคิดทั่วไป ไม่ได้ผูกกับ Order โดยธรรมชาติ
// แต่ถูกซ้อนไว้ที่นี่เพราะสะดวกตอนเขียนครั้งแรก
public enum ShippingMethod { Standard, Express, Overnight }
public ShippingMethod Method { get; set; }
public decimal Total { get; set; }
}
// Invoice.cs — ต้องรู้จัก Order ทั้ง class เพียงเพื่อใช้ enum เดียวข้างใน
public class Invoice
{
public Order.ShippingMethod PreferredShipping { get; set; }
public decimal CalculateShippingSurcharge()
{
return PreferredShipping switch
{
Order.ShippingMethod.Overnight => 25.00m,
Order.ShippingMethod.Express => 10.00m,
_ => 0m
};
}
}

Invoice ไม่​ได้​สนใจ Order เลย​แม้แต่​นิดเดียว มัน​แค่​ต้องการ​แนวคิด “วิธี​จัด​ส่ง” แต่​ต้อง​อ้างอิง class Order ทั้ง​ก้อน​เพียง​เพื่อ​ไป​หยิบ enum ข้าง​ใน — ถ้า​วัน​หนึ่ง Order ถูก​ย้าย​ไป assembly อื่น หรือ​ถูก refactor ครั้ง​ใหญ่ Invoice จะ​ถูก​ดึง​เข้าไป​เกี่ยวข้อง​ด้วย​ทั้ง​ที่​ไม่​ควร​ต้อง​เป็น​แบบ​นั้น

ก่อน refactor graph dependency หน้าตา​แบบ​นี้:

flowchart LR
    subgraph Before
        InvoiceB[Invoice] --> OrderB[Order]
        OrderB --> ShipB[ShippingMethod]
    end

refactor ด้วย​เทคนิค​ที่​ใกล้​เคียง Move Class ของ Fowler — ย้าย type ที่​ยืน​ได้​ด้วย​ตัวเอง​ออก​มา​เป็น top-level type ของ​ตัวเอง ไม่​ซ้อน​อยู่​ใน class ใด class หนึ่ง:

Order.cs
// ShippingMethod.cs — แนวคิดที่ยืนได้ด้วยตัวเอง ไม่ผูกกับ Order หรือ Invoice
public enum ShippingMethod { Standard, Express, Overnight }
public class Order
{
public ShippingMethod Method { get; set; }
public decimal Total { get; set; }
}
// Invoice.cs — ไม่ต้องรู้จัก Order อีกต่อไป
public class Invoice
{
public ShippingMethod PreferredShipping { get; set; }
public decimal CalculateShippingSurcharge()
{
return PreferredShipping switch
{
ShippingMethod.Overnight => 25.00m,
ShippingMethod.Express => 10.00m,
_ => 0m
};
}
}

หลัง refactor ทั้ง​สอง​ฝั่ง​พึ่งพา ShippingMethod โดยตรง ไม่มี edge ปลอม​ระหว่าง​กัน​อีก​ต่อ​ไป:

flowchart LR
    subgraph After
        InvoiceA[Invoice] --> ShipA[ShippingMethod]
        OrderA[Order] --> ShipA
    end

หลักการ​เดียวกัน​นี้​ใช้ได้​กับ static utility method ด้วย: ถ้า OrderProcessor มี public static string FormatCurrency(decimal amount) ที่​ไม่​แตะ field หรือ state ของ OrderProcessor เลย ให้​ใช้ Extract Class ย้าย​มัน​ไป​เป็น CurrencyFormatter แยก​ต่างหาก แล้ว​ให้​ทุก class ที่​ต้องการ format เงิน​อ้างอิง CurrencyFormatter ตรง ๆ แทนที่​จะ​ต้อง​รู้จัก OrderProcessor ไป​ด้วย