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

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 ไม่​ได้​แยก​สอง​แกน​ออก​จาก​กัน​ให้​เห็น​ชัด

สมมติ​มี feature export รายงาน โดย​ต้อง​รองรับ รูปแบบ file (Csv, Json, Xml) คูณ​กับ ปลายทาง (Email, Ftp) — สอง​แกน​ที่​เป็น​อิสระ​จาก​กัน​โดย​ธรรมชาติ แต่ code กลับ​เข้า​รหัส​รวม​กัน​เป็น method ต่อ​คู่:

// กลิ่น: method แยกตามคู่ (Format x Destination) ครบทุกชุด — 3 format x 2 destination = 6 method
public 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 — ญาติ​ที่​ยัง​แยก​เป็น​สอง​ลำดับ​ชั้น ต่าง​จาก​กลิ่น​นี้​ที่​พับ​รวม​เป็น​หนึ่ง​เดียว