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

Law of Demeter Violations

เข้าถึง object จาก​ส่วน​ไกล​ของ object graph แทน​เพื่อนบ้าน​ใกล้​ตัว

Law of Demeter Violations เกิด​เมื่อ code เข้าถึง object จาก​ส่วน​ที่​ไกล​ของ object graph แทน​การ​จำกัด​ปฏิสัมพันธ์​กับ collaborator ใกล้​ตัว​เท่านั้น หลักการ Law of Demeter (หรือ “principle of least knowledge”) บอกว่า method หนึ่ง​ควร​เรียก​ใช้​เฉพาะ: ตัว​มัน​เอง, parameter ของ​มัน, object ที่​มัน​สร้าง​ขึ้น​เอง, field ของ​ตัว​มัน​เอง หรือ global ที่​เข้าถึงได้ในสโคป​นั้น — ไม่ใช่​ผลลัพธ์​ที่​คืน​มา​จาก object อื่น​ซ้อน​กัน​หลาย​ชั้น

รูปแบบ​ที่​พบ​บ่อย​ที่สุด​ของ​การ​ละเมิด​คือ​สิ่ง​ที่​เรียก​กัน​ว่า “train wreck” หรือ “message chain” คือ​การ chain การ​เรียก method หรือ property ผ่าน object กลาง​หลาย​ตัว​เพื่อ​ไป​ถึง object ปลายทาง เช่น person.getDepartment().getManager().getName() — code บรรทัด​นี้​ไม่​ได้​คุย​กับ “เพื่อนบ้าน” ของ person เท่านั้น แต่​เดิน​ทะลุ​ไป​ถึง​เพื่อน​ของ​เพื่อน​ของ​เพื่อนบ้าน ซึ่ง​ขัด​กับ​อุปมา​ที่​มัก​ใช้​อธิบาย​กฎ​นี้​ว่า “สั่ง​สุนัข​ให้​เดิน อย่า​ไป​สั่ง​ขา​ของ​มัน​แต่ละ​ข้าง​โดยตรง”

สำหรับ​ภาษา​ที่​ใช้ dot notation มี​กฎ​อย่าง​ง่าย​ที่​เรียก​ว่า “one dot rule”: a.m() โอเค แต่ a.m().n() มี​แนวโน้ม​ละเมิด (ยกเว้น method chaining แบบ fluent interface ที่​คืน this ของ object เดียวกัน ซึ่ง​ไม่​นับ​เป็นการ​ละเมิด​เพราะ​ไม่​ได้​ข้าม​ไป object คนละ​ตัว)

  • code มี​จุด (.) เรียง​ต่อ​กัน​ยาว​ใน​บรรทัด​เดียว เช่น order.getCustomer().getAddress().getCity().toUpperCase()
  • ต้อง​รู้ “แผนที่” ทั้งหมด​ของ object graph ก่อน​จะ​เขียน code หนึ่ง​บรรทัด​ได้ — เพื่อ​ไป​ถึง City ต้อง​รู้​ว่า Order มี Customer, Customer มี Address, Address มี City
  • เปลี่ยน​โครงสร้าง​ภายใน​ของ object กลาง​ตัว​ใด​ตัว​หนึ่ง (เช่น​ย้าย field, เปลี่ยน​ชื่อ, เพิ่ม​ชั้น​ซ้อน) แล้ว compile error หรือ null reference กระจาย​ไป​ทั่ว codebase ใน​จุด​ที่​ไม่​เกี่ยวข้อง​กัน​โดยตรง
  • Test ของ class หนึ่ง​ต้อง mock/stub object กลาง​หลาย​ชั้น​ซ้อน​กัน​เพื่อ​ให้ chain การ​เรียก​ทำงาน​ได้ (mock ของ mock ของ mock)
  • อาการ​นี้​มัก​มา​คู่​กับ​กลิ่น Message Chains ซึ่ง​เป็น​ภาพ​ที่​มอง​เห็น​ได้​ชัดเจน​ที่สุด​ของ​การ​ละเมิด​นี้​ใน code

Coupling ที่​สูง​เกิน​จำเป็น — client ที่ chain ผ่าน a.b().c().d() ผูก​ติด​อยู่​กับ​โครงสร้าง​ภายใน​ของ​ทั้ง A, B, และ C พร้อม​กัน ทั้ง​ที่​จริง ๆ client สนใจ​แค่​ผลลัพธ์​สุดท้าย​จาก D เท่านั้น หาก B เปลี่ยน​วิธี​เก็บ reference ไป​ยัง C (เช่น​เปลี่ยน​จาก field เป็น lazy-loaded property, หรือ​เปลี่ยน​ชื่อ) ทุก​จุด​ที่ chain ผ่าน​ต้อง​แก้ไข​ตาม แม้​ไม่มี​ส่วน​เกี่ยวข้อง​กับ business logic ที่แท้​จริง​เลย

Fragility และ shotgun surgery — ยิ่ง chain ยาว ยิ่ง​มี​จุด​ที่​พัง​ได้​มาก (แต่ละ​จุด​ใน chain อาจ​คืน​ค่า null ได้) และ​ยิ่ง​มี code จำนวน​มาก​ที่​ต้อง​แก้​เมื่อ object graph เปลี่ยน ซึ่ง​นำ​ไป​สู่​กลิ่น Shotgun Surgery โดยตรง

รั่วไหล​ของ encapsulation — object ที่​เปิด​ให้​เข้าถึง collaborator ภายใน​ของ​ตัวเอง​ผ่าน public getter ก็​คือ​การ​เปิดเผย​รายละเอียด​การ implement ของ​ตัวเอง แทนที่​จะ​ห่อ​หุ้ม (encapsulate) พฤติกรรม​ไว้ ขัด​กับ​หลัก Encapsulation และ​หลัก Tell, Don’t Ask ที่​บอกว่า​ควร “บอก” object ให้​ทำงาน​แทนที่​จะ “ถาม” ข้อมูล​ออก​มา​แล้ว​เอา​ไป​ตัดสิน​ใจ​เอง

อ่าน​ยาก เทส​ยาก — ต้อง​ไล่​ตาม​ความ​สัมพันธ์​ทั้ง​สาย​เพื่อ​เข้าใจ​ว่า code หนึ่ง​บรรทัด​ทำ​อะไร และ​การ​เขียน unit test ต้อง​สร้าง object graph ทั้ง​ชุด (หรือ mock ซ้อน mock) เพียง​เพื่อ​ทดสอบ logic เล็ก ๆ ตรง​ปลาย chain

งาน​วิจัย​เชิง​ประจักษ์​เกี่ยว​กับ software metrics พบ​ว่า​จำนวน​การ​เรียก method ที่​กระจาย​ออก​จาก class หนึ่ง (response for a class, RFC) ที่​สูง​มี​ความ​สัมพันธ์​กับ​จำนวน bug ที่​มาก​ขึ้น ซึ่ง​สอดคล้อง​กับ​สัญชาตญาณ​เบื้องหลัง Law of Demeter — ยิ่ง​พึ่งพา object ไกล​ตัว​มาก ยิ่ง​มี​จุด​ที่​พัง​ได้​มาก

code ตัวอย่าง​ต่อ​ไป​นี้ chain ผ่าน OrderCustomerAddress เพื่อ​ไป​เอา​ชื่อ​เมือง แล้ว​นำ​ไป​คำนวณ​ส่วนลด​ค่า​จัด​ส่ง — เป็น train wreck คลาสสิก:

// smelly: chain ทะลุผ่าน object กลางหลายชั้น
public class ShippingCalculator
{
public decimal CalculateDiscount(Order order)
{
// ต้องรู้ว่า Order มี Customer, Customer มี Address, Address มี City
string city = order.Customer.Address.City.ToUpperInvariant();
if (city == "BANGKOK" || city == "NONTHABURI")
{
return order.Customer.Address.IsMetroArea ? 0.10m : 0.05m;
}
return 0m;
}
}
public class Order
{
public Customer Customer { get; set; }
}
public class Customer
{
public Address Address { get; set; }
}
public class Address
{
public string City { get; set; }
public bool IsMetroArea { get; set; }
}

ปัญหา: ShippingCalculator ต้อง​รู้จัก​โครงสร้าง​ภายใน​ของ​ทั้ง Order, Customer, และ Address พร้อม​กัน ถ้า​วัน​หนึ่ง​เปลี่ยน​ให้ Customer เก็บ​ที่​อยู่​หลาย​ที่ (List<Address>) หรือ​ย้าย IsMetroArea ไป​เป็น service แยก code จุด​นี้ (และ​ทุก​จุด​ที่ chain แบบ​เดียวกัน) ต้อง​แก้​หมด

ใช้​การ refactor Hide Delegate เพื่อ​ให้​แต่ละ object เปิดเผย​เฉพาะ​สิ่ง​ที่​เพื่อนบ้าน​ต้อง​รู้​จริง ๆ ซ่อน​รายละเอียด​ว่า​ใคร​ถือ reference ของ​ใคร​ไว้​ภายใน:

// refactored: Hide Delegate — แต่ละ object เปิดเผยเฉพาะสิ่งที่ต้องใช้จริง
public class ShippingCalculator
{
public decimal CalculateDiscount(Order order)
{
// ไม่มี chain แล้ว — Order เป็นผู้ตัดสินใจแทน
if (!order.IsInMetroCity("BANGKOK", "NONTHABURI"))
{
return 0m;
}
return order.IsInMetroArea() ? 0.10m : 0.05m;
}
}
public class Order
{
public Customer Customer { get; set; }
// Hide Delegate: ห่อการเข้าถึง Customer.Address ไว้ภายใน
public bool IsInMetroCity(params string[] cityNames)
=> Customer.LivesIn(cityNames);
public bool IsInMetroArea()
=> Customer.IsInMetroArea();
}
public class Customer
{
public Address Address { get; set; }
public bool LivesIn(params string[] cityNames)
=> cityNames.Contains(Address.City, StringComparer.OrdinalIgnoreCase);
public bool IsInMetroArea()
=> Address.IsMetroArea;
}
public class Address
{
public string City { get; set; }
public bool IsMetroArea { get; set; }
}

ตอน​นี้ ShippingCalculator ไม่รู้จัก Customer หรือ Address เลย มัน​คุย​กับ Order เพื่อนบ้าน​ของ​ตัวเอง​เท่านั้น (สอดคล้อง​กับ Tell, Don’t Ask) หาก Customer เปลี่ยน​วิธี​เก็บ​ที่​อยู่​ใน​อนาคต มี​เพียง Customer และ Order เท่านั้น​ที่​ต้อง​แก้ ผลกระทบ​ไม่​กระจาย​ออก​ไป​นอก aggregate

ข้อ​ควร​ระวัง: อย่า Hide Delegate พร่ำเพรื่อ​จน​ทุก class กลาย​เป็น​แค่​ตัว​ส่ง​ต่อ (forwarding) เปล่า ๆ ที่​ไม่มี logic ของ​ตัวเอง — นั่น​คือ​กลิ่น Middle Man ที่​เป็น​ผล​ข้าง​เคียง​ของ​การ​แก้​ปัญหา​นี้​มาก​เกิน​ไป หลัก​ที่​ดี​คือ Hide Delegate เฉพาะ chain ที่ client ภายนอก​ไม่​ควร​รู้จัก​โครงสร้าง​จริง ๆ ไม่ใช่​ทุก property access

flowchart LR
    Client[ShippingCalculator]
    Order1[Order]
    Customer1[Customer]
    Address1[Address]

    Client -.->|smelly: chain 3 ชั้น| Order1
    Order1 -.-> Customer1
    Customer1 -.-> Address1

    Client -->|refactored: 1 ชั้น| Order2[Order]
    Order2 --> Customer2[Customer]
    Customer2 --> Address2[Address]
  • Law of Demeter — หลักการ​ที่​กลิ่น​นี้​ละเมิด
  • Message Chains — อาการ​ที่​มอง​เห็น​ได้​ชัด​ที่สุด​ของ​การ​ละเมิด​นี้​ใน code
  • Middle Man — ผล​ข้าง​เคียง​เมื่อ Hide Delegate ถูก​ใช้​มาก​เกิน​ไป
  • Feature Envy — กลิ่น​ใกล้​เคียง​ที่ method สนใจ​ข้อมูล​ของ object อื่น​มากกว่า​ของ​ตัวเอง
  • Tell, Don’t Ask — หลัก​ที่​แนะนำ​ให้ “บอก” object ทำงาน​แทน​การ “ถาม” ข้อมูล​ออก​มา​แล้ว chain ต่อ​เอง
  • Encapsulation — หลักการ​ห่อ​หุ้ม​ที่​การ​ละเมิด Law of Demeter มัก​ฝ่าฝืน