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

Vertical Separation

code ที่​เกี่ยวข้อง​กัน​ถูกวาง​ห่าง​กัน​มาก​ใน file

Vertical Separation คือ​กลิ่น​ที่​เกิด​เมื่อ “สิ่ง​ที่​เกี่ยวข้อง​กัน” สอง​สิ่ง​ใน file เดียวกัน​ถูกวาง​ห่าง​กัน​เกิน​จำเป็น — ตัวแปร​ถูก​ประกาศ​ไว้​บน​สุด​ของ method แต่​ถูก​ใช้งาน​จริง​อีก​หลาย​สิบ​บรรทัด​ถัด​มา, method private ที่​ถูก​เรียก​ใช้​อยู่​บรรทัด​ที่ 20 แต่​ตัว​มัน​เอง​ถูก​นิยาม​ไว้​บรรทัด​ที่ 200, หรือ field กับ constructor ที่​ใช้​มัน​ถูก​คั่น​ด้วย method อื่น​อีก​นับ​สิบ​ตัว

รูปแบบ​ที่​พบ​บ่อย​ที่สุด​คือ​นิสัย​การ “ประกาศ​ตัวแปร​ทั้งหมด​ไว้​ด้าน​บน​ก่อน” ตาม​ธรรมเนียม​ภาษา​เก่า ๆ (เช่น C แบบ​ดั้งเดิม​ที่​บังคับ​ประกาศ​ตัวแปร​ต้น block) ทั้ง​ที่​ภาษา​สมัย​ใหม่​อย่าง C# ไม่มี​ข้อ​จำกัด​นั้น​แล้ว การ​ทำ​เช่น​นี้​สร้าง​ช่องว่าง​เทียม (artificial separation) ระหว่าง​จุด​ที่​ตัวแปร​ถูก​สร้าง​กับ​จุด​ที่​มัน​ถูก​ใช้​จริง ทั้ง​ที่​ทั้ง​สอง​ส่วน​ควร​อยู่​ใกล้​กัน​เพื่อ​ให้​อ่าน​เป็น​เรื่อง​เดียวกัน​ได้​ใน​คราว​เดียว

กลิ่น​นี้​เป็น​เรื่อง​ของ ระยะ​ห่าง​ใน​แนว​ตั้ง (บรรทัด​ต่อ​บรรทัด​ใน file) ไม่ใช่​เรื่อง​โครงสร้าง class หรือ module จึง​มัก​ถูก​มอง​ข้าม​ใน code รีวิว เพราะ​แต่ละ​บรรทัด​ที่​แยก​กัน​อาจ​ดู “ถูกต้อง” ใน​ตัวเอง ปัญหา​จะ​เห็น​ชัด​ก็​ต่อ​เมื่อ​พยายาม​อ่าน​ทั้ง method เป็น​เรื่องราว​เดียว

  • ตัวแปร​ถูก​ประกาศ​ที่​ต้น method แต่​ถูก​ใช้งาน​ครั้ง​แรก​หลัง​จาก​นั้น​อีก​หลาย​สิบ​บรรทัด
  • ต้อง​เลื่อน​ขึ้น​เลื่อน​ลง (scroll) ไป​มา​เพื่อ​เชื่อม​โยง​ว่า​ตัวแปร​ตัว​นี้​มา​จาก​ไหน หรือ method ที่​ถูก​เรียก​อยู่​ที่ไหน
  • method private/helper ถูกวาง​ไว้​ไกล​จาก​จุด​ที่​ถูก​เรียก​ใช้​ครั้ง​แรก แทนที่​จะ​อยู่​ใต้​ผู้​เรียก​ทันที
  • มี​การ​ใช้ #region เพื่อ “ซ่อน” กลุ่ม method helper ไว้​ท้าย file ซึ่ง​มัก​เป็นการ​ปิดบัง​ปัญหา​นี้​มากกว่า​แก้ไข (ดู Regions)
  • จัด​กลุ่ม method ตาม access modifier (public ทั้งหมด​ก่อน แล้ว​ค่อย private ทั้งหมด) แทนที่​จะ​จัด​ตาม​ความ​สัมพันธ์​เชิง​หน้าที่ — ผู้​เรียก​กับ​ผู้​ถูก​เรียก​ที่​เกี่ยวข้อง​กัน​จึง​กระจัดกระจาย
  • method ยาว​มาก (มัก​มา​กับ Long Method) ที่​ทำ​หลาย​ขั้นตอน โดย​ตัวแปร​ของ​แต่ละ​ขั้นตอน​ถูก​ปน​กัน​ไว้​ตอน​ต้น function

การ​อ่าน code คือ​การ​สร้าง​แบบ​จำลอง​ใน​หัว​ว่า “อะไร​เกิด​ขึ้น แล้ว​มัน​เชื่อม​กับ​อะไร” เมื่อ​สิ่ง​ที่​เกี่ยวข้อง​กัน​ถูก​แยก​ออก​จาก​กัน​ด้วย code อื่น​จำนวน​มาก ผู้​อ่าน​ต้อง ถือ​บริบท​ไว้​ใน​หัว (เช่น “อ้อ ตัวแปร taxRate ที่​เห็น​ตอน​ต้น เดี๋ยว​จะ​โดน​ใช้​ตอน​ท้าย”) ตลอด​ช่วง​ที่​เลื่อน​ผ่าน code ที่​ไม่​เกี่ยวข้อง นี่​คือ​ภาระ​ทาง​ปัญญา (cognitive load) ที่​ไม่​จำเป็น และ​ยิ่ง method ยาว​เท่าไร ภาระ​นี้​ก็​ยิ่ง​สะสม​มาก​ขึ้น​เท่านั้น

ผลกระทบ​ที่​ตาม​มา:

  • code รีวิว​พลาด​ง่าย​ขึ้น — ผู้​รีวิว​เห็น​เฉพาะ​บรรทัด​ที่​เปลี่ยน (diff) แต่​บริบท​ของ​ตัวแปร​หรือ method ที่​เกี่ยวข้อง​อยู่​ไกล​ออก​ไป​นอก​จอ ทำให้​พลาด​ผลกระทบ​ข้าง​เคียง​ได้​ง่าย
  • แก้ bug ยาก​ขึ้น — เมื่อ​จะ​แก้ไข​วิธี​คำนวณ​ค่า​ใด​ค่า​หนึ่ง ต้อง​ไล่​หา​ทุก​จุด​ที่​เกี่ยวข้อง​ซึ่ง​กระจาย​อยู่​ทั่ว file แทนที่​จะ​เห็น​เป็นกลุ่ม​ก้อน​เดียว
  • เพิ่ม​ความ​เสี่ยง​ต่อ bug เวลา​แก้ไข — ถ้า​ย้าย​หรือ​แก้ตัวแปร​ที่​ประกาศ​ไว้​ไกล​จาก​จุด​ใช้ ผู้​แก้ไข​อาจมอง​ไม่​เห็น​ผลกระทบ​ทั้งหมด เพราะ​การ​ใช้งาน​จริง​ถูก​ซ่อน​อยู่​ลึก​ใน method
  • เป็น​สัญญาณ​ของ​ปัญหา​ที่​ลึก​กว่า — บ่อย​ครั้ง Vertical Separation ไม่ใช่​แค่​เรื่อง​จัด​บรรทัด แต่​บอกว่า method นั้น​ทำ​หลาย​หน้าที่​เกิน​ไป (ขัด​กับ Single Responsibility) จน​ต้อง​มี​ตัวแปร​หลาย​ชุด​ที่​ใช้​ใน​ช่วง​ต่าง ๆ ของ method เดียวกัน การ​จัด​กลุ่ม code ใหม่​ให้​ใกล้​กัน​จึง​มัก​เป็น​ก้าว​แรก​ที่​นำ​ไป​สู่​การ​เห็น​ขอบเขต​ของ method ย่อย​ที่​ซ่อน​อยู่

ตัวอย่าง​นี้​ประกาศ​ตัวแปร​ทั้งหมด​ไว้​บน​สุด แล้ว​ใช้งาน​กระจาย​อยู่​ปลาย method พร้อม​ทั้ง​ซ่อน method helper ไว้​ใน #region ท้าย class:

// smelly: ตัวแปรถูกประกาศไว้บนสุด แต่ถูกใช้จริงอีกหลายบรรทัดถัดมา
// และ method helper ถูกซ่อนไว้ท้าย class ด้วย #region
public class OrderProcessor
{
public void ProcessOrder(Order order)
{
var pricingPlan = _pricingService.GetPlan(order.CustomerId);
var taxRate = _taxService.GetRate(order.ShippingAddress);
var discountCode = order.DiscountCode;
decimal total = 0;
ValidateOrder(order);
LogOrderReceived(order);
// ...สมมติมี code อื่นคั่นอยู่อีกหลายสิบบรรทัดตรงนี้...
total = CalculateSubtotal(order);
total = ApplyDiscount(total, discountCode); // เพิ่งใช้ discountCode ตอนนี้
total += total * taxRate; // เพิ่งใช้ taxRate ตอนนี้
total = pricingPlan.AdjustForPlan(total); // เพิ่งใช้ pricingPlan ตอนนี้
Charge(order.CustomerId, total);
}
#region Helper methods
private void ValidateOrder(Order order) { /* ... */ }
private void LogOrderReceived(Order order) { /* ... */ }
private decimal CalculateSubtotal(Order order) { /* ... */ }
private decimal ApplyDiscount(decimal total, string code) { /* ... */ }
private void Charge(string customerId, decimal amount) { /* ... */ }
#endregion
}

หลัง refactor ด้วย Slide Statements (เลื่อน​การ​ประกาศ​ตัวแปร​ให้​ไป​อยู่​ติด​กับ​จุด​ที่​ใช้​จริง — refactoring ใน catalog ของ Martin Fowler) ร่วม​กับ Extract Method (แยก​แต่ละ​ขั้นตอน​คำนวณ​ออก​เป็น method ที่​มีชื่อ​สื่อ​ความหมาย) แล้ว​เลิก​ใช้ #region เป็น​ตัว​ซ่อน​ปัญหา:

// clean: ตัวแปรแต่ละตัวถูกประกาศและใช้งานอยู่ในขอบเขตเดียวกัน
// ผู้เรียกกับ helper อยู่ใกล้กัน อ่านจากบนลงล่างได้เป็นเรื่องเดียว
public class OrderProcessor
{
public void ProcessOrder(Order order)
{
ValidateOrder(order);
LogOrderReceived(order);
var subtotal = CalculateSubtotal(order);
var discounted = ApplyDiscount(subtotal, order.DiscountCode);
var taxed = ApplyTax(discounted, order.ShippingAddress);
var total = ApplyPricingPlan(taxed, order.CustomerId);
Charge(order.CustomerId, total);
}
private void ValidateOrder(Order order) { /* ... */ }
private void LogOrderReceived(Order order) { /* ... */ }
private decimal CalculateSubtotal(Order order) { /* ... */ }
private decimal ApplyDiscount(decimal total, string discountCode)
{
// discountCode ถูกประกาศและใช้ในจุดเดียวกันแล้ว ไม่ต้องแบกไว้ตั้งแต่ต้น method
// ...
return total;
}
private decimal ApplyTax(decimal total, Address shippingAddress)
{
var taxRate = _taxService.GetRate(shippingAddress);
return total + total * taxRate;
}
private decimal ApplyPricingPlan(decimal total, string customerId)
{
var pricingPlan = _pricingService.GetPlan(customerId);
return pricingPlan.AdjustForPlan(total);
}
private void Charge(string customerId, decimal amount) { /* ... */ }
}

สังเกต​ว่า​หลัง refactor ตัวแปร​อย่าง pricingPlan และ taxRate ไม่​ได้ “หาย” ไป​ไหน แต่​ถูก​ย้าย​ให้​ประกาศ​และ​ใช้งาน​อยู่ ใน method เดียวกัน ใน​บรรทัด​ติด​กัน ผลลัพธ์​คือ ProcessOrder เอง​อ่าน​ได้​เหมือน​สารบัญ​ของ​ขั้นตอน​ธุรกิจ (validate → log → subtotal → discount → tax → pricing plan → charge) โดย​ไม่​ต้อง​แบก​รายละเอียด​การ​คำนวณ​แต่ละ​อย่าง​ไว้​ใน​หัว​ระหว่าง​อ่าน และ​ไม่​ต้อง​ใช้ #region มา​ซ่อน​ความ​ยาว​ของ class อีก​ต่อ​ไป

ข้อ​ควร​ระวัง: Slide Statements ทำได้​เฉพาะ​เมื่อ​การ​ย้าย​ไม่​เปลี่ยน​พฤติกรรม — ห้าม​เลื่อน​การ​ใช้​ตัวแปร​ไป​อยู่​ก่อน​จุด​ประกาศ และ​ถ้า​มี​บรรทัด​อื่น​ที่​แก้ไข​ค่าตัวแปร​ระหว่าง​ทาง ต้อง​ไม่​เลื่อน​ข้าม​บรรทัด​นั้น

  • Code Readability — แนว​ปฏิบัติ​หลัก​ที่ Vertical Separation ละเมิด
  • Long Method — method ยาว​มัก​เป็น​สาเหตุ​ที่​ทำให้​เกิด​ช่องว่าง​แนว​ตั้ง​ขนาด​ใหญ่
  • Regions — การ​ใช้ #region มัก​เป็นการ​ซ่อน Vertical Separation แทนที่​จะ​แก้ไขมัน
  • Comments — คอมเมนต์​อธิบาย​บริบท​มัก​ถูก​ใช้​ชดเชย​เมื่อ code ที่​เกี่ยวข้อง​กัน​อยู่​ห่าง​กัน​เกิน​ไป
  • Bump Road — อีก​กลิ่น​เรื่อง​โครงสร้าง method ที่​ทำให้​อ่าน​ลำบาก