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 ยาวเทอะทะ เพราะทีมกล้าแก้ไขและต่อยอดมันมากกว่า
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”ก่อน — method เดียวแบกทุกความรับผิดชอบ
หัวข้อที่มีชื่อว่า “ก่อน — 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 (และ Extract Class ต่อยอด)
หัวข้อที่มีชื่อว่า “หลัง — ใช้ Extract Method (และ Extract Class ต่อยอด)”ขั้นแรกใช้ 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 โตขึ้นเรื่อย ๆ)
เทคนิค refactor อื่นที่มักใช้ร่วมกัน
หัวข้อที่มีชื่อว่า “เทคนิค refactor อื่นที่มักใช้ร่วมกัน”เมื่อ 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 เยอะขึ้น