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 ไม่ได้พูดเจตนาด้วยตัวมันเอง
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”- อ่านยาก — ผู้อ่านต้องถือ state ของทุก branch ไว้ในหัวพร้อมกันเพื่อเข้าใจ path เดียวที่ตัวเองสนใจ
- ทดสอบยาก — จำนวน path ที่ต้องครอบคลุมด้วย test โตแบบ combinatorial ตาม branch ที่ซ้อนกัน (เทียบ Combinatorial Explosion)
- ขยายยาก / ผิด Open-Closed Principle — เพิ่มเคสใหม่ = ต้องแก้ code เดิมที่ทำงานอยู่แล้ว แทนที่จะเพิ่ม code ใหม่เข้าไปข้าง ๆ เสี่ยงพังของเดิมทุกครั้งที่แก้
- กระจายและซ้ำ — เมื่อ type-check เดียวกันปรากฏหลาย method การเพิ่ม/แก้เคสหนึ่งอันต้องไล่แก้หลายจุด ลืมจุดใดจุดหนึ่งก็เกิด bug ที่ inconsistent ระหว่าง path
- บ่งชี้ว่าตรรกะควรอยู่ในข้อมูล ไม่ใช่ใน code ที่เรียกใช้ข้อมูล — ตาม Tell-Don’t-Ask การถามว่า “แกเป็น type ไหน” แล้วเลือกพฤติกรรมเอง คือการทำหน้าที่ที่ตัว object นั้นควรทำเอง
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”ตัวอย่างคลาสสิก: ความเร็วนกตามชนิด
หัวข้อที่มีชื่อว่า “ตัวอย่างคลาสสิก: ความเร็วนกตามชนิด”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 เดิมกระจายหลายจุดแล้วต้องแก้พร้อมกัน