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

Switch Statements

แตก​พฤติกรรม​ตาม​ชนิด/สถานะ​ด้วย switch ที่​ทำซ้ำ​หลาย​ที่

Switch Statements เกิด​เมื่อ code ใช้ switch (หรือ if-else ต่อ​กัน​ยาว ๆ) เพื่อ แตก​พฤติกรรม​ตาม​ชนิด สถานะ หรือ kind ของ object — เช่น​ถาม​ว่า “นี่​เป็น​นก​ยุโรป​หรือ​แอฟริกา” แล้ว​เลือก​ทำ​สิ่ง​ที่​ต่าง​กัน

switch เดียว​เดี่ยว ๆ ไม่ใช่​ปัญหา ปัญหา​เกิด​เมื่อ switch เดียวกัน​บน​ตัวแปร​เดียวกัน​ถู​กก็อป​วาง​ซ้ำ​ใน​หลาย​ที่​ทั่ว​โปรแกรม — ทุก​ครั้ง​ที่​มี​การ​เพิ่ม​ชนิด​ใหม่ (bird type ใหม่, order status ใหม่) นัก​พัฒนา​ต้อง​ไล่​หา​ทุก switch ที่​แตก branch ตาม​ชนิด​นั้น​แล้ว​แก้​พร้อม​กัน​ให้​ครบ ไม่​งั้น​จะ​มี branch ที่​ตกหล่น​แอบ​ซ่อน​เป็น bug

refactoring.guru และ SourceMaking สรุป​สั้น ๆ ว่า “relatively rare use of switch and case operators is one of the hallmarks of object-oriented code” — พูด​อีก​แบบ​คือ เมื่อ​เห็น switch ควร​คิดถึง polymorphism code OO ที่​ดี​มัก​ไม่มี switch กระจาย​อยู่​ทั่วไป เพราะ​งาน​แตก branch ตาม​ชนิด​ถูก​ย้าย​ไป​ให้​แต่ละ subclass รับผิดชอบ​ตัวเอง

ข้อ​ยกเว้น​ที่​ยอมรับ​ได้: switch ที่​อยู่ จุด​เดียว ภายใน factory method (เพื่อ​สร้าง object ที่​ถูก​ชนิด) ไม่​ถือ​เป็นกลิ่น — นั่น​คือ​หน้าที่​ปกติ​ของ Factory Method หรือ Abstract Factory

  • ค้นหา switch หรือ if...else if...else if... ที่​แตก​ตาม type code, enum, หรือ property ที่​บอก​ชนิด/สถานะ​ของ object เดียวกัน
  • switch แบบ​เดียวกัน (บน field เดียวกัน) ปรากฏ​ซ้ำ​ใน​หลาย method หลาย class หรือ​หลาย file
  • เวลา​ต้อง​เพิ่ม​ชนิด​ใหม่​หนึ่ง​ชนิด ต้อง​แก้ file หลาย file ที่​ไม่​เกี่ยวข้อง​กัน​โดยตรง (อาการ​นี้​คือ Shotgun Surgery)
  • มี default: ที่ throw exception หรือ log ว่า “unknown type” เพราะ​ไม่มี​ใคร​มั่นใจ​ว่า branch ครบ​ทุก​กรณี​หรือ​ไม่
  • ตัวแปร​ที่​ใช้​แตก switch เป็น type code แบบ string/int/enum ธรรมดา ไม่ใช่ object ที่​มี​พฤติกรรม​ใน​ตัว (มัก​ไป​คู่​กับ Primitive Obsession)

ผิด​หลัก Open-Closed — ทุก​ครั้ง​ที่​มี​ชนิด​ใหม่ ต้อง​เปิด code เดิม​ที่​ทำงาน​ได้​อยู่​แล้ว​มา​แก้ ขัด​กับ Open-Closed Principle ที่​ต้องการ​ให้​ระบบ​ขยาย​ได้​โดย​ไม่​แก้​ของ​เดิม

ทำซ้ำ​และ​กระจาย — switch เดียวกัน​ที่ก็อป​วาง​หลาย​จุด​คือ Duplicate Code เต็ม​รูปแบบ การ​แก้ไข​หนึ่ง​จุด​ไม่​ได้​แปล​ว่า​อีก​จุด​จะ​ถูก​แก้​ตาม เสี่ยง​ต่อ Shotgun Surgery ทุก​ครั้ง​ที่ requirement เปลี่ยน

เสี่ยง​ตกหล่น branch — ไม่มี compiler ตัว​ไหน​บังคับ​ว่า switch ทุก​จุด​ต้อง​มี case ครบ​ตาม​ชนิด​ที่​มี​อยู่​จริง เพิ่ม enum ใหม่​แล้ว​ลืม​แก้ switch สัก​จุด​หนึ่ง โปรแกรม​จะ​พัง​แบบ​เงียบ ๆ ที่ runtime (ตก​ไป​ที่ default หรือ throw)

ปน​ตรรกะ + สร้าง​กลิ่น​อื่น​ตาม​มา — method ที่​มี switch ยาว ๆ มัก​โต​จน​กลาย​เป็น Long Method และ​เปิด​ช่อง​ให้​แปะ Comments อธิบาย​แต่ละ case แทนที่​จะ​ให้​ชื่อ class/method สื่อ​ความหมาย​เอง

ทดสอบ​ยาก — test ต้อง cover ทุก branch ใน​ทุก​จุด​ที่ก็อป switch ไว้ ยิ่ง​จุด​เยอะ​ยิ่ง test ทับซ้อน​และ​เปราะ

ตัวอย่าง​คลาสสิก​จาก Martin Fowler’s Refactoring catalog: คำนวณ​คำ​อธิบาย​ขนสัตว์ (plumage) ของ​นก​ตาม​ชนิด

code ก่อน (มี switch แตก​ตาม type code และ​มัก​ถู​กก็อป​ไป​คำนวณ numberOfCoconuts หรือ airSpeedVelocity ที่​อื่น​ซ้ำ​อีก):

public string GetPlumage(Bird bird)
{
switch (bird.Type)
{
case BirdType.EuropeanSwallow:
return "average";
case BirdType.AfricanSwallow:
return bird.NumberOfCoconuts > 2 ? "tired" : "average";
case BirdType.NorwegianBlueParrot:
return bird.Voltage > 100 ? "scorched" : "beautiful";
default:
throw new ArgumentException("unknown bird type");
}
}

ปัญหา: ถ้า​มี method GetAirSpeedVelocity(bird) อีก​ตัว​ที่​ต้อง​แตก​ตาม bird.Type เหมือน​กัน code switch ชุด​นี้​จะ​ถู​กก็อป​ซ้ำ และ​ถ้า​เพิ่ม BirdType.IndianRingneckParrot วัน​หนึ่ง ต้อง​ไล่​แก้​ทุก method ที่​มี switch บน BirdType

refactor ด้วย Replace Conditional with Polymorphism (Martin Fowler) — ยก code ของ​แต่ละ branch ไป​เป็น method ใน subclass ของ​มัน​เอง แล้ว​ให้ runtime polymorphism เลือก implementation แทน​การ if/switch:

public abstract class Bird
{
public abstract string Plumage { get; }
}
public class EuropeanSwallow : Bird
{
public override string Plumage => "average";
}
public class AfricanSwallow : Bird
{
public int NumberOfCoconuts { get; set; }
public override string Plumage =>
NumberOfCoconuts > 2 ? "tired" : "average";
}
public class NorwegianBlueParrot : Bird
{
public int Voltage { get; set; }
public override string Plumage =>
Voltage > 100 ? "scorched" : "beautiful";
}

ตอน​เรียก​ใช้ ไม่​ต้อง switch อีก​ต่อ​ไป:

string description = bird.Plumage;

เพิ่ม​นก​ชนิด​ใหม่​ก็​แค่​เพิ่ม subclass ใหม่​หนึ่ง​ตัว โดย​ไม่​ต้อง​แตะ code เดิม​เลย — ตรง​ตาม Open-Closed และ​แต่ละ subclass ยัง swap แทน​กัน​ได้​ตาม Liskov Substitution เพราะ​ทุก​ตัว​สัญญา​แค่ Plumage เหมือน​กัน

เมื่อ “ชนิด” เปลี่ยนแปลง​ระหว่าง​การ​ทำงาน (เช่น order ที่​เปลี่ยน​สถานะ pending → shipped → delivered) ให้​ใช้ State แทน​การ​สร้าง subclass ตายตัว — object เปลี่ยน “ชนิด​พฤติกรรม” ของ​ตัวเอง​ได้​โดย​สลับ state object ภายใน

เมื่อ switch เลือก algorithm/พฤติกรรม​ที่​ผูก​กับ context ภายนอก (ไม่ใช่​ตัว object เอง) ให้​ใช้ Strategy แทน

เมื่อ​หนึ่ง​ใน branch คือ “ไม่มี​อะไร​ทำ” หรือ​เช็ค null ให้​ใช้ Null Object แทน if (obj == null) ที่​กระจาย​ซ้ำ

แผนภาพ​เปรียบเทียบ​โครงสร้าง​ก่อน/หลัง:

flowchart TB
    subgraph Before
        Client1[Client] --> Switch[GetPlumage switch on Type]
        Switch --> A1[case EuropeanSwallow]
        Switch --> A2[case AfricanSwallow]
        Switch --> A3[case NorwegianBlueParrot]
    end
    subgraph After
        Client2[Client] --> Base[Bird.Plumage]
        Base --> B1[EuropeanSwallow.Plumage]
        Base --> B2[AfricanSwallow.Plumage]
        Base --> B3[NorwegianBlueParrot.Plumage]
    end
  • Open-Closed Principle — เหตุผล​หลัก​ที่ switch กระจาย​เป็น​ปัญหา
  • Liskov Substitution — สัญญา​ที่ subclass ที่​แทน switch ต้อง​รักษา​ไว้
  • Strategy — แทน switch เมื่อ “พฤติกรรม” ผูก​กับ context ภายนอก
  • State — แทน switch เมื่อ “ชนิด/สถานะ” เปลี่ยน​ระหว่าง​การ​ทำงาน
  • Null Object — แทน branch ที่​เช็ค null ซ้ำ ๆ
  • Shotgun Surgery — อาการ​ที่​ตาม​มา​เมื่อ switch เดียวกัน​กระจาย​หลาย​จุด