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

Feature Envy

method ที่​สนใจ​ข้อมูล​ของ class อื่น​มากกว่า class ของ​ตัวเอง

Feature Envy คือ​กลิ่น​ที่ method หนึ่ง “อิจฉา” feature ของ class อื่น — มัน​เรียก getter หรือ method ของ object อื่น​ซ้ำ​แล้ว​ซ้ำ​เล่า​เพื่อ​อ่าน​ข้อมูล​มา​คำนวณ ทั้ง​ที่​แทบ​ไม่​แตะ field หรือ method ของ class ตัวเอง​เลย Martin Fowler นิยาม​ไว้​ตั้งแต่​หนังสือ Refactoring ฉบับ​ปี 1999 ว่า​เป็น​กรณี​ที่ “method ใน class หนึ่ง​สื่อสาร​กับ​ข้อมูล​หรือ method ของ​อีก module หนึ่ง​มากกว่า module ของ​ตัวเอง” — ใน​ฉบับ​พิมพ์​ใหม่​เขา​ขยาย​คำ​ว่า “class” เป็น “module” เพื่อ​ให้​ครอบคลุม​หน่วย​จัด​ระเบียบ code แบบ​อื่น​ด้วย

ราก​ของ​ปัญหา​คือ misplaced responsibility — ความ​รับผิดชอบ​ถูกวาง​ผิด​ที่ ตรรกะ​ทาง​ธุรกิจ​ไม่​ได้​อยู่​ติด​กับ​ข้อมูล​ที่​มัน​ต้อง​ใช้ กลิ่น​นี้​มัก​โผล่​มา​พร้อม​กับ Data Class เช่น​เมื่อ​ทีม​แยก field ออก​ไป​ไว้​ใน class ข้อมูล​ล้วน ๆ (มัก​เพื่อ mapping กับ​ฐาน​ข้อมูล​หรือ DTO) แต่​ลืม​ย้าย​พฤติกรรม​ที่​ใช้ field เหล่า​นั้น​ตาม​ไป​ด้วย ผล​คือ​มี class “Service” ลอย ๆ คอย​เอื้อม​มือ​เข้าไป​ควาน​ข้อมูล​จาก​อีก​ฝั่ง​อยู่​ตลอด

ทาง​แก้​คลาสสิก​คือ Move Method (ใน​ฉบับ​พิมพ์​ที่​สอง​เรียก Move Function) — ย้าย logic ไป​อยู่​กับ​ข้อมูล​ของ​มัน ตาม​หลัก Tell, Don’t Ask: แทนที่​จะ​ถาม​ข้อมูล​ออก​มา​แล้ว​คำนวณ​เอง ให้​บอก​ให้ object นั้น​ทำงาน​ให้ และ​ตาม Law of Demeter: อย่า​เอื้อม​มือ​ไป​ไกล​เกิน​กว่า​เพื่อนบ้าน​ชั้น​เดียว

สัญญาณ​ที่​พบ​บ่อย​เวลา​อ่าน code:

  • method เรียก getter ของ object เดียวกัน​หลาย​ตัว​ติด​กัน (customer.GetTier(), customer.GetLoyaltyYears(), customer.GetLifetimeSpend() …) เพื่อ​คำนวณ​ค่า​อะไร​บาง​อย่าง
  • ถ้า​ลบ parameter ที่​รับ object อื่น​เข้า​มา​ออก​ไป method นี้​จะ​แทบ​ไม่​เหลือ​อะไร​ให้​ทำ — มัน​แทบ​ไม่​ใช้ field หรือ method ของ class ตัวเอง​เลย
  • method สามารถ​เป็น static ได้​สบาย ๆ ถ้า​ไม่​ติด​ที่​มัน​ต้อง​พึ่งพา object ต่าง class ตลอด​เวลา
  • code รีวิว​มัก​ถาม​คำถาม​แบบ “ทำไม OrderService ต้อง​รู้จัก field ภายใน​ของ Customer ตั้ง​สาม​ตัว​แค่​เพื่อ​คำนวณ​ส่วนลด”
  • มัก​เกิด​คู่​กับ Data Class ที่​มี​แต่ property เปิด​สาธารณะ​เต็ม​ไป​หมด​โดย​ไม่มี​พฤติกรรม​ของ​ตัวเอง
  • เครื่องมือ​วัด cohesion อย่าง LCOM (Lack of Cohesion of Methods) มัก​สูง​ผิด​ปกติ​ใน class ที่​มี method แบบ​นี้​ปะปน​อยู่

Coupling ผิด​ทิศทาง — method ผูก​ติด​กับ​โครงสร้าง​ภายใน​ของ class อื่น​แน่น​กว่า class ตัวเอง​เสีย​อีก ถ้า Customer เปลี่ยน​วิธี​เก็บ​ข้อมูล field ที่ method นี้​อ่าน​อยู่ code ฝั่ง OrderService ก็​พัง​ตาม​ไป​ด้วย ทั้ง​ที่​ควร​จะ​เป็น​เรื่อง​ภายใน​ของ Customer เอง

Encapsulation รั่ว — เพื่อ​ให้ method ข้าง​นอก​อ่าน​ข้อมูล​ได้ class ต้นทาง​ต้อง​เปิด getter สาธารณะ​ออก​มา​เยอะ​เกิน​จำเป็น ทั้ง​ที่​จริง ๆ ควร​เปิดเผย​แค่ “พฤติกรรม” ไม่ใช่ “ข้อมูล​ดิบ”

Duplicate Code แฝง​ตัว — ถ้า​มี​อีก​จุด​ใน​ระบบ​ต้อง​คำนวณ​เรื่อง​เดียวกัน​จาก​ข้อมูล​เดียวกัน มัน​มัก​จะ​เขียน logic ซ้ำ​ขึ้น​มา​อีก​ชุด เพราะ logic ไม่​ได้​อยู่​เป็น​ที่​เป็น​ทางใน class ข้อมูล​นั้น ขัด​กับ DRY

ทดสอบ​ยาก — การ​เขียน unit test ให้ method ที่ envy class อื่น ต้อง​ประกอบ object ของ​อีก​ฝั่ง​ขึ้น​มา​เต็ม​รูปแบบ (หรือ mock) ทั้ง​ที่​ถ้า logic อยู่​กับ​ข้อมูล​ของ​มัน​เอง ก็​ทดสอบ​ได้​แบบ​โดดเดี่ยว​ง่าย ๆ

สัญญาณ​ของ Anemic Domain Model — ใน​บริบท DDD นี่​มัก​เป็น​รอย​เท้า​ของ Anemic Domain Model: entity กลาย​เป็น​ถุง​ใส่​ข้อมูล​ที่​มี​แต่ getter/setter ส่วน​ตรรกะ​ทาง​ธุรกิจ​ทั้งหมด​ไป​กอง​อยู่​ใน “Service” ที่​คอย​เอื้อม​มือ​เข้า​มา​อ่าน — นี่​ขัด​กับ​เจตนา​ของ model domain ที่​ควร​ห่อ​หุ้ม​ทั้ง​ข้อมูล​และ​พฤติกรรม​ที่​สื่อ ubiquitous language ไว้​ด้วย​กัน

ข้อ​ยกเว้น — อย่า​ไล่​ล่า​กลิ่น​นี้​แบบ​ไม่​ดู​บริบท ถ้า​การ​แยก​พฤติกรรม​ออก​จาก​ข้อมูล​เป็น​ความ​ตั้งใจ​ของ design pattern อย่าง Strategy หรือ Visitor ที่​ต้องการ​สลับ​พฤติกรรม​ได้​อย่าง​ยืดหยุ่น กรณี​นั้น​ไม่ใช่​กลิ่น แต่​เป็นการ​ออกแบบ​ที่​ตั้งใจ

code สมมติ: OrderService ต้อง​คำนวณ​อัตราส่วนลด​ของ​ลูกค้า แต่​ทำ​โดย​เอื้อม​มือ​เข้าไป​อ่าน field ของ Customer เอง​ทั้งหมด

// กลิ่น: OrderService "อิจฉา" ข้อมูลของ Customer
public class Customer
{
public CustomerTier Tier { get; set; }
public int LoyaltyYears { get; set; }
public decimal LifetimeSpend { get; set; }
}
public class OrderService
{
public decimal CalculateDiscountRate(Customer customer)
{
// method นี้แทบไม่แตะข้อมูลของ OrderService เองเลย
// มันอ่าน field ของ Customer ทั้งหมดสามตัวเพื่อคำนวณ
decimal rate = 0m;
if (customer.Tier == CustomerTier.Gold)
rate += 0.10m;
if (customer.LoyaltyYears >= 5)
rate += 0.05m;
if (customer.LifetimeSpend > 100_000m)
rate += 0.03m;
return Math.Min(rate, 0.20m);
}
}

refactor ด้วย Move Method — ย้าย logic ทั้ง​ก้อน​ไป​อยู่​กับ Customer ซึ่ง​เป็น​เจ้าของ​ข้อมูล​จริง ๆ แล้ว​ปิด field ให้​เป็น private ตาม​หลัก encapsulation จาก​นั้น​ให้ OrderService แค่ “บอก” ให้ Customer คำนวณ​เอง (Tell, Don’t Ask):

// หลัง refactor ด้วย Move Method: ย้าย logic ไปอยู่กับข้อมูลที่มันต้องใช้
public class Customer
{
public CustomerTier Tier { get; private set; }
public int LoyaltyYears { get; private set; }
public decimal LifetimeSpend { get; private set; }
public decimal CalculateDiscountRate()
{
decimal rate = 0m;
if (Tier == CustomerTier.Gold)
rate += 0.10m;
if (LoyaltyYears >= 5)
rate += 0.05m;
if (LifetimeSpend > 100_000m)
rate += 0.03m;
return Math.Min(rate, 0.20m);
}
}
public class OrderService
{
public decimal CalculateDiscountRate(Customer customer)
=> customer.CalculateDiscountRate(); // Tell, Don't Ask
}

ถ้า method เดิม​ทำ​หลาย​อย่าง​ปน​กัน — ส่วน1 envy Customer อีก​ส่วน​เป็น​ตรรกะ​เฉพาะ​ของ order เอง (เช่น​โปรโมชั่น​ตาม​ฤดูกาล) — ให้​ทำ Extract Method แยก​ส่วน​ที่ envy ออก​มา​ก่อน แล้ว​ค่อย Move Method เฉพาะ​ส่วน​นั้น​ไป​หา Customer ส่วน​ตรรกะ​ที่​เหลือ​ซึ่ง​เป็น​ของ OrderService จริง ๆ ก็​ปล่อย​ไว้​ที่​เดิม — เทคนิค​นี้​บาง​แหล่ง​เรียก​ว่า “Split Across Classes” เมื่อ method หนึ่ง​พึ่งพา​หลาย class พร้อม​กัน

ทิศทาง​ของ coupling เปลี่ยน​จาก​กระจัดกระจาย (เอื้อม​อ่าน​หลาย field) เหลือ​แค่​การ​เรียก​เดียว:

graph LR
    subgraph Before
        A1[CalculateDiscountRate] -->|reads Tier| B1[Customer]
        A1 -->|reads LoyaltyYears| B2[Customer]
        A1 -->|reads LifetimeSpend| B3[Customer]
    end
    subgraph After
        A2[CalculateDiscountRate] -->|calls| B4[Customer]
    end
  • Data Class — กลิ่น​คู่หู​ที่​มัก​เกิด​พร้อม​กัน: class ข้อมูล​ล้วน ๆ ที่​ไม่มี​พฤติกรรม ทำให้​ตรรกะ​ต้อง​ไป​อยู่​ที่​อื่น
  • Tell, Don’t Ask — หลักการ​ที่​ชี้​ทาง​แก้: บอก​ให้ object ทำงาน แทนที่​จะ​ถาม​ข้อมูล​ออก​มา​คำนวณ​เอง
  • Law of Demeter — กติกา​ว่า​อย่า​เอื้อม​มือ​เกิน​ระยะ​เพื่อนบ้าน​ชั้น​เดียว
  • Anemic Domain Model — ผลลัพธ์​ระดับ​สถาปัตยกรรม​เมื่อ Feature Envy เกิด​ขึ้น​ทั่ว​ทั้ง​ระบบ
  • Inappropriate Intimacy — กลิ่น​ใกล้​เคียง​ที่ class สอง​ฝั่ง​ผูก​ติด​กัน​แน่น​เกิน​ไป​ทั้ง​คู่ ไม่ใช่​แค่​ฝ่าย​เดียว​ที่ envy
  • Middle Man — ผล​ข้าง​เคียง​ถ้า refactor เกิน​ขอบเขต จน class หนึ่ง​เหลือ​แต่​การ​ส่ง​ต่อ (delegate) ล้วน ๆ