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

Class Doesn’t Do Much

class ที่​ทำ​น้อย​จน​ไม่​คุ้ม​การ​มี​อยู่ (Lazy Class)

Class Doesn’t Do Much (หรือ​ชื่อ​ดั้งเดิม​ใน​หนังสือ​ของ Fowler ว่า Lazy Class) อธิบาย class ที่​ทำ​หน้าที่​น้อย​เกิน​กว่า​จะ​คุ้ม​กับ​ต้นทุน​ที่​มัน​สร้าง​ขึ้น — ต้นทุน​ของ​การ​มี class 1 class ไม่​ได้​มี​แค่​บรรทัด code แต่​รวม​ถึง file ที่​ต้อง​เปิด ชื่อ​ที่​ต้อง​จำ test ที่​ต้อง​เขียน และ indirection ที่​คน​อ่าน code ต้อง​กระโดด​ตาม​เพื่อ​ทำความ​เข้าใจ ระบบ​ทีม​งาน​ของ DevIQ สรุป​สั้น ๆ ว่า ถ้า class หนึ่ง​ไม่​ได้​ทำ​อะไร​มาก​พอที่​จะ “จ่าย​ค่า​เช่า” ความ​ซับซ้อน​ที่​มัน​เพิ่ม​เข้า​มา ก็​ควร​ถูก​ยุบ​ทิ้ง

กลิ่น​นี้​มัก​เกิด​จาก​สอง​เส้นทาง:

  1. หด​ตัว​จาก​การ refactor — ครั้ง1 class นี้​เคย​มี​ความ​รับผิดชอบ​เต็ม แต่​เมื่อ​เวลา​ผ่าน​ไป logic ถูก​ดึง​ออก​ไป​อยู่​ที่​อื่น​ที​ละ​นิด (Extract Method/Class ไป​เรื่อย ๆ) จน​เหลือ​แต่​เปลือก​ที่​ห่อ field เดียว​หรือ method เดียว
  2. Speculative Generality — สร้าง class ขึ้น​มา​ล่วงหน้า​เพื่อ​รองรับ​อนาคต​ที่​ยัง​ไม่​มา​ถึง เช่น “เผื่อ​วัน​หนึ่ง​จะ​มี​หลาย strategy” แต่​จนถึง​วัน​นี้​มี implementation เดียว

สิ่ง​สำคัญ​คือ​ต้อง​แยก​ให้​ออก​จาก value object, DTO, Null Object หรือ marker type ที่​ตั้งใจ​ให้​เล็ก — class เหล่า​นั้น​เล็ก​แต่ “ทำงาน” อยู่​จริง เช่น บังคับ invariant ป้องกัน​ค่าที่​ผิด​กฎ หรือ​ให้ type safety ที่ primitive ธรรมดา​ให้​ไม่​ได้ Lazy Class ต่าง​ออก​ไป​ตรง​ที่​มัน​ไม่​ได้​ทำ​อะไร​เลย​นอกจาก​ส่ง​ต่อ (forward) ค่า​หรือ​การ​เรียก method

  • class มี field เดียว​หรือ​สอง​อัน และ​มี method เดียว​ที่​แค่​คืน​ค่า​หรือ​คำนวณ​ตรง ๆ จาก field นั้น ไม่มี logic อื่น
  • class ถูก instantiate และ​เรียก​ใช้​จาก​จุด​เดียว​ใน​ทั้ง​ระบบ ไม่มี​ที่​อื่น​อ้างอิง​ถึง
  • method ส่วน​ใหญ่​เป็น getter/setter หรือ pass-through ไป​ยัง object อื่น​โดย​ไม่​เพิ่ม​พฤติกรรม​อะไร
  • subclass ที่ override method parent เพียง​เล็กน้อยหรือ​ไม่ override อะไร​เลย — สัญญาณ​ว่า hierarchy นั้น​ควร​ถูก​ยุบ
  • เวลา​ถาม​ทีม​ว่า “ทำไม class นี้​ต้อง​แยก​จาก class ที่​เรียก​มัน” คำ​ตอบ​มัก​เป็น “เผื่อ​อนาคต” มากกว่า​เหตุผล​ด้าน domain หรือ invariant
  • class ถูก​สร้าง​ขึ้น​เพื่อ​รองรับ interface ที่​วางแผน​ไว้ (เช่น Strategy ที่​มี implementation เดียว) แต่​ยัง​ไม่มี​เหตุผล​จริง​ให้​สลับ implementation

Lazy Class ไม่​ได้​ผิด​เพราะ​ทำให้​โปรแกรม​พัง แต่​ผิด​เพราะ​มัน​เพิ่ม ต้นทุน​ความ​เข้าใจ (cognitive overhead) โดย​ไม่​ได้​แลก​กับ​คุณค่า​อะไร​กลับ​มา:

  • การนำทาง code ยาก​ขึ้น — ผู้​อ่าน​ต้อง​กระโดด​ข้าม file เพื่อ​ตาม​รอย logic ที่​จริง ๆ ควร​อยู่​รวม​กัน​ใน​ที่​เดียว
  • ความ​รับผิดชอบ​ไม่​ชัดเจน — เพราะ class ไม่มี​ความ​รับผิดชอบ​ที่​จับ​ต้อง​ได้ จึง​ขัด​กับ​เจตนารมณ์​ของ Single Responsibility Principle ใน​ทาง​กลับ​กัน (มี class แต่​ไม่มี responsibility ให้​รับผิดชอบ)
  • ต้นทุน​บำรุง​รักษา​สะสม — test ต้อง​ครอบคลุม class เพิ่ม, การ refactor ต้อง​แก้​หลาย file, การ onboard คน​ใหม่​ต้อง​อธิบาย class ที่​ไม่มี​เหตุผล​อยู่
  • บั่นทอน​ความ​ไว้​วางใจ​ต่อ abstraction — เมื่อ​ทีม​เจอ Lazy Class บ่อย ๆ จะ​เริ่ม​มอง​ข้าม abstraction ใหม่ ๆ ว่า​อาจ​ไม่มี​ประโยชน์​จริง ทำให้การ​ออกแบบ​ใน​อนาคต​ถูก​ตั้ง​คำถาม​โดย​ไม่​จำเป็น

จุด​สมดุล​คือ: การ​มี class เล็ก​ไม่ใช่​ปัญหา​ใน​ตัว​มัน​เอง ปัญหา​คือ class เล็ก​ที่ ไม่​ทำ​อะไร​เพื่อ​แลก​กับ​ต้นทุน​ที่​มัน​สร้าง

code ก่อน​แก้: DiscountCalculator ถูก​สร้าง​ขึ้น​มา​เพื่อ “แยก concern การ​คิด​ส่วนลด” แต่​ถูก​ใช้​จาก​ที่​เดียว​เท่านั้น และ​ไม่มี logic ใด​นอกจาก​การ​คูณ

public class DiscountCalculator
{
private readonly decimal _rate;
public DiscountCalculator(decimal rate)
{
_rate = rate;
}
public decimal Calculate(decimal amount)
{
return amount * _rate;
}
}
public class Order
{
private readonly DiscountCalculator _discountCalculator;
public decimal Total { get; }
public Order(decimal total, decimal discountRate)
{
Total = total;
_discountCalculator = new DiscountCalculator(discountRate);
}
public decimal GetDiscountedTotal()
{
return Total - _discountCalculator.Calculate(Total);
}
}

DiscountCalculator มี field เดียว method เดียว และ​ไม่​ถูก​อ้างอิง​จาก​ที่​อื่น​เลย — เข้า​เกณฑ์ Lazy Class ชัดเจน ใช้ Inline Class ยุบ​มัน​กลับ​เข้า Order:

public class Order
{
private readonly decimal _discountRate;
public decimal Total { get; }
public Order(decimal total, decimal discountRate)
{
Total = total;
_discountRate = discountRate;
}
public decimal GetDiscountedTotal()
{
return Total - (Total * _discountRate);
}
}

code สั้น​ลง อ่าน​ง่าย​ขึ้น และ​ไม่มี indirection ที่​ต้อง​ตาม — ถ้า​ใน​อนาคต​การ​คิด​ส่วนลด​ซับซ้อน​ขึ้น​จริง (เช่น​มี​หลาย​กฎ หลาย tier) ค่อย Extract Class กลับ​ออก​มา​ใหม่​ตอน​นั้น​ก็​ยัง​ทัน

กรณี​ที่ 2 — subclass ที่​ไม่​ทำ​อะไร​เพิ่ม แก้​ด้วย Collapse Hierarchy

หัวข้อ​ที่​มีชื่อ​ว่า “กรณี​ที่ 2 — subclass ที่​ไม่​ทำ​อะไร​เพิ่ม แก้​ด้วย Collapse Hierarchy”
public abstract class Shipment
{
public abstract decimal CalculateFee(decimal weightKg);
}
public class StandardShipment : Shipment
{
public override decimal CalculateFee(decimal weightKg)
{
return weightKg * 10m;
}
}

ถ้า StandardShipment เป็น subclass เดียว​ที่​มี​อยู่ และ​ไม่มี​แผน​จะ​เพิ่ม​ชนิด​การ​จัด​ส่ง​อื่น​ใน​เร็ว ๆ นี้ hierarchy ทั้ง​ชุด​ก็​เป็น​ภาระ​โดย​ไม่​จำเป็น ใช้ Collapse Hierarchy รวม2 class เป็น class เดียว:

public class Shipment
{
public decimal CalculateFee(decimal weightKg)
{
return weightKg * 10m;
}
}

เมื่อ​วัน​หนึ่ง​มี​ชนิด​การ​จัด​ส่ง​ที่​สอง​จริง ๆ (เช่น ExpressShipment ที่​คิด​ค่า​ธรรมเนียม​ต่าง​ออก​ไป) ค่อย​แยก abstraction กลับ​มา​ใหม่​ตาม​ความ​จำเป็น​จริง ไม่ใช่​เผื่อ​ไว้​ล่วงหน้า

ข้อ​ควร​ระวัง — อย่า​สับสน​กับ class เล็ก​ที่​มี​เหตุผล

หัวข้อ​ที่​มีชื่อ​ว่า “ข้อ​ควร​ระวัง — อย่า​สับสน​กับ class เล็ก​ที่​มี​เหตุผล”
// ไม่ใช่ Lazy Class แม้จะมี field เดียว — เพราะบังคับ invariant จริง
public sealed class Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0)
throw new ArgumentException("amount ต้องไม่ติดลบ");
Amount = amount;
Currency = currency;
}
}

Money เล็ก​พอ ๆ กับ DiscountCalculator ใน​ตัวอย่าง​แรก แต่​มัน​ทำงาน​จริง (บังคับ​กฎ ป้องกัน​ค่า​ไม่​ถูกต้อง ให้ type safety) จึง​ไม่ใช่ Lazy Class — การ​ตัดสิน​ใจ​ต้อง​ดู​ที่ “ทำ​อะไร​อยู่” ไม่ใช่​แค่ “ขนาด​ใหญ่​แค่​ไหน”

flowchart TB
  subgraph Before
    OrderA[Order] --> Calc[DiscountCalculator]
    Calc --> RateA[rate field]
  end
  subgraph After
    OrderB[Order with rate field inline]
  end
  • Middle Man — class ที่​ทำงาน​ได้​บ้าง​แต่​ส่วน​ใหญ่​เป็นการ​ส่ง​ต่อ (delegate) มาก​เกิน​ไป มัก​ปน​กับ Lazy Class
  • Speculative Generality — สาเหตุ​อันดับ​ต้น ๆ ที่​ทำให้​เกิด Lazy Class คือ​การ​ออกแบบ​เผื่อ​อนาคต​ที่​ไม่​เคย​มา​ถึง
  • Data Class — อีก​กลิ่น​ใน​กลุ่ม Dispensable ที่ class มี​แต่ data ไม่มี behavior
  • YAGNI — หลักการ​ที่​อธิบาย​ว่า​ทำไม​ไม่​ควร​สร้าง abstraction เผื่อ​ไว้​ล่วงหน้า
  • KISS — หลักการ​ความ​เรียบ​ง่าย​ที่ Lazy Class มัก​ละเมิด​โดย​ไม่​ตั้งใจ