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

Long Method

method ที่​ยาว​เกิน​ไป​จน​แบก​ความ​รับผิดชอบ​มากมาย

Long Method หมาย​ถึง method ที่ โต​ใหญ่​เกิน​ไป​จน​แบก​ความ​รับผิดชอบ​หลาย​อย่าง​พร้อม​กัน อ่านที​เดียว​เข้าใจ​ยาก ตั้ง​ชื่อ​ให้​สื่อ​ความ​ยาก และ reuse หรือ​ทดสอบ​แยก​ส่วน​ก็​ยาก มัน​แทบ​ไม่​เคย​เกิด​จาก​การ​นั่ง​เขียน​ยาว ๆ ตั้งแต่​ต้น แต่​ค่อย ๆ โต​ขึ้น​ที​ละ​บรรทัด​ทุก​ครั้ง​ที่​มี​นัก​พัฒนา​คน​หนึ่ง​เติม logic เข้าไป​แทนที่​จะ​แตก method ใหม่ — เหมือน​ที่ SourceMaking เปรียบ​ว่า​มัน​คือ “Hotel California” ของ code: มี​ของ​เข้า​มา​เพิ่ม​เรื่อย ๆ แต่​ไม่มี​อะไร​ถูก​เอา​ออก​ไป​เลย

กลิ่น​นี้​อยู่​ใน​กลุ่ม Bloaters (สิ่ง​ที่​บวม​โต​จน​จัดการ​ยาก) ร่วม​กับ Long Parameter List และ Duplicate Code — ทั้งหมด​ล้วน​เป็น​ผล​จาก​การ​ปล่อย​ให้ code สะสม​มวล​โดย​ไม่​หยุด​มา​จัด​ระเบียบ

Martin Fowler ให้​มุมมอง​ที่​คม​กว่า​การ​นับ​บรรทัด: ปัญหา​ที่แท้​จริง​ไม่ใช่ “ความ​ยาว” แต่​คือ ระยะ​ห่าง​เชิง​ความหมาย​ระหว่าง​สิ่ง​ที่ method ทำ กับ​วิธี​ที่​มัน​ทำ ถ้า​ต้อง​ใช้​ความ​พยายาม​อ่าน code ท่อน​หนึ่ง​เพื่อ​เข้าใจ​ว่า​มัน “ทำ​อะไร” นั่น​คือ​สัญญาณ​ให้​แยก​ท่อน​นั้น​ออก​เป็น method ใหม่ แล้ว​ตั้ง​ชื่อ​ตาม​เจตนา ไม่ใช่​ตาม​กลไก​ภายใน

  • ต้อง เลื่อน​จอ (scroll) เพื่อ​อ่าน method ให้​จบ — ไม่​พอดี​หน้า​จอ​เดียว
  • มี comment คั่น​เป็น​ท่อน ๆ อธิบาย​ว่า​ท่อน​ไหน​ทำ​อะไร (เช่น // validate input, // calculate total, // send notification) — แต่ละ comment คือ​ผู้​สมัคร Extract Method
  • ชื่อ method มัก​กว้าง​และ​คลุมเครือ เช่น ProcessOrder, HandleRequest, DoWork เพราะ​บรรยาย​พฤติกรรม​ทั้งหมด​ใน​ชื่อ​เดียว​ไม่​ไหว
  • มี​ตัวแปร local จำนวน​มาก​ที่​ถูก​ใช้​ซ้ำ​ไป​มา​ข้าม​หลาย​ท่อน​ของ code (ทำให้​แยก​ออก​มา​เป็น method ใหม่​ลำบาก เพราะ​ต้อง​ส่ง parameter เยอะ)
  • มี nested condition หรือ loop หลาย​ชั้น​ซ้อน​กัน​ภายใน method เดียว (มัก​มา​กับ Conditional Complexity)
  • แก้ไข bug หรือ​เพิ่ม feature แต่ละ​ครั้ง​ใช้​เวลา​นาน​ผิด​ปกติ เพราะ​ต้อง​ไล่​อ่าน​ทั้ง method ก่อน​จะ​กล้า​แตะ​จุด​ใด​จุด​หนึ่ง
  • อ่าน​และ​ทำความ​เข้าใจ​ยาก — ผู้​อ่าน​ต้อง​เก็บ state และ context ของ​ทั้ง method ไว้​ใน​หัว​พร้อม​กัน สมอง​มนุษย์​ทำ​แบบ​นี้​ได้​จำกัด
  • ทดสอบ​ยาก — เพราะ method ทำ​หลาย​อย่าง​พร้อม​กัน unit test หนึ่ง​เคส​จึง​มัก​ต้อง​ครอบคลุม​หลาย branch ที่​ไม่​เกี่ยวข้อง​กัน ทำให้ test เปราะ​และ​ตั้ง​ชื่อ test case ให้​สื่อ​ความหมาย​ก็​ยาก
  • reuse ไม่​ได้ — ถ้า​อยาก​ใช้ logic แค่​ครึ่ง​เดียว​ของ method ต้อง copy-paste ท่อน​นั้น​ออก​มา ซึ่ง​นำ​ไป​สู่ Duplicate Code โดยตรง
  • ซ่อน code ซ้ำ​ได้​ง่าย — method ยาว​มัก​มี​ท่อน code คล้าย​กัน​ซ้ำ​อยู่​ภายใน​ตัวเอง แต่​มอง​ไม่​เห็น​เพราะ​ถูก​ฝัง​อยู่​กลาง logic อื่น
  • ขัด​กับ Single Responsibility — method ที่​มี​เหตุผล​ให้​เปลี่ยนแปลง​ได้​หลาย​เหตุผล (validate, calculate, persist, notify ฯลฯ) จะ​ถูก​แก้​บ่อย​ขึ้น และ​แต่ละ​ครั้ง​ก็​เสี่ยง​กระทบ​ท่อน​อื่น​ที่​ไม่​เกี่ยว
  • บั่นทอน ความ​อ่าน​ง่าย​ของ code ใน​ระยะ​ยาว — class ที่​มี​แต่ method สั้น กระชับ มัก​มีอายุ​การ​ใช้งาน​ยืนยาว​กว่า class ที่​เต็ม​ไป​ด้วย method ยาว​เทอะทะ เพราะ​ทีม​กล้า​แก้ไข​และ​ต่อยอด​มัน​มากกว่า
public class OrderProcessor
{
public void ProcessOrder(Order order)
{
// validate input
if (order == null)
throw new ArgumentNullException(nameof(order));
if (order.Items == null || order.Items.Count == 0)
throw new InvalidOperationException("Order must have at least one item.");
foreach (var item in order.Items)
{
if (item.Quantity <= 0)
throw new InvalidOperationException($"Invalid quantity for {item.Sku}.");
}
// calculate total
decimal subtotal = 0m;
foreach (var item in order.Items)
{
subtotal += item.UnitPrice * item.Quantity;
}
decimal discount = 0m;
if (order.Customer.IsLoyaltyMember && subtotal > 1000m)
{
discount = subtotal * 0.1m;
}
decimal tax = (subtotal - discount) * 0.07m;
decimal total = subtotal - discount + tax;
order.Total = total;
// persist
using (var connection = new SqlConnection(_connectionString))
{
connection.Open();
var command = connection.CreateCommand();
command.CommandText = "INSERT INTO Orders (CustomerId, Total) VALUES (@CustomerId, @Total)";
command.Parameters.AddWithValue("@CustomerId", order.Customer.Id);
command.Parameters.AddWithValue("@Total", order.Total);
command.ExecuteNonQuery();
}
// notify
var message = $"Order for {order.Customer.Name} totaling {order.Total:C} has been placed.";
_emailSender.Send(order.Customer.Email, "Order Confirmation", message);
}
}

method นี้​แบก 4 ความ​รับผิดชอบ​ไว้​ใน​ที่​เดียว คือ validate, คำนวณ​ราคา, บันทึก​ลง​ฐาน​ข้อมูล และ​แจ้ง​เตือน​ลูกค้า — comment ทั้ง​สาม​บรรทัด​คือ​หลักฐาน​ชัดเจน​ว่า​มี “หน่วย​งาน” ซ่อน​อยู่ 4 หน่วย​ที่​รอ​ถูก​แยก​ออก​มา

ขั้น​แรก​ใช้ Extract Method แยก​แต่ละ​ท่อน​ออก​เป็น method ที่​ตั้ง​ชื่อ​ตาม​เจตนา:

public class OrderProcessor
{
public void ProcessOrder(Order order)
{
ValidateOrder(order);
order.Total = CalculateTotal(order);
SaveOrder(order);
SendConfirmation(order);
}
private void ValidateOrder(Order order)
{
if (order == null)
throw new ArgumentNullException(nameof(order));
if (order.Items == null || order.Items.Count == 0)
throw new InvalidOperationException("Order must have at least one item.");
foreach (var item in order.Items)
{
if (item.Quantity <= 0)
throw new InvalidOperationException($"Invalid quantity for {item.Sku}.");
}
}
private decimal CalculateTotal(Order order)
{
decimal subtotal = order.Items.Sum(i => i.UnitPrice * i.Quantity);
decimal discount = CalculateLoyaltyDiscount(order.Customer, subtotal);
decimal tax = (subtotal - discount) * 0.07m;
return subtotal - discount + tax;
}
private decimal CalculateLoyaltyDiscount(Customer customer, decimal subtotal)
{
bool eligible = customer.IsLoyaltyMember && subtotal > 1000m;
return eligible ? subtotal * 0.1m : 0m;
}
private void SaveOrder(Order order)
{
_orderRepository.Save(order);
}
private void SendConfirmation(Order order)
{
var message = $"Order for {order.Customer.Name} totaling {order.Total:C} has been placed.";
_emailSender.Send(order.Customer.Email, "Order Confirmation", message);
}
}

ProcessOrder ตอน​นี้​อ่าน​เหมือน​สารบัญ (table of contents) ของ​ขั้นตอน​การ​ประมวล​ผล​ออเดอร์ — มอง​ครั้ง​เดียว​ก็​เห็น​ภาพ​รวม แล้ว​ค่อย​ไป​ดู​รายละเอียด​ใน method ย่อย​เมื่อ​จำเป็น method ย่อย​แต่ละ​อัน​สั้น มี​ความ​รับผิดชอบ​เดียว ทดสอบ​แยก​ได้ ตั้ง​ชื่อ​สื่อ​ความหมาย​ได้​ทันที และ​การ​ที่ SaveOrder เปลี่ยน​จาก raw SqlConnection มา​เรียก _orderRepository.Save ยัง​กำจัด​ปัญหา​การ​เข้าถึง​ฐาน​ข้อมูล​ตรง ๆ ใน​ชั้น business logic ไป​ด้วย (ก้าว​ต่อ​ไป​ที่​เป็น​ไป​ได้​คือ Extract Class แยก OrderPricingCalculator ออก​มา​เป็น class ของ​ตัวเอง หาก​ยัง​พบ​ว่า CalculateTotal โต​ขึ้น​เรื่อย ๆ)

เมื่อ Extract Method อย่าง​เดียว​ไม่​พอ ยัง​มี​เทคนิค​เสริม​จาก catalog ของ Fowler ที่​ช่วย​แก้​อุปสรรค​ระหว่าง​แยก method:

  • Replace Temp with Query — เมื่อ​ตัวแปร local ถูก​ใช้​ซ้ำ​หลาย​จุด​จน​กีดขวาง​การ​แยก method ให้​เปลี่ยน​ตัวแปร​นั้น​เป็น method query แทน
  • Introduce Parameter Object / Preserve Whole Object — เมื่อ method ที่​แยก​ออก​มา​ต้อง​รับ parameter เยอะ​เกิน​ไป ให้​รวบ​เป็น object เดียว (ดู​เพิ่ม​ที่ Long Parameter List)
  • Decompose Conditional — แยก​แต่ละ branch ของ​เงื่อนไข​ซับซ้อน​ออก​เป็น method ของ​ตัวเอง มี​ประโยชน์​มาก​เมื่อ Long Method มา​กับ Conditional Complexity
  • Replace Method with Method Object — เมื่อ method พัน​ตัวแปร local เข้า​ด้วย​กัน​แน่น​เกิน​กว่า​จะ​แยก​ได้​ตรง ๆ ให้​ย้าย​ทั้ง method ไป​เป็น class ของ​ตัวเอง โดย​ตัวแปร local เดิม​กลาย​เป็น field ของ class นั้น
flowchart LR
    LongMethod[ProcessOrder ยาวเทอะทะ]
    Validate[ValidateOrder]
    Calc[CalculateTotal]
    Save[SaveOrder]
    Notify[SendConfirmation]
    LongMethod -->|Extract Method| Validate
    LongMethod -->|Extract Method| Calc
    LongMethod -->|Extract Method| Save
    LongMethod -->|Extract Method| Notify
  • Single Responsibility — หลักการ​ที่ Long Method ละเมิด​โดยตรง​เมื่อ method มี​เหตุผล​ให้​เปลี่ยนแปลง​มากกว่า​หนึ่ง​อย่าง
  • Code Readability — เป้าหมาย​ปลายทาง​ที่​การ​แตก method สั้น​ลง​ช่วย​ส่งเสริม
  • Refactoring — วินัย​ที่​ใช้​แก้​กลิ่น​นี้​อย่าง​ปลอดภัย​ที​ละ​ขั้น
  • Guard Clause — เทคนิค​ที่​มัก​ใช้​คู่​กับ​การ​แยก method validate ออก​มา
  • Conditional Complexity — กลิ่น​ที่​มัก​ซ่อน​อยู่​ใน method ยาว และ​แก้​ด้วย Decompose Conditional
  • Long Parameter List — กลิ่น​ใน​กลุ่ม Bloaters เดียวกัน มัก​โผล่​ตาม​หลัง​เมื่อ​แยก method แล้ว​ต้อง​ส่ง parameter เยอะ​ขึ้น