Combinatorial Explosion
การเพิ่มตัวแปรใหม่ทำให้จำนวน class/method ทวีคูณแทนที่จะโตเชิงเส้น
กลิ่นนี้คืออะไร
หัวข้อที่มีชื่อว่า “กลิ่นนี้คืออะไร”คำว่า combinatorial explosion ยืมมาจากคณิตศาสตร์เชิงผสม (combinatorics) — ถ้ามีตัวแปร boolean n ตัว จำนวนสถานะที่เป็นไปได้คือ 2 กำลัง n และถ้าตัวแปรแต่ละตัวมีค่าได้ Z แบบ จำนวนสถานะจะเป็น Z กำลัง n เมื่อยืมแนวคิดนี้มาอธิบาย code สิ่งที่เกิดขึ้นคือ: design มี “แกน” (axis) ของการเปลี่ยนแปลงมากกว่าหนึ่งแกน แล้วแต่ละแกนกลับถูกเข้ารหัสรวมกันเป็น class หรือ method เดียว ทำให้เมื่อแกนหนึ่งมี m ตัวเลือกและอีกแกนมี n ตัวเลือก จำนวน class/method ที่ต้องดูแลกลายเป็น m × n แทนที่จะเป็น m + n
William C. Wake อธิบายไว้ใน Refactoring Workbook (2003) — หนึ่งใน code smell ใหม่ที่ไม่ได้อยู่ใน catalog ดั้งเดิมของ Fowler — ว่า “a lot of code does almost the same thing” คำว่า “almost” ตรงนี้สำคัญมาก เพราะ code แต่ละชุดไม่ได้เหมือนกันเป๊ะ (ถ้าเหมือนเป๊ะจะเป็น Duplicate Code ธรรมดา) แต่ต่างกันแค่ “ชนิดหรือปริมาณของข้อมูล/object” ที่ใช้ ผลคือจำนวนเคสที่ต้องครอบคลุมทุกเส้นทาง (path) พุ่งขึ้นแบบทวีคูณ ไม่ใช่เชิงเส้น
Wake ยังชี้ว่ากลิ่นนี้เป็นญาติกับ Parallel Inheritance Hierarchies — ต่างกันตรงที่ parallel inheritance คือมีสองลำดับชั้นแยกกันที่ต้องขยายคู่กันเสมอ ส่วน combinatorial explosion คือ “พับทุกอย่างรวมเป็นลำดับชั้นเดียว” แล้วสร้าง class ย่อยหรือ method สำหรับทุกคู่ผสมที่เป็นไปได้
วิธีสังเกต
หัวข้อที่มีชื่อว่า “วิธีสังเกต”- ชื่อ class/method ผสมสองแกนเข้าด้วยกันตรงๆ เช่น
CsvToEmailExporter,JsonToFtpExporter,XmlToEmailExporter— เห็น pattern คำผสม (Format × Destination, Type × Rule, Role × Action) ซ้ำๆ - เพิ่มตัวเลือกใหม่หนึ่งค่าในแกนเดียว (เช่น เพิ่ม format หนึ่งชนิด) แล้วต้องเขียน class/method ใหม่หลายตัวพร้อมกัน (หนึ่งตัวต่อทุกค่าที่มีอยู่แล้วในแกนที่สอง)
- class หนึ่งมี method จำนวนมากที่ทำ query หรือ transform คล้ายกันมาก ต่างกันแค่เงื่อนไข/parameter ที่ hardcode ไว้ — สัญญาณเดียวกับที่ Joshua Kerievsky อธิบายใน Refactoring to Patterns ว่า “ยิ่งต้องรองรับ query แบบพิเศษมากเท่าไร ก็ยิ่งต้องสร้าง query method มากเท่านั้น”
- โครงสร้าง if/else หรือ switch ซ้อนสองชั้น (แกนนอก × แกนใน) ที่ทำ logic คล้ายกันในแต่ละกิ่ง แค่สลับ operation
- ทีมเริ่ม “กลัว” การเพิ่มแกนใหม่ เพราะรู้ว่าต้องแตะ file เดิมจำนวนมากและเขียน class ใหม่เป็นสิบ
- test suite มีเทสจำนวน m×n ชุดที่โครงสร้างเหมือนกันทุกอย่างยกเว้นค่าคงที่สองสามตัว
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”เติบโตแบบไม่ยั่งยืน — domain จริงมักโตแบบบวก (เพิ่มทีละ format ใหม่ ทีละ destination ใหม่) แต่ code ที่มีกลิ่นนี้โตแบบคูณ ถ้าวันนี้มี 3 format × 3 destination = 9 class พอเพิ่ม format ที่ 4 และ destination ที่ 4 จะกลายเป็น 16 class ทันที ต้นทุนของทุก feature ใหม่จึงแพงขึ้นเรื่อยๆ ไม่คงที่
ละเมิด DRY และ OCP พร้อมกัน — code แต่ละชุดเกือบเหมือนกัน (ผิด DRY) และการเพิ่มความสามารถใหม่ต้องไปแก้/เพิ่มจุดเดิมจำนวนมากแทนที่จะขยายจากภายนอก (ผิด OCP)
ต้นทุน bug คูณสอง — เจอ bug ใน logic ที่ซ้ำกันหนึ่งจุด ต้องไล่แก้ในทุกคอมบิเนชันที่ก็อป code นั้นไป เสี่ยงแก้ไม่ครบ
ทดสอบยากขึ้นแบบทวีคูณ — ถ้าจะเทสให้ครอบคลุมทุกเส้นทาง จำนวนเทสเคสก็ต้องโตแบบ m×n ตามไปด้วย ทีมส่วนใหญ่จะเทสแค่บางคู่แล้วปล่อยคู่ที่เหลือให้เสี่ยงดวง
อ่านยาก แก้ผิดง่าย — คนอ่านต้องแยกเองว่าส่วนไหนของ method เป็น “ตัวแปรตามแกนที่ 1” ส่วนไหนเป็น “ตัวแปรตามแกนที่ 2” เพราะ code ไม่ได้แยกสองแกนออกจากกันให้เห็นชัด
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”สมมติมี feature export รายงาน โดยต้องรองรับ รูปแบบ file (Csv, Json, Xml) คูณกับ ปลายทาง (Email, Ftp) — สองแกนที่เป็นอิสระจากกันโดยธรรมชาติ แต่ code กลับเข้ารหัสรวมกันเป็น method ต่อคู่:
// กลิ่น: method แยกตามคู่ (Format x Destination) ครบทุกชุด — 3 format x 2 destination = 6 methodpublic class ReportExporter{ public void ExportCsvToEmail(Report report, string emailAddress) { var payload = BuildCsv(report); SendEmail(emailAddress, payload); }
public void ExportCsvToFtp(Report report, string ftpPath) { var payload = BuildCsv(report); UploadFtp(ftpPath, payload); }
public void ExportJsonToEmail(Report report, string emailAddress) { var payload = BuildJson(report); SendEmail(emailAddress, payload); }
public void ExportJsonToFtp(Report report, string ftpPath) { var payload = BuildJson(report); UploadFtp(ftpPath, payload); }
public void ExportXmlToEmail(Report report, string emailAddress) { var payload = BuildXml(report); SendEmail(emailAddress, payload); }
public void ExportXmlToFtp(Report report, string ftpPath) { var payload = BuildXml(report); UploadFtp(ftpPath, payload); }
// เพิ่ม destination ใหม่ เช่น S3 -> ต้องเพิ่มอีก 3 method (CsvToS3, JsonToS3, XmlToS3) // เพิ่ม format ใหม่ เช่น Yaml -> ต้องเพิ่มอีก 2 method (YamlToEmail, YamlToFtp) private string BuildCsv(Report report) => "..." /* แปลงเป็น CSV */; private string BuildJson(Report report) => "..." /* แปลงเป็น JSON */; private string BuildXml(Report report) => "..." /* แปลงเป็น XML */; private void SendEmail(string address, string payload) { /* ส่งอีเมล */ } private void UploadFtp(string path, string payload) { /* อัปโหลดผ่าน FTP */ }}ตัวเลข 6 method ยังพอทนได้ แต่สังเกตว่ามันคือ 3×2 ไม่ใช่ 3+2 — และมันจะโตแบบคูณต่อไปทุกครั้งที่เพิ่มค่าใหม่ในแกนใดแกนหนึ่ง
การ refactor: แยกสองแกนออกจากกันด้วย Bridge — แกน “จะแปลงข้อมูลยังไง” (format) กับแกน “จะส่งไปไหน” (destination) กลายเป็น interface คนละตัว แล้วประกอบ (compose) เข้าด้วยกันตอน runtime แต่ละแกนภายในก็คือการใช้ Strategy เพื่อสลับ implementation ได้อย่างอิสระ:
// refactor ด้วย Bridge: แยกแกน Format ออกจากแกน Destination// เพิ่ม format ใหม่ = เพิ่ม 1 class, เพิ่ม destination ใหม่ = เพิ่ม 1 class (บวกกัน ไม่คูณกัน)public interface IReportFormatter{ string Format(Report report);}
public class CsvFormatter : IReportFormatter{ public string Format(Report report) => "..." /* แปลงเป็น CSV */;}
public class JsonFormatter : IReportFormatter{ public string Format(Report report) => "..." /* แปลงเป็น JSON */;}
public class XmlFormatter : IReportFormatter{ public string Format(Report report) => "..." /* แปลงเป็น XML */;}
public interface IReportDestination{ void Send(string payload, string target);}
public class EmailDestination : IReportDestination{ public void Send(string payload, string target) { /* ส่งอีเมล */ }}
public class FtpDestination : IReportDestination{ public void Send(string payload, string target) { /* อัปโหลดผ่าน FTP */ }}
// ตัว "สะพาน" ที่ประกอบสองแกนเข้าด้วยกัน โดยไม่รู้จัก class รูปธรรมของอีกฝั่งเลยpublic class ReportExporter{ private readonly IReportFormatter _formatter; private readonly IReportDestination _destination;
public ReportExporter(IReportFormatter formatter, IReportDestination destination) { _formatter = formatter; _destination = destination; }
public void Export(Report report, string target) { var payload = _formatter.Format(report); _destination.Send(payload, target); }}
// ใช้งาน: ผสมแกนได้อิสระโดยไม่ต้องเขียน class ใหม่ต่อคู่var exporter = new ReportExporter(new JsonFormatter(), new FtpDestination());exporter.Export(report, "/reports/out.json");ตอนนี้เพิ่ม format ใหม่ = เพิ่ม IReportFormatter 1 class เพิ่ม destination ใหม่ = เพิ่ม IReportDestination 1 class ไม่มีจุดไหนต้องคูณกันอีก:
classDiagram
class ReportExporter
class IReportFormatter
class CsvFormatter
class JsonFormatter
class XmlFormatter
class IReportDestination
class EmailDestination
class FtpDestination
ReportExporter o-- IReportFormatter
ReportExporter o-- IReportDestination
IReportFormatter <|.. CsvFormatter
IReportFormatter <|.. JsonFormatter
IReportFormatter <|.. XmlFormatter
IReportDestination <|.. EmailDestination
IReportDestination <|.. FtpDestination
หมายเหตุ: IReportFormatter และ IReportDestination คือ interface ทั้งคู่ (ตัดออกในไดอะแกรม สื่อผ่านชื่อและคำอธิบายแทน)
อีกกรณีที่พบบ่อยคือ “query method explosion” — class มี method query เฉพาะทางเพิ่มขึ้นเรื่อยๆ ทีละตัวต่อทุกชุดเงื่อนไขที่ผู้ใช้ต้องการ (FindActiveCustomersInRegion, FindActiveCustomersOverAge, FindInactiveCustomersInRegion, …) Joshua Kerievsky ใน Refactoring to Patterns แนะนำให้แทนที่ด้วยภาษาย่อยสำหรับประกอบเงื่อนไข เช่น Specification หรือ Interpreter แทนที่จะสร้าง method ใหม่ทุกครั้งที่มีชุดเงื่อนไขใหม่
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Strategy — แยกแต่ละแกนของความแปรผันให้สลับ implementation ได้อิสระ
- Bridge — ประกอบสองแกน (abstraction × implementation) เข้าด้วยกันแทนการคูณเป็น class ย่อย
- Specification — แก้ query method explosion ด้วยการประกอบเงื่อนไขแทนสร้าง method ใหม่ทุกครั้ง
- Open-Closed — เป้าหมายปลายทางของการ refactor: ขยายแกนใหม่ได้โดยไม่แก้ของเดิม
- Duplicate Code — ญาติใกล้ชิด กลิ่นนี้คือ duplicate code แบบที่ “เกือบ” เหมือนกันเพราะผสมสองแกน
- Parallel Inheritance Hierarchies — ญาติที่ยังแยกเป็นสองลำดับชั้น ต่างจากกลิ่นนี้ที่พับรวมเป็นหนึ่งเดียว