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

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 สนิท​กัน​เกิน​ไป จะ​เกิด​ผล​เสีย​หลาย​ด้าน

  1. Coupling สูง เปลี่ยน​อะไร​ก็​กระทบ​กัน​หมด — แก้ field ใน class B ต้อง​ไล่​แก้ class A ตาม​ด้วย​เสมอ ระบบ​จึง​เปราะบาง​และ​แก้​ยาก ตรง​ข้าม​กับ​หลัก Encapsulation ที่​ต้องการ​ซ่อน​รายละเอียด​ภายใน​ไว้​ไม่​ให้​โลก​ภายนอก​รับรู้
  2. นำ​กลับ​มา​ใช้​ใหม่​ไม่​ได้ — เพราะ A กับ B ผูก​ติด​กัน​แน่น จะ​ดึง class ใด class หนึ่ง​ไป​ใช้​ใน​บริบท​อื่น​โดย​ไม่​ลาก อีก class ตาม​ไป​ด้วย​แทบ​เป็น​ไป​ไม่​ได้
  3. test ยาก — การ​ทดสอบ class A ต้อง​ตั้ง​ค่า mock/stub ของ class B ใน​ระดับ​รายละเอียด​ภายใน ทำให้ test เปราะ​และ​ผูก​กับ implementation มากกว่า behavior
  4. ละเมิด Law of Demeter — เมื่อ object หนึ่ง​เริ่ม “รู้” โครงสร้าง​ภายใน​ของ​อีก object จน​เรียก​ทะลุ​เข้าไป​หลาย​ชั้น มัก​ไป​พร้อม​กับ​การ​ละเมิด Law of Demeter (“อย่า​คุย​กับ​คน​แปลก​หน้า”) ด้วย
  5. ความ​รับผิดชอบ​กระจาย​ผิด​ที่ — บ่อย​ครั้ง​กลิ่น​นี้​บ่ง​ชี้​ว่า logic ที่​ควร​อยู่​ใน​ที่​เดียว​ถูก​ฉีก​กระจาย​ไป2 class ทำให้ cohesion ของ​แต่ละ class ต่ำ​ลง

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 ย้อน​กลับ​ไป​แก้​ผู้​อื่น

  • 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 ให้​สอดคล้อง​กับ​ความ​สัมพันธ์​จริง