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 ไกลตัวมาก ยิ่งมีจุดที่พังได้มาก
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”code ตัวอย่างต่อไปนี้ chain ผ่าน Order → Customer → Address เพื่อไปเอาชื่อเมือง แล้วนำไปคำนวณส่วนลดค่าจัดส่ง — เป็น 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 มักฝ่าฝืน