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

Spaghetti Code

code พัน​กัน​ยุ่งเหยิง​จน​ตาม​การ​ทำงาน​แทบ​ไม่​ได้

Spaghetti Code หมาย​ถึง source code ที่​มี control flow พัน​กัน​ยุ่งเหยิง เหมือน​เส้น​สปา​เก็ตตี้​ใน​จาน — บิด​ไป​มา​จน​แกะ​ไม่​ออกว่า​อะไร​เรียก​อะไร ทำงาน​ตาม​ลำดับ​ไหน ชื่อ​นี้​ถูก​ใช้​ครั้ง​แรก ๆ ใน​ช่วง​ต้น​ทศวรรษ 1970 เมื่อ Martin Hopkins เขียน​ไว้​ใน​ปี 1972 ว่า​แรง​จูงใจ​หลัก​ของ​การ​เลิก​ใช้ GOTO คือ​ความ​หวัง​ว่า​โปรแกรม​ที่​ได้​จะ​ไม่ “หน้าตา​เหมือน​ชาม​สปา​เก็ตตี้” อีก​ต่อ​ไป

ใน​ภาษา​โปรแกรม​ยุค​เก่า ตัวการ​หลัก​คือ statement GOTO ที่​กระโดด​ไป​มาระหว่าง​บรรทัด​โดย​ไม่มี​โครงสร้าง — ไม่มี entry/exit point ที่​ชัดเจน ไล่​อ่าน​จาก​บน​ลง​ล่าง​ไม่​ได้ ต้อง​กระโดด​ตาม​ลูกศร​ไป​ทั่ว​ทั้ง file ใน​ภาษา​ยุค​ใหม่​ที่​ไม่มี GOTO แล้ว spaghetti code ก็​ยัง​เกิด​ได้​ผ่าน​กลไก​อื่น เช่น

  • method ยาว​เป็น​ร้อย​บรรทัด​ที่​ผสม​ทุก​ความ​รับผิดชอบ​ไว้​ใน​ที่​เดียว (ดู Long Method)
  • เงื่อนไข if/else หรือ switch ซ้อน​กัน​หลาย​ชั้น​จน​อ่าน​ไม่​ออกว่า​ทาง​ไหน​ไป​ทาง​ไหน
  • การ include/นำ​เข้า file ที่​พัน​กัน​เป็น​วงจร (circular dependency) จน​แยก​ไม่​ออกว่า module ไหน​พึ่งพา module ไหน
  • code เชิง​วัตถุ​ที่​เขียน​แบบ procedural — เรียก method ข้าม object ไป​มา​แบบ​สุ่ม ไม่​ใช้ polymorphism หรือ​การ​ห่อ​หุ้ม​ใด ๆ เลย

ราก​ของ​ปัญหา​มัก​ไม่ใช่​ความ​ตั้งใจ​ร้าย แต่​คือ​การ “เติม​ที​ละ​บรรทัด” ต่อ​เนื่อง​กัน​เป็น​เวลา​นาน — เพิ่ม field ใหม่​ใน​นี้​บ้าง เพิ่ม​เงื่อนไข​พิเศษ​ตรง​นั้น​บ้าง จน​ไม่มี​ใคร​มอง​เห็น​ภาพ​รวม​อีก​ต่อ​ไป

  • เร็ว​ใน​ระยะ​สั้น — เติม code อีก​บรรทัด​ใน method ที่​มี​อยู่​แล้ว​ง่าย​กว่า​การ​หยุด​คิด​ออกแบบ method ใหม่ ใน​ทาง​จิตวิทยา “เขียน code ใหม่” ให้​ความ​รู้สึก​ยาก​กว่า “อ่าน code เดิม​แล้ว​แยก​ออก​มา” เสมอ ดังนั้น​นัก​พัฒนา​จึง​มัก​เลือก​ทาง​ลัด​ที่​เร็ว​กว่า​คือ​แปะ​เพิ่ม​เข้าไป
  • ไม่​ต้อง​เข้าใจ​ภาพ​รวม — เมื่อ​รีบ​ส่ง​งาน การกระโดด GOTO ไป​แก้​จุด​เดียว​แบบ​เจาะจง ดูเหมือน​เสี่ยง​น้อย​กว่า​การ​รื้อ​โครงสร้าง control flow ทั้งหมด
  • ไม่มี​ใคร​บังคับ​ให้​หยุด — ไม่มี code review หรือ automated check ที่​เตือน​ว่า method ยาว​เกิน​ไป​แล้ว จึง​ไม่มี​จุด​เบรก​ให้​กลับ​ไป​จัด​โครงสร้าง​ใหม่
  • ใน​บริบท​เดิม ๆ มัน​เคย​ได้​ผล — โปรแกรม​ขนาด​เล็ก​ที่ GOTO ไม่​กี่​ตัว​ยัง​พอ​ไล่​ตาม​ได้​ด้วย​ตา ทำให้​ทีม​ประเมิน​ผิด​ว่า​วิธี​นี้​จะ​ยัง scale ได้​เมื่อ​โปรแกรม​โต​ขึ้น
  • อ่าน​ไม่​ออกว่า​ทำงาน​ยังไง — ไม่มี​จุด​เริ่มต้น-จุดจบ​ที่​ชัดเจน​แบบ single entry/single exit ต้อง​ไล่​กระโดด​ไป​มา​ใน​หัว​เพื่อ​จำลอง control flow ซึ่ง​สมอง​มนุษย์​ทำได้​จำกัด​มาก
  • แก้ bug เสี่ยง​สูง — เพราะ​ทุก​อย่าง​พัน​กัน การ​แก้​จุด​หนึ่ง​อาจ​กระทบ​อีก​จุด​ที่​ไม่​คาด​คิด เนื่องจาก​ไม่มี​ขอบเขต​ความ​รับผิดชอบ (responsibility) ที่​ชัดเจน​คอย​กั้น​ผลกระทบ
  • test ยาก หรือ test ไม่​ได้​เลย — method ที่​ทำ​หลาย​อย่าง​พร้อม​กัน​ใน function เดียว ไม่​สามารถ​แยก​ทดสอบ​ที​ละ​ส่วน​ได้ ต้อง​ตั้ง state ที่​ซับซ้อน​เพื่อ trigger การ​ทำงาน​เพียง​ส่วน​เดียว
  • ต่อยอด feature ใหม่​แพง — ทุก​การ​เปลี่ยนแปลง​ต้อง​เข้าใจ​ทั้ง​กระบวนการ​ก่อน ต้นทุน​ใน​การ​อ่าน code สูง​กว่า​ต้นทุน​ใน​การ​เขียน code ใหม่​หลาย​เท่า
  • เป็น​จุด​เริ่ม​ของ Big Ball of Mud — เมื่อ spaghetti code เกิด​ขึ้น​ซ้ำ ๆ ทั่ว​ทั้ง​ระบบ ไม่ใช่​แค่​ใน method เดียว ระบบ​ทั้ง​ก้อน​จะ​กลาย​เป็น​สถาปัตยกรรม​ที่​ไร้​โครงสร้าง​ใด ๆ เลย

method เดียว​ที่​ประมวล​ผล​คำ​สั่ง​ซื้อ ผสม​การ​ตรวจสอบ ส่วนลด ภาษี การ​จัด​ส่ง และ​การ​แจ้ง​เตือน​ไว้​ด้วย​กัน​หมด เงื่อนไข​ซ้อน​กัน​หลาย​ชั้น ไม่มี​จุด​ใด​แยก​ความ​รับผิดชอบ​ออก​จาก​กัน​เลย

// ANTIPATTERN: ทุกอย่างยัดอยู่ใน method เดียว เงื่อนไขซ้อนกันหลายชั้น
public decimal ProcessOrder(Order order, Customer customer)
{
decimal total = 0;
if (order != null)
{
if (order.Items != null && order.Items.Count > 0)
{
foreach (var item in order.Items)
{
if (item.Quantity > 0)
{
decimal price = item.UnitPrice * item.Quantity;
if (customer.IsVip)
{
if (item.Category == "Electronics")
price = price * 0.9m;
else
price = price * 0.95m;
}
else
{
if (order.Items.Count > 10)
price = price * 0.98m;
}
if (order.ShipToCountry == "TH")
price = price * 1.07m; // ภาษีมูลค่าเพิ่ม
else
price = price * 1.0m;
total = total + price;
if (item.Quantity > 100 && customer.IsVip == false)
{
// แจ้งเตือนแบบยัดใส่ตรงนี้เลย ไม่มีที่ทางแยก
Console.WriteLine("แจ้งเตือน: ลูกค้าสั่งจำนวนมาก อาจต้องยืนยันสต็อก");
}
}
}
}
}
return total;
}

จุด​สังเกต: อ่าน​แล้ว​ต้อง​ไล่​ตาม​เงื่อนไข​ซ้อน 5-6 ชั้น​เพื่อ​รู้​ว่า​ราคา​สุดท้าย​คำนวณ​มา​จาก​อะไร ทดสอบ​ยาก​เพราะ​ทุก branch อยู่​ใน function เดียว และ​เพิ่ม​กฎ​ส่วนลด​ใหม่​แต่ละ​ครั้ง​จะ​ยิ่ง​ซ้อน​ลึก​ขึ้น​เรื่อย ๆ

หลักการ​หลัก​คือ Extract Method — แยก​แต่ละ​ความ​รับผิดชอบ​ออก​เป็น method/class ของ​ตัวเอง ที่​มีชื่อ​สื่อ​ความหมาย จาก​นั้น​ให้ method หลัก​อ่าน​เหมือน “สารบัญ” ของ​ขั้นตอน​ทำงาน ไม่ใช่​รายละเอียด​ทั้งหมด

// REFACTORED: แยกความรับผิดชอบ อ่านจากบนลงล่างได้เป็นเส้นตรง
public decimal ProcessOrder(Order order, Customer customer)
{
if (order?.Items is not { Count: > 0 })
return 0m;
decimal total = order.Items
.Where(item => item.Quantity > 0)
.Sum(item => CalculateLineTotal(item, order, customer));
NotifyIfBulkOrderFromNonVip(order, customer);
return total;
}
private decimal CalculateLineTotal(OrderItem item, Order order, Customer customer)
{
decimal price = item.UnitPrice * item.Quantity;
price = ApplyDiscount(price, item, order, customer);
price = ApplyTax(price, order);
return price;
}
private decimal ApplyDiscount(decimal price, OrderItem item, Order order, Customer customer)
{
if (customer.IsVip)
return price * (item.Category == "Electronics" ? 0.9m : 0.95m);
return order.Items.Count > 10 ? price * 0.98m : price;
}
private decimal ApplyTax(decimal price, Order order)
=> order.ShipToCountry == "TH" ? price * 1.07m : price;
private void NotifyIfBulkOrderFromNonVip(Order order, Customer customer)
{
bool hasBulkItem = order.Items.Any(item => item.Quantity > 100);
if (hasBulkItem && !customer.IsVip)
Console.WriteLine("แจ้งเตือน: ลูกค้าสั่งจำนวนมาก อาจต้องยืนยันสต็อก");
}

ขั้นตอน refactor ทั่วไป​ที่​ใช้ได้​ผล:

  1. หา guard clause — คืน​ค่า​เร็ว​สำหรับ​กรณี​ขอบ (order == null) แทน​การ​ซ้อน if ลึก​ลง​ไป​เรื่อย ๆ
  2. Extract Method ที​ละ​ก้อน — เจอ block ที่​ต้อง​อธิบาย​ด้วย​คอมเมนต์ ให้​ดึง​ออก​มา​เป็น method ที่​ชื่อ​อธิบาย​ตัวเอง​แทน​คอมเมนต์​นั้น (ดู Comments)
  3. แทน switch/if-else ยาว ด้วย polymorphism เมื่อ​เงื่อนไข​แตก​ตาม​ชนิด​ของ​วัตถุ (ดู Switch Statements)
  4. เขียน test ก่อน refactor ด้วย​แนวทาง TDD หรือ​อย่าง​น้อย characterization test เพื่อ​ยืนยัน​ว่า​พฤติกรรม​เดิม​ไม่​เปลี่ยน​หลัง​แยก method
  5. ยึด Separation of Concerns ตั้งแต่​ต้น — แต่ละ method/class ควร​มี​เหตุผล​เดียว​ใน​การ​เปลี่ยนแปลง
flowchart LR
    A[ProcessOrder ยาวเป็นก้อน] --> B[หา guard clause]
    B --> C[Extract Method ทีละส่วน]
    C --> D[แทน switch ด้วย polymorphism]
    D --> E[เขียน test ครอบคลุม behavior]
    E --> F[method หลักอ่านเหมือนสารบัญ]
  • Separation of Concerns — หลักการ​แบ่ง​ความ​รับผิดชอบ​ที่​ป้องกัน spaghetti code ตั้งแต่​ต้น
  • Code Readability — เป้าหมาย​ปลายทาง​ของ​การ​แก้ spaghetti code
  • Long Method — กลิ่น code ที่​มัก​เป็น​สัญญาณ​เริ่มต้น​ของ spaghetti code
  • Switch Statements — เงื่อนไข​ซ้อน​กัน​ยาว​ที่​ควร​แทน​ด้วย polymorphism
  • Big Ball of Mud — สิ่ง​ที่​ระบบ​จะ​กลาย​เป็น​เมื่อ spaghetti code ลุกลาม​ทั้ง​สถาปัตยกรรม
  • Refactoring — แนว​ปฏิบัติ​ที่​ใช้​แก้ spaghetti code อย่าง​เป็น​ระบบ