Inconsistent Abstraction Levels
function เดียวทำงานคละหลายระดับ abstraction พร้อมกัน
กลิ่นนี้คืออะไร
หัวข้อที่มีชื่อว่า “กลิ่นนี้คืออะไร”Inconsistent Abstraction Levels (บางแหล่งเรียก Dubious Abstraction หรือ Changing Levels of Abstraction) เกิดเมื่อ function หรือ class เดียว ทำงานคละหลายระดับ abstraction พร้อมกัน — ขั้นตอนระดับสูง (ระบบทำ “อะไร”) ปนกับรายละเอียดระดับต่ำ (ทำขั้นนั้น “อย่างไร”) อยู่ใน block เดียวกัน
ลองนึกภาพ abstraction level เหมือนการไล่ระดับความละเอียดจากบนลงล่างของโปรแกรม งานระดับสูงอย่าง “หยิบวัตถุดิบมาทำอาหารเที่ยง” คือ abstraction หนึ่งชั้น ส่วน “เปิดตู้เย็น” กับ “ปิดตู้เย็น” คือ abstraction ที่ต่ำกว่าอีกชั้นหนึ่ง เมื่อทั้งสองระดับถูกเขียนเรียงกันใน method เดียวโดยไม่มีการแยกชั้น ผู้อ่านต้องสลับบริบทระหว่าง “ภาพรวม” กับ “รายละเอียดปลีกย่อย” อยู่ตลอดเวลา ซึ่งเพิ่มภาระทางสมอง (cognitive load) โดยไม่จำเป็น
แนวคิดนี้ใกล้เคียงกับ Single Level of Abstraction Principle (SLAP) — ใน1 method ทุกบรรทัดควรอยู่ที่ “ชั้นความละเอียด” เดียวกัน หากมีขั้นตอนที่ละเอียดกว่าขั้นตอนอื่นอย่างชัดเจน นั่นคือสัญญาณว่าควรถูกดึงออกไปเป็น method ย่อยที่มีชื่อสื่อความหมาย
วิธีสังเกต
หัวข้อที่มีชื่อว่า “วิธีสังเกต”จุดที่ระดับ abstraction มักจะเปลี่ยนกลางคันโดยไม่รู้ตัว:
- Branch หรือเงื่อนไข (if/else) — แต่ละเส้นทางมักถูกเติมรายละเอียดคนละระดับ เพราะเขียนเพิ่มทีละเงื่อนไขโดยไม่ทบทวนภาพรวม
- Loop — วน loop แล้วทำทั้งงานระดับสูง (เช่น “ประมวลผลคำสั่งซื้อ”) และรายละเอียดต่ำ (เช่น string formatting, การเรียก field ตรง ๆ) ในตัวเดียวกัน
- code บางช่วง “หน้าตาไม่เหมือนส่วนอื่น” — เช่น method ส่วนใหญ่เป็นชื่อ function สื่อความหมายเรียงต่อกัน (อ่านเหมือนสารบัญ) แต่จู่ ๆ มี block SQL string, การ parse, หรือ loop นับ index โผล่แทรกมา
- ต้องอ่าน code อย่างละเอียดถึงจะสังเกตเห็น เพราะไม่มี syntax error หรือ warning ใด ๆ บ่งชี้ — เป็นกลิ่นเชิงโครงสร้างที่ต้องอาศัยการทบทวนเจตนา (intent) ของแต่ละบรรทัดเทียบกับชื่อ method
กฎคร่าว ๆ ที่ใช้ได้จริง: interface ของ function ควรอยู่ต่ำกว่าชื่อของมันหนึ่งระดับ ถ้า method ชื่อ ProcessOrder แต่เนื้อในมีทั้งการคำนวณราคา การต่อ connection string และการวน loop เขียน log ทีละบรรทัด นั่นคือลดหลั่นมากกว่าหนึ่งระดับ
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”- อ่านยาก ต้องสลับบริบทตลอดเวลา — ผู้อ่านสร้าง mental map ของ workflow ไม่ได้ เพราะต้องสลับไปมาระหว่างการเข้าใจ “ทำไม” (business intent) กับ “อย่างไร” (implementation detail) ในหัวเดียวกัน
- ขยายต่อยาก (poor extendibility) — code ที่เขียน “ตรงตัวเกินไป” (too literally) ผูกติดกับรายละเอียดการทำงานปัจจุบัน เมื่อความต้องการเปลี่ยน การแก้ไขจะกระทบทั้งภาพรวมและรายละเอียดพร้อมกัน เพราะไม่มีขอบเขตแบ่งชั้นให้แก้ทีละจุด
- มักมากับ Single Responsibility Principle ถูกละเมิด — method ที่ทำงานหลายระดับ abstraction มักทำหลายหน้าที่ไปพร้อมกันด้วย (ดู SRP)
- ทดสอบยาก — เมื่อ logic ระดับสูงกับรายละเอียดระดับต่ำถูกผูกใน method เดียว การเขียน unit test เฉพาะส่วนใดส่วนหนึ่งแยกจากกันแทบทำไม่ได้
- ตรวจจับด้วยเครื่องมือได้ยาก — ต่างจาก Long Method ที่วัดจากจำนวนบรรทัดได้ตรง ๆ กลิ่นนี้เป็นเรื่องเชิงความหมาย (semantic) ต้องอาศัยการตัดสินใจของมนุษย์ว่า “สองบรรทัดนี้อยู่คนละระดับความละเอียดหรือไม่” — เป็นเหตุผลหลักที่กลิ่นนี้หลงเหลืออยู่ใน code จำนวนมากแม้ทีมจะรีวิว code สม่ำเสมอ
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”ตัวอย่าง: method สร้างใบแจ้งหนี้ที่คละระดับ abstraction — ตรรกะทางธุรกิจ (คำนวณยอดรวม) ปนกับรายละเอียดการ implement (สร้าง connection string, เขียน SQL ตรง ๆ, format ตัวเลขเอง)
// ก่อน refactor: คละระดับ abstraction ใน method เดียวpublic class InvoiceProcessor{ public void CreateInvoice(Order order) { // ระดับสูง: กติกาทางธุรกิจ decimal subtotal = order.Items.Sum(i => i.Price * i.Quantity); decimal tax = subtotal * 0.07m; decimal total = subtotal + tax;
// ระดับต่ำ: ต่อ connection เองแบบดิบ ๆ string connStr = "Server=db01;Database=Sales;Trusted_Connection=True;"; using var conn = new SqlConnection(connStr); conn.Open();
// ระดับต่ำ: ประกอบ SQL string ตรง ๆ string sql = $"INSERT INTO Invoices (OrderId, Total) VALUES ({order.Id}, {total})"; using var cmd = new SqlCommand(sql, conn); cmd.ExecuteNonQuery();
// ระดับต่ำ: format ตัวเลขเป็น string เอง แล้วค่อยส่งอีเมล string formattedTotal = "$" + total.ToString("N2"); string body = "ยอดรวมใบแจ้งหนี้ของคุณคือ " + formattedTotal; var mail = new MailMessage("billing@shop.com", order.Customer.Email, "ใบแจ้งหนี้", body); new SmtpClient("smtp.shop.local").Send(mail); }}ปัญหา: ผู้ที่เปิดอ่าน CreateInvoice เพื่อทำความเข้าใจ “ขั้นตอนสร้างใบแจ้งหนี้” ต้องลงไปอ่านรายละเอียด SQL, connection string, และ SMTP พร้อมกันไปด้วย ทั้งที่ต้องการแค่ภาพรวมสามขั้น
ใช้ Extract Method (บางแหล่งเรียก Extract Function) ดึงรายละเอียดแต่ละระดับออกเป็น method ที่มีชื่อสื่อเจตนา ให้ method หลักเหลือแต่การเล่าลำดับขั้นระดับสูง:
// หลัง refactor: แต่ละ method อยู่คนละระดับ abstraction ชัดเจนpublic class InvoiceProcessor{ public void CreateInvoice(Order order) { // method นี้เหลือแต่การเล่าขั้นตอนระดับสูงล้วน ๆ อ่านแล้วเข้าใจ "อะไร" ทันที decimal total = CalculateTotal(order); SaveInvoice(order, total); NotifyCustomer(order, total); }
private decimal CalculateTotal(Order order) { decimal subtotal = order.Items.Sum(i => i.Price * i.Quantity); decimal tax = subtotal * TaxRate; return subtotal + tax; }
private void SaveInvoice(Order order, decimal total) { using var conn = new SqlConnection(_connectionString); conn.Open(); using var cmd = new SqlCommand( "INSERT INTO Invoices (OrderId, Total) VALUES (@orderId, @total)", conn); cmd.Parameters.AddWithValue("@orderId", order.Id); cmd.Parameters.AddWithValue("@total", total); cmd.ExecuteNonQuery(); }
private void NotifyCustomer(Order order, decimal total) { string body = $"ยอดรวมใบแจ้งหนี้ของคุณคือ {total:C}"; var mail = new MailMessage(SenderAddress, order.Customer.Email, "ใบแจ้งหนี้", body); new SmtpClient(SmtpHost).Send(mail); }
private const decimal TaxRate = 0.07m; private readonly string _connectionString = "Server=db01;Database=Sales;Trusted_Connection=True;"; private const string SenderAddress = "billing@shop.com"; private const string SmtpHost = "smtp.shop.local";}ตอนนี้ CreateInvoice อ่านได้เหมือนสารบัญสามบรรทัด — คำนวณ, บันทึก, แจ้งเตือน — ทุกบรรทัดอยู่ระดับเดียวกัน ส่วนรายละเอียดการต่อฐานข้อมูลหรือส่ง SMTP ถูกเก็บไว้ใน method ของมันเอง ผู้อ่านที่ต้องการภาพรวมไม่ต้องลงไปเห็น SQL หรือ SMTP เลย ส่วนผู้ที่ต้องการแก้รายละเอียดก็เจาะเข้า method ย่อยได้ตรงจุด
ในกรณีที่ระดับ abstraction เปลี่ยนตามลำดับชั้นของ class (เช่น base class รู้รายละเอียด infrastructure ที่ subclass ไม่ควรรู้) การ refactor ที่เหมาะสมอาจเป็น Extract Class หรือ Extract Superclass แทน เพื่อย้ายรายละเอียดชั้นต่ำไปไว้อีก class ที่ทำหน้าที่นั้นโดยเฉพาะ
แผนภาพต่อไปนี้สรุปการไล่ระดับ abstraction ของ version ที่ refactor แล้ว:
flowchart TD
CreateInvoice[CreateInvoice ระดับสูง เล่าขั้นตอน] --> CalculateTotal[CalculateTotal คำนวณยอดรวม]
CreateInvoice --> SaveInvoice[SaveInvoice บันทึกลงฐานข้อมูล]
CreateInvoice --> NotifyCustomer[NotifyCustomer แจ้งเตือนลูกค้า]
SaveInvoice --> SqlDetail[SQL และ connection string ระดับต่ำ]
NotifyCustomer --> SmtpDetail[SMTP และ format ข้อความ ระดับต่ำ]