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

Bump Road

code ที่​ทำงาน​ด้วย​ลำบาก เพราะ​แรง​เสียด​ทาน​ที่​สะสม​ที​ละ​นิด

Bump Road อธิบาย code ที่ ทำงาน​ด้วย​ยาก​ใน​ทาง​ปฏิบัติ — ไม่ใช่​เพราะ​ข้อ​บกพร่อง​เดียว​ที่​ชัดเจน แต่​เพราะ​แรง​เสียด​ทาน​เล็ก ๆ หลาย​จุด​สะสม​รวม​กัน​จน​ทุก​ก้าว​ยากกว่าที่​ควร​จะ​เป็น เหมือน​ขับ​รถ​บน​ถนน​เต็ม​ไป​ด้วย​หลุม​บ่อ — ไป​ถึง​ปลายทาง​ได้ แต่​ไม่​สบาย ช้า และ​เสี่ยง​พลาด​ง่าย​กว่า​ถนน​เรียบ

DevIQ ใช้​ภาพ​นี้​ใน​ความหมายกว้าง คือ​ความ​ไม่​ราบรื่น​สะสม​ใน codebase ส่วน​คำ​เดียวกัน​ใน​วงการ​วิเคราะห์ code (Adam Tornhill / CodeScene) ก็​ใช้​ชื่อ “Bumpy Road” อธิบาย​อาการ​เดียวกัน​ใน​ระดับ 1 function เดียว: function ที่​มี​เงื่อนไข​ซ้อน​ลึก (nested conditional) หลาย​ก้อน​เรียง​ต่อ​กัน — เวลา​มอง​รูปทรง​การ​เยื้อง (indentation) ของ code จะ​เห็น​เป็น​ลอน​สูง​ต่ำ​สลับ​กัน​เหมือน​ถนน​เป็น​หลุม​เป็น​บ่อ​จริง ๆ แต่ละ “หลุม” คือ​ก้อน​ตรรกะ​ที่​ควร​จะ​ถูก​ห่อ (encapsulate) เป็น​ของ​ตัวเอง แต่​กลับ​ถูก​ปล่อย​ให้​นอน​แช่​อยู่​ใน function เดียวกัน

แต่ละ​หลุม​อาจ​เล็ก แต่​รวม​กัน​เก็บ​ภาษี​มหาศาล​จาก​นัก​พัฒนา​ทุก​คน​ที่​ต้อง​อ่าน​หรือ​แก้ code นั้น ทาง​แก้​ที่ DevIQ เสนอ​คือ Boy Scout Rule — ทำ​ถนน​ให้​เรียบ​ที​ละ​นิด​ใน​ทุก commit (เทียบ​กับ​ปรากฏการณ์ Broken Windows ที่​ความ​เสื่อมโทรม​เล็ก ๆ ที่​ไม่มี​ใคร​แก้ จะ​ชวน​ให้​เกิด​ความ​เสื่อมโทรม​เพิ่ม​ขึ้น​เรื่อย ๆ)

สัญญาณ​ที่​บอกว่า​กำลัง​เจอ Bump Road:

  • เปิด function ขึ้น​มา​แล้ว​เห็น indentation เป็น​ลอน — ส่วน​หัว​ราบ ตาม​ด้วย block if/for ที่​ซ้อน​ลึก 3-4 ชั้น แล้วกลับ​มา​ราบ แล้ว​ซ้อน​ลึก​อีกรอบ ทำซ้ำ​หลาย​ครั้ง​ใน function เดียว
  • นับ “จำนวน​หลุม” ได้​มากกว่า 1-2 หลุม​ใน function เดียว — แต่ละ​หลุม​มัก​แทน​ความ​รับผิดชอบ (responsibility) คนละ​เรื่อง​กัน แต่​ไม่​เคย​ถูก​แยก​ออก​มา
  • อ่าน code แล้ว​ต้อง “ถือ” เงื่อนไข​หลาย​ชั้น​ไว้​ใน​หัว​พร้อม​กัน​เพื่อ​จะ​เข้าใจ​ว่า​บรรทัด​ปัจจุบัน​ทำงาน​ภาย​ใต้​เงื่อนไข​อะไร​บ้าง
  • แก้ bug เล็ก ๆ หนึ่ง​จุด แต่​ต้อง​ไล่​สายตา​ผ่าน​หลุม​อื่น​ที่​ไม่​เกี่ยวข้อง​เพื่อ​ยืนยัน​ว่า​ไม่​กระทบ​กัน
  • ทีม​เริ่ม​หลีก​เลี่ยง file/function นี้ พูด​แบบ​ไม่​เป็น​ทางการ​ว่า “อย่า​ไป​แตะ​ตรง​นั้น” — เป็น​สัญญาณ​ว่า​ถนน​เป็น​หลุม​จน​ไม่มี​ใคร​อยาก​ขับ​ผ่าน

เครื่องมือ​อย่าง CodeScene วัด​กลิ่น​นี้​ได้​อัตโนมัติ​จาก​สาม​ปัจจัย: ความ​ลึก​ของ​การ​ซ้อน (nesting depth) จำนวน​หลุม (number of bumps) และ​ขนาด​ของ​แต่ละ​หลุม (bump size) แล้ว​รวม​เข้า​กับ​ดัชนี Code Health ของ​ทั้ง file

หน่วย​ความ​จำ​ทำงาน (working memory) ของ​สมอง​มนุษย์​มี​ขีด​จำกัด งาน​วิจัย​ที่ CodeScene อ้างอิง​ชี้​ว่า​อาจ​ถือ​ข้อมูล​พร้อม​กัน​ได้​เพียง 3-4 ชิ้น​เท่านั้น เงื่อนไข​ซ้อน​ลึก​หลาย​ก้อน​บังคับ​ให้​ผู้​อ่าน​ต้อง “แบก” บริบท​ของ​ทุก​ชั้น​ไว้​ใน​หัว​พร้อม​กัน — ยิ่ง​ซ้อน​ลึก​และ​มี​หลาย​หลุม ภาระ​ทาง​ปัญญา (cognitive load) ก็​ยิ่ง​สูง และ​งาน​วิจัย​พบ​ว่า​ความ​ซับซ้อน​จาก​การ​ซ้อน​มี​ความ​สัมพันธ์​สูง​กับ​อัตรา​การ​เกิด bug

ใน​ภาษา​เชิง​คำ​สั่ง (imperative language) ยัง​มี​ความ​เสี่ยง​เพิ่ม​คือ feature entanglement — ตัวแปร​สถานะ​ที่​ใช้​ร่วม​กัน​ข้าม​หลาย​หลุม​ทำให้การ​จัดการ​สถานะ (state management) ซับซ้อน​ขึ้น​ไป​อีก แก้​จุด​หนึ่ง​อาจ​กระทบ​อีก​จุด​ที่​ดูเหมือน​ไม่​เกี่ยวข้อง​กัน​เลย

ใน​มุม​ที่​กว้าง​กว่า​ระดับ function เดียว — ความหมาย​ดั้งเดิม​ของ DevIQ — แรง​เสียด​ทาน​สะสม​ยัง​มี​ต้นทุน​เชิง​องค์กร​ด้วย: นัก​พัฒนา​ใหม่​ใช้​เวลา​นาน​ขึ้น​กว่า​จะ “ชิน” กับ​ถนน เวลา​ประเมิน​งาน​คลาดเคลื่อน​เพราะ​ไม่มี​ใคร​รู้​ว่า​จะ​เจอ​หลุม​กี่​หลุม​ระหว่าง​ทาง และ​ทีม​เริ่ม​เลี่ยง​พื้นที่​นั้น​แทนที่​จะ​ซ่อม​มัน — เป็น​วงจร​เดียว​กับ Broken Windows

ตัวอย่าง function ประมวล​ผล​ออเดอร์​ที่​มี​สาม​หลุม: ตรวจการ​ชำระ​เงิน ตรวจ​สต็อก และ​คำนวณ​ค่า​ส่ง — แต่ละ​หลุม​ซ้อน​เงื่อนไข 3 ชั้น​ขึ้น​ไป และ​แทรก​อยู่​ใน function เดียวกัน​หมด

// ก่อน refactor: 1 function สามหลุม อ่านยาก แก้เสี่ยง
public OrderResult ProcessOrder(Order order, Customer customer, Warehouse warehouse)
{
var result = new OrderResult();
// หลุมที่ 1: ตรวจการชำระเงิน
if (order.PaymentMethod != null)
{
if (order.PaymentMethod.IsValid)
{
if (order.Total <= customer.CreditLimit)
{
result.PaymentApproved = true;
}
else
{
result.Errors.Add("เกินวงเงินเครดิต");
}
}
else
{
result.Errors.Add("วิธีชำระเงินไม่ถูกต้อง");
}
}
else
{
result.Errors.Add("ไม่พบวิธีชำระเงิน");
}
// หลุมที่ 2: ตรวจสต็อกสินค้า
if (result.PaymentApproved)
{
foreach (var item in order.Items)
{
if (warehouse.HasStock(item.Sku))
{
if (warehouse.GetStock(item.Sku) >= item.Quantity)
{
result.ReservedItems.Add(item);
}
else
{
result.Errors.Add($"สต็อกไม่พอสำหรับ {item.Sku}");
}
}
else
{
result.Errors.Add($"ไม่พบสินค้า {item.Sku} ในคลัง");
}
}
}
// หลุมที่ 3: คำนวณค่าส่ง
if (result.Errors.Count == 0)
{
if (customer.Address.Country == "TH")
{
if (order.Total > 1000)
{
result.ShippingFee = 0;
}
else
{
result.ShippingFee = 50;
}
}
else
{
result.ShippingFee = 500;
}
}
return result;
}

ทั้ง​สาม​หลุม​เป็น​คนละ​ความ​รับผิดชอบ​กัน​โดย​สิ้นเชิง — การ​อ่าน function นี้​ต้อง​ไล่​เยื้อง​ขึ้น​ลง​สาม​รอบ และ​ตัวแปร result กลาย​เป็น​จุด​เชื่อม​สถานะ​ข้าม​หลุม​ที่​ทำให้​แก้​จุด​หนึ่ง​เสี่ยง​กระทบ​อีก​จุด

refactor ด้วย Extract Method แยก​แต่ละ​หลุม​ออก​เป็น function ของ​ตัวเอง และ​ใช้ Replace Nested Conditional with Guard Clauses ทำให้​แต่ละ function ย่อย​แบน​ราบ​ขึ้น:

// หลัง refactor: function หลักอ่านเป็นลำดับขั้นตอนตรงไปตรงมา
public OrderResult ProcessOrder(Order order, Customer customer, Warehouse warehouse)
{
var result = new OrderResult();
ApprovePayment(order, customer, result);
if (!result.PaymentApproved) return result;
ReserveStock(order, warehouse, result);
if (result.Errors.Count > 0) return result;
result.ShippingFee = CalculateShippingFee(order, customer);
return result;
}
private void ApprovePayment(Order order, Customer customer, OrderResult result)
{
if (order.PaymentMethod is null)
{
result.Errors.Add("ไม่พบวิธีชำระเงิน");
return;
}
if (!order.PaymentMethod.IsValid)
{
result.Errors.Add("วิธีชำระเงินไม่ถูกต้อง");
return;
}
if (order.Total > customer.CreditLimit)
{
result.Errors.Add("เกินวงเงินเครดิต");
return;
}
result.PaymentApproved = true;
}
private void ReserveStock(Order order, Warehouse warehouse, OrderResult result)
{
foreach (var item in order.Items)
{
if (!warehouse.HasStock(item.Sku))
{
result.Errors.Add($"ไม่พบสินค้า {item.Sku} ในคลัง");
continue;
}
if (warehouse.GetStock(item.Sku) < item.Quantity)
{
result.Errors.Add($"สต็อกไม่พอสำหรับ {item.Sku}");
continue;
}
result.ReservedItems.Add(item);
}
}
private decimal CalculateShippingFee(Order order, Customer customer)
{
if (customer.Address.Country != "TH") return 500;
return order.Total > 1000 ? 0 : 50;
}

ผลลัพธ์: function หลัก​อ่าน​ได้​เหมือน​รายการ​ขั้นตอน (ตรวจ​เงิน → จอง​สต็อก → คิด​ค่า​ส่ง) แต่ละ function ย่อย​มี​ความ​รับผิดชอบ​เดียว ทดสอบ​แยก​หน่วย​ได้​เอง และ​ไม่มี​จุด​ไหน​ต้อง​ซ้อน​เกิน 2 ชั้น — ถนน​เรียบ​ขึ้น​ทั้ง​เส้น

graph TD
    A[ProcessOrder เดิม] --> B[หลุม 1 ตรวจเงิน]
    A --> C[หลุม 2 ตรวจสต็อก]
    A --> D[หลุม 3 คิดค่าส่ง]
    B --> E[ApprovePayment]
    C --> F[ReserveStock]
    D --> G[CalculateShippingFee]

หลักการ​เลือก​ว่า​จะ​แยก​กี่ function และ​ตรง​ไหน: แต่ละ​หลุม​ที่​มีชื่อ​เรียก​ได้​ใน domain (ตรวจ​เงิน, จอง​สต็อก, คิด​ค่า​ส่ง) คือ​ผู้​สมัคร​ที่​ดี​สำหรับ Extract Method — ถ้า​ไม่รู้​จะ​ตั้ง​ชื่อ​อะไร มัก​แปล​ว่า​หลุม​นั้น​ยัง​ไม่ใช่​ความ​รับผิดชอบ​เดี่ยว ๆ จริง ต้อง​แยก​เงื่อนไข​ให้​ชัด​ก่อน​ด้วย Decompose Conditional

  • Boy Scout Rule — ทำ​ถนน​ให้​เรียบ​ขึ้น​ที​ละ​นิด​ทุก​ครั้ง​ที่​ผ่าน แทนที่​จะ​ปล่อย​หลุม​ไว้
  • Broken Windows — ความ​เสื่อมโทรม​เล็ก ๆ ที่​ไม่มี​ใคร​แก้ ชวน​ให้​เกิด​ความ​เสื่อมโทรม​เพิ่ม
  • Code Readability — เป้าหมาย​ปลายทาง​ของ​การ​ปรับ​ถนน​ให้​เรียบ
  • Conditional Complexity — กลิ่น​พี่น้อง​ที่​อธิบาย​ตรรกะ​เงื่อนไข​ซับซ้อน​ใน​ระดับ​เดียวกัน
  • Long Method — Bump Road มัก​โผล่​คู่​กับ function ที่​ยาว​เกิน​ไป
  • Mixed Levels of Abstraction — แต่ละ​หลุม​มัก​ผสม​ระดับ abstraction ที่​ต่าง​กัน​ไว้​ใน​ที่​เดียว