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

Alternative Class with Different Interfaces

2 class ทำ​หน้าที่​เดียวกัน แต่ interface ต่าง​จน​สลับ​กัน​ไม่​ได้

กลิ่น​นี้​เกิด​เมื่อ 2 class ขึ้น​ไป​ทำ​บทบาท​เหมือน​หรือ​คล้าย​กัน​มาก แต่​เปิด function ผ่าน​ชื่อ method ลำดับ parameter หรือ signature ที่​ต่าง​กัน ทั้ง​ที่​ใน​ทาง​แนวคิด​แล้ว​มัน​ควร​เป็น “ทาง​เลือก” ของ​กัน​และ​กัน — สลับ​ใช้​อัน​ไหน​ก็ได้​แล้ว​แต่​สถานการณ์ แต่​เพราะ interface ไม่​ตรง​กัน code ฝั่ง​ที่​เรียก​ใช้​จึง​สลับ​แทน​กัน​ไม่​ได้​จริง ต้อง​เขียน​เงื่อนไข​แยก​เป็น​ราย class แทน

Martin Fowler จัด​กลิ่น​นี้​อยู่​ใน​หมวด Object-Orientation Abusers ใน​หนังสือ Refactoring และ​วิกิพีเดีย​เรียก​กลิ่น​นี้​ว่า Duplicate Abstraction ด้วย​เช่น​กัน คือ​เมื่อ2 abstraction มีชื่อ​ซ้ำ หรือ​มี​การ​ทำงาน​ซ้ำ หรือ​ทั้ง​สอง​อย่าง สาเหตุ​ที่​พบ​บ่อย​ที่สุด​คือ นัก​พัฒนา​คน​หนึ่ง​เขียน class ขึ้น​มา​โดย​ไม่รู้​ว่า​มี class ที่​ทำ​หน้าที่​เดียวกัน​อยู่​แล้ว​ใน codebase — อาจ​เพราะ​ทีม​ใหญ่​ขึ้น สื่อสาร​กัน​ไม่​ทั่วถึง หรือ​แยก​กัน​ทำงาน​คนละ module โดย​ไม่รู้จัก​งาน​ของ​กัน

  • มี2 class (หรือ​มากกว่า) ที่​ทำงาน​คล้าย​กัน​มาก แต่​ชื่อ method คนละ​ชื่อ เช่น SendEmail กับ Transmit
  • code ผู้​เรียก​มี if/else หรือ switch ที่​ตรวจ​ชนิด​ของ object ก่อน แล้ว​เรียก method คนละ​ชื่อ​ใน​แต่ละ branch
  • parameter ทำ​หน้าที่​เดียวกัน​แต่​ลำดับ​หรือ​ชนิด​ต่าง​กัน เช่น class หนึ่ง​รับ (string to, string subject, string body) อีก class รับ (string message, string recipient)
  • เวลา​ต้อง​เพิ่ม​ช่อง​ทาง​ใหม่ (channel/format/provider ใหม่) ต้อง​เพิ่ม​เงื่อนไข​ใหม่​ใน​ทุก​จุด​ที่​เรียก​ใช้ แทนที่​จะ​เพิ่ม implementation ใหม่​เพียง​จุด​เดียว
  • ทีม​สอง​คน (หรือ2 sprint) ผลิต class ที่​ทำงาน​ซ้ำ​กัน​ขึ้น​มา​โดย​ไม่รู้ตัว มัก​เจอ​ตอน​รีวิว code หรือ​ตอน merge

ปัญหา​หลัก​คือ coupling ฝั่ง​ผู้​เรียก​เพิ่ม​ขึ้น​โดย​ไม่​จำเป็น — code ที่​ควร​จะ​ปฏิบัติ​ต่อ​ทั้ง2 class อย่าง​เดียวกัน (polymorphism) กลับ​ต้อง​รู้จัก​รายละเอียด​ของ​แต่ละ class แยก​กัน ทุก​จุด​ที่​ใช้งาน​ต้อง​คอย branch ตาม​ชนิด ซึ่ง​ขัด​กับ DRY เพราะ​ตรรกะ “จะ​เรียก​อัน​ไหน​ด้วย​ชื่อ​ไหน” ถู​กก็อป​ปีกระจายอยู่​หลาย​ที่

มัน​ยัง​ขัด​กับ​แนวคิด​ของ Liskov Substitution — ถ้า2 class ควร​จะ​แทน​กัน​ได้​ใน​เชิง​พฤติกรรม แต่ interface ต่าง​กัน ก็​ไม่มี​ทาง substitute จริง ๆ ได้​จนกว่า​จะ​เขียน adapter หรือ branch เอา​ไว้​ก่อน และ​เมื่อ​มี class ที่ 3 มา​เพิ่ม (เช่น ช่อง​ทาง​แจ้ง​เตือน​ใหม่) จำนวน​จุด​ที่​ต้อง​แก้​ก็​ยิ่ง​ทวีคูณ ซึ่ง​เป็น​อาการ​เดียว​กับ Shotgun Surgery — เปลี่ยน​สิ่ง​หนึ่ง​แต่​ต้อง​กระเพื่อม​ไป​แก้​หลาย file

สุดท้าย​มัน​คือ​รูปแบบ​หนึ่ง​ของ Duplicate Code ใน​ระดับ abstraction ไม่ใช่​ระดับ statement — code สอง (หรือ​หลาย) ก้อน​ทำงาน​เหมือนกันเป๊ะ เพียง​แต่​ห่อ​ด้วย​ชื่อ​คนละ​ชื่อ ทำให้​คน​อ่าน code ใหม่​ต้อง​เสีย​เวลา​ไล่​ดู​ว่า​ทำไม​ถึง​มี2 class สำหรับ​งาน​เดียวกัน และ​เสี่ยง​แก้ bug แค่ class เดียว​โดย​ลืม​อีก class

ผลกระทบ​ยัง​ลาม​ไป​ถึง​ชุด​ทดสอบ​ด้วย — เมื่อ​ไม่มี interface กลาง unit test ของ code ผู้​เรียก​ต้อง mock หรือ stub class ทั้ง​สอง​แยก​กัน แทนที่​จะ mock ผ่าน​สัญญา​เดียว และ​เมื่อ code โต​ขึ้น​เรื่อย ๆ จำนวน class “ทาง​เลือก” ที่​ไม่​ได้​อยู่​ใน​สัญญา​เดียวกัน​ก็​จะ​ยิ่ง​เพิ่ม ทำให้การ​เพิ่ม feature ใหม่​แต่ละ​ครั้ง​มี​ต้นทุน​สูง​ขึ้น​เรื่อย ๆ ทั้ง​ที่​ไม่​จำเป็น

สมมติ​มี2 class สำหรับ​แจ้ง​เตือน​ลูกค้า class หนึ่ง​ส่ง​อีเมล อีก class ส่ง SMS ทั้ง​คู่​ทำ​หน้าที่​เดียวกัน​ใน​เชิง​แนวคิด​คือ “ส่ง​ข้อความ​หา​ลูกค้า” แต่​เปิด interface ต่าง​กัน​โดย​สิ้นเชิง

// ก่อน refactor — interface ไม่ตรงกัน แม้ทำงานเดียวกัน
public class EmailSender
{
public void SendEmail(string recipient, string subject, string body)
{
// ... ส่งอีเมลจริง
}
}
public class SmsGateway
{
public void Transmit(string phoneNumber, string message)
{
// ... ส่ง SMS จริง
}
}
// code ผู้เรียกต้องแยกเงื่อนไขตามชนิด object
public void Notify(object channel, Customer customer, string message)
{
if (channel is EmailSender email)
{
email.SendEmail(customer.Email, "แจ้งเตือนคำสั่งซื้อ", message);
}
else if (channel is SmsGateway sms)
{
sms.Transmit(customer.Phone, message);
}
// เพิ่มช่องทางใหม่ทีไร ต้องมาเพิ่ม else if ที่นี่ทุกครั้ง
}

ทาง​แก้​ตาม​แนวทาง​ของ Fowler คือ​หา​ตัว​ส่วนร่วม (common denominator) ของ interface แล้ว​ปรับ​ชื่อ method และ signature ให้​ตรง​กัน ด้วย​การ​ทำ Change Function Declaration (เดิม​เรียก Rename Method) เพื่อ​รวม​ชื่อ method ให้​เหมือน​กัน จาก​นั้น​ทำ Extract Interface เพื่อ​ประกาศ​สัญญา​กลาง​ที่​ทั้ง2 class implement ร่วม​กัน

// หลัง refactor — รวม interface ด้วย Change Function Declaration + Extract Interface
public interface INotificationChannel
{
void Send(string address, string message);
}
public class EmailSender : INotificationChannel
{
public void Send(string address, string message)
{
// ... ส่งอีเมลจริง โดย address คืออีเมล
}
}
public class SmsGateway : INotificationChannel
{
public void Send(string address, string message)
{
// ... ส่ง SMS จริง โดย address คือเบอร์โทร
}
}
// code ผู้เรียกไม่ต้องรู้จักชนิดที่แท้จริงอีกต่อไป
public void Notify(INotificationChannel channel, string address, string message)
{
channel.Send(address, message);
}

หลัง refactor ทั้ง2 class substitute กัน​ได้​จริง​ผ่าน INotificationChannel เดียวกัน เพิ่ม​ช่อง​ทาง​ใหม่ (เช่น push notification) ก็​แค่​เขียน class ใหม่​ที่ implement interface นี้ ไม่​ต้อง​แตะ code ผู้​เรียก​เลย

classDiagram
    INotificationChannel <|.. EmailSender
    INotificationChannel <|.. SmsGateway
    Notify --> INotificationChannel

ถ้า2 class มี​แค่ “บาง​ส่วน” ที่​ซ้ำ​กัน ไม่ใช่​ทั้งหมด ให้​ใช้ Extract Superclass แทน — ดึง​พฤติกรรม​ร่วม​ขึ้น​เป็น class แม่ แล้ว​ให้ class เดิม​ทั้ง​สอง​กลาย​เป็น subclass ของ​มัน ส่วน​กรณี​ที่ class ทาง​เลือก​มา​จาก library ภายนอก​คนละ​ตัว (เช่น SDK ผู้​ให้​บริการ​อีเมล​สอง​เจ้า) ซึ่ง​แก้ source เดิม​ไม่​ได้ ให้​ใช้ Adapter ห่อ​แต่ละ​ตัว​ให้​เปิด interface เดียวกัน​แทนที่​จะ​พยายาม merge class จริง

  • Adapter — ห่อ interface ที่​ต่าง​กัน​ให้​เป็น​หน้าตา​เดียว เมื่อ​แก้ class เดิม​ไม่​ได้
  • Liskov Substitution — เป้าหมาย​ปลายทาง​ของ​การ​รวม interface คือ​ให้ substitute กัน​ได้​จริง
  • Duplicate Code — กลิ่น​พี่น้อง​กัน เพียง​แต่​ซ้ำ​กัน​ที่​ระดับ abstraction แทนที่​จะ​เป็น​ระดับ statement
  • Inconsistency — ชื่อ method/parameter ที่​ไม่​สอดคล้อง​กัน​คือ​ราก​ของ​ปัญหา​นี้
  • Oddball Solution — อีก​กลิ่น​ใน​ตระกูล​เดียวกัน​ที่​เกิด​จาก​การ​แก้​ปัญหา​ซ้ำ​ด้วย​วิธี​ที่​ต่าง​ออก​ไป​โดย​ไม่​จำเป็น
  • DRY — หลักการ​ที่​กลิ่น​นี้​ละเมิด​เมื่อ code ผู้​เรียก​ต้อง​คัด​ลอก​ตรรกะ​แยก​ตาม​ชนิด class