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

Duplicate Code

code เหมือน​หรือ​คล้าย​กัน​ปรากฏ​มากกว่า​หนึ่ง​ที่ — กลิ่น​พื้นฐาน​ที่สุด

Duplicate Code คือ​การ​มี code ที่​เหมือน​หรือ​คล้าย​เชิง​โครงสร้าง​ใน​หลาย​ที่ ถือ​เป็น กลิ่น​พื้นฐาน​ที่สุด ใน​บรรดา code smells ทั้งหมด — Martin Fowler ถึง​กับ​จัด​ให้​เป็นกลิ่น​อันดับ​หนึ่ง​ใน​หนังสือ Refactoring ของ​เขา เพราะ​แทบ​ทุก smell อื่น ๆ (เช่น Long Method, Divergent Change, Shotgun Surgery) มัก​ลงเอย​ที่​การ​มี​ความ​รู้​เดียวกัน​ถูก​เขียน​ซ้ำ​ใน​หลาย​จุด

ทุก​ความ​ซ้ำ​หมาย​ถึง​การ​เปลี่ยนแปลง​ใน​อนาคต​ต้องหา เข้าใจ และ​แก้​หลาย​จุด​พร้อม​กัน หาก​พลาด​จุด​ใด​จุด​หนึ่ง ระบบ​ก็​จะ​ไม่​สอดคล้อง​กัน (inconsistent) และ​ถ้า logic เดิม​มี bug อยู่ bug นั้น​ก็​จะ​ถูก​คัด​ลอก​ไป​พร้อม​กัน​หลาย​ที่​ด้วย — รวม​ถึง​ช่อง​โหว่​ด้าน​ความ​ปลอดภัย​ก็​เช่น​กัน ถ้า code ที่​มี​ช่อง​โหว่​ถูกก็อปปี้​ไป​ที่​อื่น การ patch ที่​จุด​เดียว​ก็​ไม่​ช่วย​จุด​ที่​เหลือ

ความ​ซ้ำ​มี​สอง​ระดับ​ที่​ควร​แยก​ให้​ออก:

  • ซ้ำ​แบบ​ตรง​ตัว (exact clone) — code ทุก​ตัว​อักษร​เหมือน​กัน มอง​เห็น​ง่าย เครื่องมือ static analysis จับ​ได้​ตรง​ไป​ตรง​มา
  • ซ้ำ​เชิง​โครงสร้าง (structural / semantic clone) — code หน้าตา​ต่าง​กัน​เล็กน้อย (ชื่อ​ตัวแปร ลำดับ​เงื่อนไข) แต่​ทำ​หน้าที่​เดียวกัน ตรวจ​จับ​ยาก​กว่า​มาก เพราะ​ต้อง​เข้าใจ​เจตนา​ของ code ไม่ใช่​แค่​เทียบ string
  • ก็อปปี้​แล้ว​วาง (copy-paste) แล้ว​แก้​เพียง​เล็กน้อย เช่น เปลี่ยน​ชื่อ​ตัวแปร​หรือ​ค่า​คงที่​หนึ่ง​ตัว — สัญญาณ​คลาสสิก​ของ Copy-Paste Programming
  • เมื่อ​แก้ bug หรือ​เพิ่ม​เงื่อนไข​ใหม่ ต้อง​จำ​ได้​เอง​ว่า​มี​อีก​กี่​ที่​ที่​ต้อง​แก้​ตาม ไม่มี compiler หรือ type system ช่วย​เตือน
  • method สอง​ตัว​ใน class เดียวกัน​มี block คำ​สั่ง​ที่​เหมือนกันเป๊ะ
  • class ลูกพี่​ลูกน้อง (sibling subclasses) มี constructor หรือ method ที่​ทำ​สิ่ง​เดียวกัน​เกือบ​ทั้งหมด ต่าง​กัน​แค่​รายละเอียด​ปลีกย่อย
  • class ที่​ไม่มี​ความ​สัมพันธ์​กัน​เลย​กลับ​มี method utility หน้าตา​เหมือน​กัน เพราะ​ต่าง​คน​ต่าง​เขียน​แก้​ปัญหา​เดียวกัน​โดย​ไม่รู้​ว่า​มี​อยู่​แล้ว — มัก​เกิด​เมื่อ​ทีม​ทำงาน​คู่​ขนาน​กัน​แบบ​ไม่​สื่อสาร
  • นิพจน์​เงื่อนไข (if/switch) หลาย​จุด​มี​เนื้อหา​บาง​ส่วน​ซ้ำ​กัน​ใน​ทุก​กิ่ง (branch) ทั้ง​ที่​เงื่อนไข​ต่าง​กัน
  • ต้นทุน​การ​บำรุง​รักษา​คูณ​สอง (หรือ​คูณ N) — ทุก​การ​เปลี่ยนแปลง requirement ต้องตา​มหา​ทุก​สำเนา​ของ logic แล้ว​แก้​ให้​ครบ ยิ่ง​สำเนา​มาก ยิ่ง​เสี่ยง​ตกหล่น
  • ความ​ไม่​สอดคล้อง​ของ​ระบบ — เมื่อ​แก้​ไม่​ครบ​ทุก​จุด พฤติกรรม​ของ​ระบบ​จะ​แตก​ต่าง​กัน​ไป​ตาม​เส้นทาง code ที่​ถูก​เรียก ทำให้​เกิด bug ที่​จับ​ยาก​เพราะ “บาง​ที่​ทำงาน​ถูก บาง​ที่​ทำงาน​ผิด”
  • bug และ​ช่อง​โหว่​กระจาย​ตัว — ถ้า​จุด​ต้นทาง​มี​ข้อ​ผิดพลาด​เชิง​ตรรกะ​หรือ​ช่อง​โหว่​ด้าน​ความ​ปลอดภัย ทุก​สำเนา​ก็​รับ​ผิดพลาด​นั้น​ไป​ด้วย และ​การ​แก้​ที่​จุด​เดียว​จะ​ทำให้​เข้าใจ​ผิด​ว่า​ปัญหา​ถูก​แก้​แล้ว​ทั้ง​ระบบ
  • อ่าน​และ​ทำความ​เข้าใจ​ยาก​ขึ้น — ผู้​อ่าน code ต้อง​เสีย​เวลา​ไล่​เทียบ​ว่า​สำเนา​แต่ละ​ชุด​เหมือน​กัน​จริง​หรือ​มี​ความ​ต่าง​ที่​ตั้งใจ​ไว้ ซึ่ง​เป็น​ภาระ​ทาง​ปัญญา (cognitive load) ที่​ไม่​จำเป็น
  • ตัว​ชี้​วัด​ซอฟต์แวร์​แย่​ลง — จำนวน​บรรทัด code (LOC), cyclomatic complexity, และ coupling ที่​แฝง​อยู่​ใน​ความ​ซ้ำ ล้วน​เพิ่ม​ขึ้น​โดย​ไม่​ได้​เพิ่ม​คุณค่า​ทาง​ธุรกิจ ส่ง​ผล​ให้ compile ช้า​ลง​และ code review หนัก​ขึ้น

code 2 method คำนวณ​ราคา​รวม​พร้อม​ภาษี logic เดียวกัน​ถูก​เขียน​ซ้ำ:

public class OrderService
{
public decimal CalculateDomesticTotal(Order order)
{
decimal subtotal = order.Items.Sum(i => i.Price * i.Quantity);
decimal tax = subtotal * 0.07m;
decimal total = subtotal + tax;
return Math.Round(total, 2, MidpointRounding.AwayFromZero);
}
public decimal CalculateExportTotal(Order order)
{
decimal subtotal = order.Items.Sum(i => i.Price * i.Quantity);
decimal tax = subtotal * 0.0m; // สินค้าส่งออกไม่เสียภาษี
decimal total = subtotal + tax;
return Math.Round(total, 2, MidpointRounding.AwayFromZero);
}
}

หลัง​ใช้ Extract Method ดึง​ส่วน​ที่​ซ้ำ​ออก​มา​เป็น method เดียว แล้ว​ให้​ทั้ง​สอง​กรณี​เรียก​ใช้ โดย​ส่ง​เฉพาะ​สิ่ง​ที่​ต่าง​กัน​จริง (อัตรา​ภาษี) เป็น parameter:

public class OrderService
{
public decimal CalculateDomesticTotal(Order order) =>
CalculateTotal(order, taxRate: 0.07m);
public decimal CalculateExportTotal(Order order) =>
CalculateTotal(order, taxRate: 0.0m); // สินค้าส่งออกไม่เสียภาษี
private decimal CalculateTotal(Order order, decimal taxRate)
{
decimal subtotal = order.Items.Sum(i => i.Price * i.Quantity);
decimal tax = subtotal * taxRate;
decimal total = subtotal + tax;
return Math.Round(total, 2, MidpointRounding.AwayFromZero);
}
}

EmailNotifier และ SmsNotifier ต่าง​ก็​สืบทอด​จาก Notifier แต่​ทั้ง​คู่​เขียน logic ตรวจสอบ​ก่อน​ส่ง​ซ้ำ​กัน​เป๊ะ:

public abstract class Notifier
{
public abstract void Send(string recipient, string message);
}
public class EmailNotifier : Notifier
{
public override void Send(string recipient, string message)
{
if (string.IsNullOrWhiteSpace(recipient))
throw new ArgumentException("ต้องระบุผู้รับ");
if (string.IsNullOrWhiteSpace(message))
throw new ArgumentException("ข้อความห้ามว่าง");
SendEmail(recipient, message);
}
private void SendEmail(string recipient, string message) { /* ... */ }
}
public class SmsNotifier : Notifier
{
public override void Send(string recipient, string message)
{
if (string.IsNullOrWhiteSpace(recipient))
throw new ArgumentException("ต้องระบุผู้รับ");
if (string.IsNullOrWhiteSpace(message))
throw new ArgumentException("ข้อความห้ามว่าง");
SendSms(recipient, message);
}
private void SendSms(string recipient, string message) { /* ... */ }
}

ใช้ Pull Up Method ย้าย logic ตรวจสอบ​ที่​เหมือน​กัน​ขึ้น​ไป​ไว้​ใน class ฐาน แล้ว​ให้ class ลูก​ทำ​เฉพาะ​ส่วน​ที่​ต่าง​จริง ๆ ผ่าน template method:

public abstract class Notifier
{
public void Send(string recipient, string message)
{
if (string.IsNullOrWhiteSpace(recipient))
throw new ArgumentException("ต้องระบุผู้รับ");
if (string.IsNullOrWhiteSpace(message))
throw new ArgumentException("ข้อความห้ามว่าง");
Deliver(recipient, message);
}
protected abstract void Deliver(string recipient, string message);
}
public class EmailNotifier : Notifier
{
protected override void Deliver(string recipient, string message) { /* ส่งอีเมล */ }
}
public class SmsNotifier : Notifier
{
protected override void Deliver(string recipient, string message) { /* ส่ง SMS */ }
}

รูปแบบ​นี้​คือ​การนำ​เทคนิค Form Template Method มา​ใช้​ร่วม​กับ​แนวคิด Strategy — ส่วน​ที่​เหมือน​กัน​อยู่​ที่ class ฐาน​แห่ง​เดียว ส่วน​ที่​ต่าง​กัน​จึง​เป็น “จุด​ขยาย” ที่​ชัดเจน

flowchart TD
    Start[พบ Duplicate Code]
    Same[ซ้ำใน class เดียวกัน]
    Sibling[ซ้ำใน class ลูกพี่ลูกน้อง]
    Unrelated[ซ้ำใน class ที่ไม่เกี่ยวข้องกัน]
    Cond[ซ้ำในนิพจน์เงื่อนไข]

    ExtractMethod[ExtractMethod]
    PullUp[PullUpMethod หรือ PullUpField]
    Template[FormTemplateMethod]
    ExtractSuper[ExtractSuperclass]
    ExtractClass[ExtractClass]
    Consolidate[ConsolidateConditional]

    Start --> Same --> ExtractMethod
    Start --> Sibling --> PullUp
    Sibling --> Template
    Start --> Unrelated --> ExtractSuper
    Unrelated --> ExtractClass
    Start --> Cond --> Consolidate

เมื่อ class ที่​มี​ความ​ซ้ำ​ไม่มี​ความ​สัมพันธ์​กัน​เลย​และ​สร้าง​ลำดับ​ชั้น​ร่วม​ไม่​ได้ ให้​ใช้ Extract Class ดึง​ส่วน​ที่​ซ้ำ​ออก​มา​เป็น class ใหม่​ที่​ทั้ง​สอง​ฝั่ง​เรียก​ใช้​แทน — วิธี​นี้​ยัง​เปิด​ทาง​ให้​ปรับ​เป็น Strategy ได้​ถ้า​จุด​ที่​ต่าง​กัน​คือ “algorithm” ไม่ใช่​แค่ “ข้อมูล”

  • Don’t Repeat Yourself — หลักการ​ที่​พูด​ถึง​กลิ่น​นี้​โดยตรง รวบ​ความ​รู้​หนึ่ง​เรื่อง​ให้​มี​แหล่ง​เดียว
  • Once and Only Once — กฎ​การ​เขียน code ที่​ผลักดัน​ให้​ทุก​ความ​รู้​ปรากฏ​เพียง​ครั้ง​เดียว
  • Copy-Paste Programming — antipattern ที่​เป็น​สาเหตุ​ตรง​ของ Duplicate Code ส่วน​ใหญ่
  • Divergent Change — อาการ​ที่​มัก​ตาม​มา​เมื่อ​ความ​ซ้ำ​ทำให้ class เดียว​ต้อง​แก้​ด้วย​เหตุผล​หลาย​อย่าง
  • Shotgun Surgery — ผลกระทบ​ตรง​ข้าม​ของ​ความ​ซ้ำ คือ​การ​เปลี่ยน​เรื่อง​เดียว​ต้อง​กระจาย​แก้​หลาย class
  • Strategy — ทางออก​เมื่อ​ความ​ซ้ำ​คือ “algorithm ที่​แปรผัน” มากกว่า​ความ​รู้​ตายตัว