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

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 สม่ำเสมอ

ตัวอย่าง: 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 ข้อความ ระดับต่ำ]