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 หนักขึ้น
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”กรณีที่ 1 — ซ้ำใน class เดียวกัน ใช้ Extract Method
หัวข้อที่มีชื่อว่า “กรณีที่ 1 — ซ้ำใน class เดียวกัน ใช้ Extract Method”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); }}กรณีที่ 2 — ซ้ำใน class ลูกพี่ลูกน้อง ใช้ Pull Up Method
หัวข้อที่มีชื่อว่า “กรณีที่ 2 — ซ้ำใน class ลูกพี่ลูกน้อง ใช้ Pull Up Method”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 ฐานแห่งเดียว ส่วนที่ต่างกันจึงเป็น “จุดขยาย” ที่ชัดเจน
เลือกวิธี refactor ตามตำแหน่งของความซ้ำ
หัวข้อที่มีชื่อว่า “เลือกวิธี refactor ตามตำแหน่งของความซ้ำ”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 ที่แปรผัน” มากกว่าความรู้ตายตัว