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

Conditional Complexity

ตรรกะ​ที่​คุม​ด้วย​ใย​ของ if/else และ switch ที่​ซ้อน​และ​กระจาย

Conditional Complexity คือ​กลิ่น​ที่​ตรรกะ​ของ​โปรแกรม​ถูก​คุม​ด้วย ใย​ของ if/else, conditional ซ้อน​กัน​หลาย​ชั้น หรือ switch ที่​ทำซ้ำ conditional เดี่ยว ๆ ตัว​เดียว​ไม่ใช่​ปัญหา — ปัญหา​อยู่​ที่​มัน​มัก​ลาม type-check เดิม (เช่น “ถ้า​เป็น​นก​ยุโรป… ถ้า​เป็น​นก​แอฟริกา…”) ปรากฏ​ซ้ำ​ใน​หลาย method ของ class หรือ​แม้แต่​กระจาย​ข้าม​หลาย class แต่ละ​ที่​ที่​ปรากฏ​ต้อง sync กันเอง เมื่อ​เพิ่ม​เคส​ใหม่ (เช่น ชนิด​นก​ใหม่) นัก​พัฒนา​ต้อง​ไล่​หา​ทุก​จุด​ที่ switch บน type นั้น​แล้ว​แก้​ที​ละ​ที่ — งาน​ที่​ทั้ง​น่า​เบื่อ​และ​เสี่ยง​พลาด

Robert C. Martin เรียก​กลิ่น​นี้​ใน​หนังสือ Clean Code ว่า “Prefer Polymorphism to If/Else or Switch/Case” และ refactoring.guru/SourceMaking จัด​เป็นกลิ่น​แฝด​กับ Switch Statements — ต่าง​กัน​ที่ Conditional Complexity เน้น​โครงสร้าง if/else ซ้อน​ลึก​และ​กระจาย ใน​ขณะ​ที่ Switch Statements เน้น switch/case ที่​สลับ​บน type code เดียวกัน​ซ้ำ​หลาย​จุด แต่​ราก​ปัญหา​และ​ทาง​แก้​เป็น​เรื่อง​เดียวกัน

  • method มี if/else ซ้อน​กัน 3-4 ชั้น​ขึ้น​ไป จน indentation ไต่​ไป​ทาง​ขวา​เรื่อย ๆ (“arrow code”)
  • นับ cyclomatic complexity ของ method แล้ว​สูง​ผิด​ปกติ (แต่ละ branch/loop เพิ่ม path การ​ทดสอบ 1 เส้น)
  • เจอ pattern if (type == X) ... else if (type == Y) ... else if (type == Z) ที่​ทดสอบ​ค่า​เดียวกัน​ซ้ำ​ใน​หลาย method ของ class เดียวกัน หรือ​แม้แต่​ต่าง class
  • เพิ่ม​เคส​ใหม่​หนึ่ง​เคส แล้ว​ต้อง​ไป​แก้ conditional เดิม​มากกว่า​หนึ่ง​จุด (สัญญาณ​ร่วม​กับ Shotgun Surgery)
  • เงื่อนไข boolean ที่​ตัว​มัน​เอง​ซับซ้อน เช่น !aDate.isBefore(start) && !aDate.isAfter(end) ที่​อ่าน​แล้ว​ต้อง​ตีความ​ความหมาย​ทุก​ครั้ง แทนที่​จะ​มีชื่อ​บอก​เจตนา เช่น isSummer()
  • คอมเมนต์​อธิบาย​ว่า “เคส​นี้​คือ…” ก่อน​แต่ละ branch — มัก​แปล​ว่า code ไม่​ได้​พูด​เจตนา​ด้วย​ตัว​มัน​เอง
  1. อ่าน​ยาก — ผู้​อ่าน​ต้อง​ถือ state ของ​ทุก branch ไว้​ใน​หัว​พร้อม​กัน​เพื่อ​เข้าใจ path เดียว​ที่​ตัวเอง​สนใจ
  2. ทดสอบ​ยาก — จำนวน path ที่​ต้อง​ครอบคลุม​ด้วย test โต​แบบ combinatorial ตาม branch ที่​ซ้อน​กัน (เทียบ Combinatorial Explosion)
  3. ขยาย​ยาก / ผิด Open-Closed Principle — เพิ่ม​เคส​ใหม่ = ต้อง​แก้ code เดิม​ที่​ทำงาน​อยู่​แล้ว แทนที่​จะ​เพิ่ม code ใหม่​เข้าไป​ข้าง ๆ เสี่ยง​พัง​ของ​เดิม​ทุก​ครั้ง​ที่​แก้
  4. กระจาย​และ​ซ้ำ — เมื่อ type-check เดียวกัน​ปรากฏ​หลาย method การ​เพิ่ม/แก้​เคส​หนึ่ง​อัน​ต้อง​ไล่​แก้​หลาย​จุด ลืม​จุด​ใด​จุด​หนึ่ง​ก็​เกิด bug ที่ inconsistent ระหว่าง path
  5. บ่ง​ชี้​ว่า​ตรรกะ​ควร​อยู่​ใน​ข้อมูล ไม่ใช่​ใน code ที่​เรียก​ใช้​ข้อมูล — ตาม Tell-Don’t-Ask การ​ถาม​ว่า “แก​เป็น type ไหน” แล้ว​เลือก​พฤติกรรม​เอง คือ​การ​ทำ​หน้าที่​ที่​ตัว object นั้น​ควร​ทำ​เอง

code ส​เมลล์ — switch บน type code เดิม​ซ้ำ และ​ซ้อน​เงื่อนไข​ภายใน​แต่ละ case:

public enum BirdType
{
European,
African,
NorwegianBlue
}
public class Bird
{
public BirdType Type { get; set; }
public bool IsNailed { get; set; }
public int NumberOfCoconuts { get; set; }
public decimal Voltage { get; set; }
public decimal GetSpeed()
{
switch (Type)
{
case BirdType.European:
return GetBaseSpeed();
case BirdType.African:
return GetBaseSpeed() - GetLoadFactor() * NumberOfCoconuts;
case BirdType.NorwegianBlue:
if (IsNailed)
return 0;
else
return GetBaseSpeedWithVoltage(Voltage);
default:
throw new InvalidOperationException("ไม่รู้จักชนิดนก");
}
}
// สมมติว่ามี method อื่นที่ switch บน Type ซ้ำอีก เช่น GetPlumage(), GetSound() ...
}

ปัญหา: ถ้า​มี method อื่น (GetPlumage, GetSound) ที่ switch บน Type เหมือน​กัน การ​เพิ่ม​ชนิด​นก​ใหม่​ต้อง​ไล่​แก้​ทุก method — สัญญาณ Divergent Change และ Shotgun Surgery พร้อม​กัน

refactor ด้วย Replace Conditional with Polymorphism (Fowler): ย้าย​แต่ละ branch ไป​เป็น method ของ subclass ที่​เกี่ยวข้อง แล้ว​ให้ client เรียก​ผ่าน abstract method เดียว การ​เลือก implementation ที่​ถูกต้อง​เกิด​จาก runtime type ของ object เอง ไม่ใช่​จาก if/switch ที่​เขียน​มือ:

public abstract class Bird
{
public abstract decimal GetSpeed();
protected decimal GetBaseSpeed() => 10m; // ตัวอย่าง
}
public class EuropeanSwallow : Bird
{
public override decimal GetSpeed() => GetBaseSpeed();
}
public class AfricanSwallow : Bird
{
public int NumberOfCoconuts { get; set; }
public override decimal GetSpeed()
=> GetBaseSpeed() - GetLoadFactor() * NumberOfCoconuts;
private decimal GetLoadFactor() => 0.5m;
}
public class NorwegianBlueParrot : Bird
{
public bool IsNailed { get; set; }
public decimal Voltage { get; set; }
public override decimal GetSpeed()
=> IsNailed ? 0 : GetBaseSpeedWithVoltage(Voltage);
private decimal GetBaseSpeedWithVoltage(decimal voltage) => GetBaseSpeed() * voltage;
}
// เรียกใช้: ไม่มี if/switch เหลืออยู่เลย
decimal speed = bird.GetSpeed();

เพิ่ม​ชนิด​นก​ใหม่​ใน​อนาคต = เพิ่ม class ใหม่1 class ไม่​ต้อง​แตะ code เดิม​ที่​ทำงาน​อยู่​แล้ว (Open-Closed) และ​ไม่มี​จุด​ไหน​อื่น​ที่​ต้อง sync

ตัวอย่าง​เสริม: Decompose Conditional สำหรับ​เงื่อนไข​ที่​ซับซ้อน​ใน​ตัวเอง

หัวข้อ​ที่​มีชื่อ​ว่า “ตัวอย่าง​เสริม: Decompose Conditional สำหรับ​เงื่อนไข​ที่​ซับซ้อน​ใน​ตัวเอง”

บาง​ครั้ง​ปัญหา​ไม่ใช่​หลาย​เคส​ของ type แต่​เป็น นิพจน์​เงื่อนไข​เดียว​ที่​อ่าน​ไม่รู้เรื่อง ทาง​แก้​คือ Decompose Conditional (Fowler) — ดึง​เงื่อนไข​และ branch ออก​เป็น method ที่​ตั้ง​ชื่อ​บอก​เจตนา:

// ก่อน: อ่านแล้วต้องตีความว่านี่คือการเช็คอะไร
if (!aDate.IsBefore(plan.SummerStart) && !aDate.IsAfter(plan.SummerEnd))
charge = quantity * plan.SummerRate;
else
charge = quantity * plan.RegularRate + plan.RegularServiceCharge;
// หลัง: ชื่อ method บอกเจตนาแทนตรรกะดิบ
charge = IsSummer(aDate, plan)
? SummerCharge(quantity, plan)
: RegularCharge(quantity, plan);

เมื่อ conditional ซ้อน​ลึก​จาก​การ​เช็ค “เคส​พัง/เคส​พิเศษ” ก่อน​เข้า happy path ให้​ใช้ Guard Clause คลี่ nested if ให้​แบน​ลง แทนที่​จะ​ซ้อน else ไป​เรื่อย ๆ

flowchart TD
    A[Client เรียก GetSpeed] --> B[Bird abstract type]
    B --> C[EuropeanSwallow]
    B --> D[AfricanSwallow]
    B --> E[NorwegianBlueParrot]
    C --> F[คืนค่าตามกติกาของตัวเอง]
    D --> F
    E --> F
  • Switch Statements — กลิ่น​แฝด​ที่​เน้น switch/case บน type code ซ้ำ​หลาย​จุด
  • Guard Clause — คลี่ nested conditional ที่​เช็ค​เคส​พิเศษ​ก่อน happy path
  • State — แทนที่ conditional ที่​ขึ้น​กับ​สถานะ​ภายใน​ด้วย​ลำดับ​ชั้น class ที่​สลับ​กันเอง
  • Strategy — สลับ algorithm/พฤติกรรม​ได้​โดย​ไม่​ต้อง​มี conditional เลือก algorithm
  • Open-Closed Principle — เป้าหมาย​ปลายทาง​ของ​การ​คลี่ conditional ด้วย polymorphism
  • Shotgun Surgery — อาการ​ที่​ตาม​มา​เมื่อ conditional เดิม​กระจาย​หลาย​จุด​แล้ว​ต้อง​แก้​พร้อม​กัน