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 เดียว และเพิ่มกฎส่วนลดใหม่แต่ละครั้งจะยิ่งซ้อนลึกขึ้นเรื่อย ๆ
ทางแก้และการ refactor
หัวข้อที่มีชื่อว่า “ทางแก้และการ refactor”หลักการหลักคือ 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 ทั่วไปที่ใช้ได้ผล:
- หา guard clause — คืนค่าเร็วสำหรับกรณีขอบ (
order == null) แทนการซ้อนifลึกลงไปเรื่อย ๆ - Extract Method ทีละก้อน — เจอ block ที่ต้องอธิบายด้วยคอมเมนต์ ให้ดึงออกมาเป็น method ที่ชื่ออธิบายตัวเองแทนคอมเมนต์นั้น (ดู Comments)
- แทน
switch/if-elseยาว ด้วย polymorphism เมื่อเงื่อนไขแตกตามชนิดของวัตถุ (ดู Switch Statements) - เขียน test ก่อน refactor ด้วยแนวทาง TDD หรืออย่างน้อย characterization test เพื่อยืนยันว่าพฤติกรรมเดิมไม่เปลี่ยนหลังแยก method
- ยึด 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 อย่างเป็นระบบ