Inappropriate Intimacy
2 class รู้ไส้ในของกันและกันมากเกินไป
กลิ่นนี้คืออะไร
หัวข้อที่มีชื่อว่า “กลิ่นนี้คืออะไร”Inappropriate Intimacy คือกลิ่นที่ 2 class คุ้นเคยกับไส้ในของกันและกันมากเกินไป ฝ่ายหนึ่ง (หรือทั้งคู่) ขุดเข้าไปใน private field, internal method หรือรายละเอียด implementation ของอีกฝ่าย แทนที่จะคุยกันผ่าน public interface ที่เหมาะสม
Martin Fowler อธิบายไว้ในหนังสือ Refactoring ว่านี่คือ “classes that spend too much time delving in each other’s private parts” — class สองตัวใช้เวลาไปกับการล้วงลูกความลับของกันและกันมากเกินไป ในฉบับพิมพ์ปี 2018 Fowler เปลี่ยนชื่อกลิ่นนี้เป็น Insider Trading เพื่อสื่อว่าปัญหาไม่ได้จำกัดแค่ระดับ class แต่เกิดกับ module หรือ subsystem ทั่วไปได้เช่นกัน แก่นของปัญหายังเหมือนเดิม นั่นคือสองส่วนของระบบแลกเปลี่ยนรายละเอียด implementation กันมากเกินความจำเป็น
ต่างจาก Feature Envy ที่ method หนึ่งสนใจข้อมูลของ class อื่นมากกว่า class ตัวเอง (ทางออกคือย้าย method นั้นไปอยู่กับข้อมูล) Inappropriate Intimacy เป็นปัญหาเชิงโครงสร้างความสัมพันธ์ — class สองตัวพึ่งพากันไปมาจนแยกกันไม่ออก ไม่ใช่แค่ method เดียวหลงทาง
วิธีสังเกต
หัวข้อที่มีชื่อว่า “วิธีสังเกต”- class A เรียกใช้ field หรือ method ที่ควรเป็น private/internal ของ class B โดยตรง (ผ่านการลด access modifier ให้หลวมกว่าที่ควร)
- class A และ B แก้ไข field ภายในของกันและกัน (bidirectional association) แทนที่จะมีทิศทางเดียวชัดเจน
- เวลาแก้ code ใน class B ต้องไปแก้ class A ตามแทบทุกครั้ง — สัญญาณของ Divergent Change และ Shotgun Surgery ที่เกิดร่วมกัน
- Subclass ล้วงเข้าไปใน implementation detail ของ superclass ที่ไม่ได้ตั้งใจให้ subclass เห็น (หรือกลับกัน)
- เมื่อวาด collaboration graph ของ class ในระบบ จะเห็น class สองตัวนี้เชื่อมกันหนาแน่นผิดปกติ ในขณะที่แทบไม่เชื่อมกับ class อื่นเลย
- Unit test ของ class A ต้อง mock หรือ setup รายละเอียดภายในของ class B จำนวนมากเพื่อให้ทดสอบผ่าน
flowchart LR
subgraph Before
A1[OrderClass] <--> B1[CustomerClass]
end
subgraph After
A2[OrderClass] --> C2[CustomerSummary]
B2[CustomerClass] --> C2
end
ฝั่งซ้ายคือความสัมพันธ์แบบสองทางที่ล้วงลึกเข้าหากัน ฝั่งขวาคือการดึงส่วนที่ใช้ร่วมกันออกมาเป็น class กลาง (Extract Class) แล้วให้ทั้งสองฝั่งพึ่งพา class กลางแทนการพึ่งพากันโดยตรง
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”หลักการพื้นฐานของการออกแบบเชิงวัตถุคือ “class ควรรู้จักกันให้น้อยที่สุดเท่าที่จำเป็น” (couplers ที่ดีคือ coupler ที่บางที่สุด) เมื่อ2 class สนิทกันเกินไป จะเกิดผลเสียหลายด้าน
- Coupling สูง เปลี่ยนอะไรก็กระทบกันหมด — แก้ field ใน class B ต้องไล่แก้ class A ตามด้วยเสมอ ระบบจึงเปราะบางและแก้ยาก ตรงข้ามกับหลัก Encapsulation ที่ต้องการซ่อนรายละเอียดภายในไว้ไม่ให้โลกภายนอกรับรู้
- นำกลับมาใช้ใหม่ไม่ได้ — เพราะ A กับ B ผูกติดกันแน่น จะดึง class ใด class หนึ่งไปใช้ในบริบทอื่นโดยไม่ลาก อีก class ตามไปด้วยแทบเป็นไปไม่ได้
- test ยาก — การทดสอบ class A ต้องตั้งค่า mock/stub ของ class B ในระดับรายละเอียดภายใน ทำให้ test เปราะและผูกกับ implementation มากกว่า behavior
- ละเมิด Law of Demeter — เมื่อ object หนึ่งเริ่ม “รู้” โครงสร้างภายในของอีก object จนเรียกทะลุเข้าไปหลายชั้น มักไปพร้อมกับการละเมิด Law of Demeter (“อย่าคุยกับคนแปลกหน้า”) ด้วย
- ความรับผิดชอบกระจายผิดที่ — บ่อยครั้งกลิ่นนี้บ่งชี้ว่า logic ที่ควรอยู่ในที่เดียวถูกฉีกกระจายไป2 class ทำให้ cohesion ของแต่ละ class ต่ำลง
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”ตัวอย่างที่ 1 — เข้าถึง field ภายในโดยตรง
หัวข้อที่มีชื่อว่า “ตัวอย่างที่ 1 — เข้าถึง field ภายในโดยตรง”code สมมติ: Order เข้าไปคำนวณส่วนลดโดยล้วง field ภายในของ Customer โดยตรง
// smelly: Order รู้ไส้ในของ Customer มากเกินไปpublic class Customer{ public string LoyaltyTier; // ควรเป็น private แต่เปิดเป็น public เพื่อให้ Order เข้าถึงได้ public int PointsBalance; public DateTime MemberSince;}
public class Order{ public decimal CalculateDiscount(Customer customer) { // Order ล้วงลึกเข้าไปตัดสินใจแทน Customer ว่า tier ไหนลดเท่าไร if (customer.LoyaltyTier == "Gold" && customer.PointsBalance > 1000) { return 0.20m; } if (customer.LoyaltyTier == "Silver" && (DateTime.Now - customer.MemberSince).TotalDays > 365) { return 0.10m; } return 0m; }}ปัญหา: Order ต้องรู้กติกาภายในของ Customer ทั้งหมด (tier, points, membership duration) ถ้าวันหนึ่งกติกาส่วนลดเปลี่ยน ต้องแก้ Order ทั้งที่ concept ของส่วนลดควรเป็นเรื่องของ Customer
ใช้ Move Method ย้าย logic การตัดสินใจไปไว้ที่ class ที่เป็นเจ้าของข้อมูลจริง แล้วเปิด method สาธารณะที่แสดงเจตนา แทนการเปิด field ภายใน:
// after Move Method: Customer เป็นเจ้าของกติกาส่วนลดของตัวเองpublic class Customer{ private string loyaltyTier; private int pointsBalance; private DateTime memberSince;
public Customer(string loyaltyTier, int pointsBalance, DateTime memberSince) { this.loyaltyTier = loyaltyTier; this.pointsBalance = pointsBalance; this.memberSince = memberSince; }
public decimal GetLoyaltyDiscountRate() { if (loyaltyTier == "Gold" && pointsBalance > 1000) { return 0.20m; } if (loyaltyTier == "Silver" && MembershipYears() > 1) { return 0.10m; } return 0m; }
private double MembershipYears() => (DateTime.Now - memberSince).TotalDays / 365;}
public class Order{ public decimal CalculateDiscount(Customer customer) { // Order แค่ถามผลลัพธ์ ไม่ต้องรู้กติกาภายใน return customer.GetLoyaltyDiscountRate(); }}ตอนนี้ field ทั้งหมดกลับไปเป็น private ได้จริง Order ไม่ต้องรู้ว่า tier มีกี่ระดับหรือคำนวณอายุสมาชิกอย่างไร — Encapsulation กลับมาสมบูรณ์
ตัวอย่างที่ 2 — ความสัมพันธ์สองทางที่ควรเป็นทางเดียว
หัวข้อที่มีชื่อว่า “ตัวอย่างที่ 2 — ความสัมพันธ์สองทางที่ควรเป็นทางเดียว”code สมมติ: Commit และ Repository ต่างฝ่ายต่างแก้ collection ภายในของกันและกัน
// smelly: bidirectional coupling — ทั้งสองฝั่งแก้ state ภายในของกันและกันpublic class Repository{ public List<Commit> Commits = new();
public void AddCommit(Commit commit) { Commits.Add(commit); commit.Repo = this; // Repository ยัด reference ตัวเองเข้าไปใน Commit }}
public class Commit{ public Repository Repo; // public field เก็บ reference กลับไปหา Repository public string Message;
public void Revert() { Repo.Commits.Remove(this); // Commit ล้วงเข้าไปแก้ list ภายในของ Repository }}ทั้ง2 class แก้ state ของกันและกันได้โดยตรง ทำให้แยก unit test ไม่ได้เลยถ้าไม่สร้างทั้งคู่พร้อมกัน และผู้เรียกไม่รู้ว่าใครเป็นเจ้าของความจริง (source of truth) ของความสัมพันธ์นี้กันแน่
ใช้ Change Bidirectional Association to Unidirectional ร่วมกับ Hide Delegate — ให้ทิศทางเดียวเป็นเจ้าของ และปิด field ภายในทั้งหมด:
// after: ความสัมพันธ์เป็นทิศทางเดียว มีเจ้าของชัดเจนpublic class Commit{ public string Message { get; }
public Commit(string message) { Message = message; }}
public class Repository{ private readonly List<Commit> commits = new();
public void AddCommit(Commit commit) => commits.Add(commit);
public void RevertCommit(Commit commit) => commits.Remove(commit);
public IReadOnlyList<Commit> Commits => commits.AsReadOnly();}Commit ไม่รู้จัก Repository อีกต่อไป ส่วน Repository เป็นเจ้าของ collection แต่เพียงผู้เดียวและเปิดเผยผ่าน IReadOnlyList เท่านั้น การ revert สั่งผ่าน Repository โดยตรง ไม่ใช่ให้ Commit ย้อนกลับไปแก้ผู้อื่น
สรุปเทคนิค refactor ที่ใช้บ่อย
หัวข้อที่มีชื่อว่า “สรุปเทคนิค refactor ที่ใช้บ่อย”- Move Method / Move Field — ย้ายส่วนที่ถูกอีกฝั่งใช้บ่อยไปอยู่กับฝั่งที่ใช้จริง
- Extract Class — ถ้า2 class มีส่วนร่วมกันจริง ให้แยกส่วนนั้นออกเป็น class ที่สาม แล้วให้ทั้งคู่พึ่งพา class ใหม่แทนการพึ่งพากันเอง
- Hide Delegate — ห่อ object ที่ถูกเข้าถึงทางอ้อมไว้หลัง method สาธารณะ ไม่ให้ผู้เรียกไล่เข้าไปถึงชั้นในสุด
- Change Bidirectional Association to Unidirectional — ตัดทิศทางที่ไม่จำเป็นออก เหลือทิศทางเดียวที่ชัดเจนว่าใครเป็นเจ้าของความสัมพันธ์
- Replace Delegation with Inheritance (หรือกลับกัน) — เมื่อความสนิทเกิดระหว่าง subclass กับ superclass ให้พิจารณาปรับโครงสร้าง inheritance ให้สอดคล้องกับความสัมพันธ์จริง