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

Iceberg Class

class ที่​ซ่อน logic มหาศาล​ไว้​หลัง private method

Michael Feathers บัญญัติ​คำ​ว่า Iceberg Class ไว้​ใน​บทความ​ปี 2005 เพื่อ​อธิบาย class ที่​มี private method มากกว่า public method อย่าง​เห็น​ได้​ชัด — โดย​เฉพาะ class ที่​มี public method อยู่​แค่​หนึ่ง​หรือ​สอง​ตัว (ไม่​นับ constructor) ใน​ขณะ​ที่​มี private method อีก​นับ​สิบ​ตัว​ซ่อน​อยู่​ข้าง​ใน

ภาพ​เปรียบเทียบ​คือ​ภูเขาน้ำแข็ง​จริง ๆ ที่​ราว 88% ของ​มวล​อยู่​ใต้​น้ำ มอง​จาก​ผิวน้ำ​จะ​เห็น​แค่​ยอด​เล็ก ๆ โผล่​ขึ้น​มา แต่​ใต้​น้ำ​คือ​ก้อน​น้ำแข็ง​มหึมา class แบบ​นี้​ก็​เช่น​กัน — public interface ดู​เรียบ​ง่าย​มี​แค่ method เดียว แต่​ภายใน​กลับ​มี private method ที่​พึ่งพา​กัน​เป็น​เครือข่าย​ซับซ้อน Feathers ตั้ง​ข้อสังเกต​ว่า​ถ้า class หนึ่ง​มี​สัดส่วน private method สูง​กว่า 60-70% ของ​ทั้งหมด มัก​เป็น​สัญญาณ​ว่า​มี abstraction ที่​ยัง​ไม่​ถูก​ดึง​ออก​มา​ซ่อน​อยู่​ข้าง​ใน​นั้น

Encapsulation ถูก​สอน​กัน​มา​ตลอด​ว่า​เป็น​คุณธรรม​ของ OOP — “ยิ่ง​ซ่อน​รายละเอียด​การ​ทำงาน​มาก​เท่าไร ยิ่ง​เป็นการ​ออกแบบ​ที่​ดี” นัก​พัฒนา​จึง​มัก​ให้​เหตุผล​กับ​ตัวเอง​ว่า class ที่​มี public surface บาง​เฉียบ แต่​ภายใน​ยัดเยียด logic ไว้​เป็น private ทั้งหมด​คือ​ตัวอย่าง​ของ information hiding ที่​ยอดเยี่ยม

ยิ่ง​ไป​กว่า​นั้น การ​เขียน method เพิ่ม​เป็น private ภายใน class เดิม​นั้น​ง่าย​และ​เร็ว​กว่า​การ​หยุด​คิด​ว่า “logic ส่วน​นี้​ควร​อยู่​ที่ไหน​กัน​แน่” — มัน​คือ​ทาง​ที่​ต้านทาน​น้อย​ที่สุด (path of least resistance) เมื่อ requirement ใหม่​เข้า​มา การ​เพิ่ม private helper อีก​ตัว​ใน class ที่​มี​อยู่​แล้ว​ดู​ปลอดภัย​กว่า​การ​สร้าง class ใหม่​ที่​ต้อง​คิด​ชื่อ คิด​ความ​รับผิดชอบ และ​คิด public API ให้​เรียบร้อย

ปัญหา​ที่แท้​จริง​ไม่ใช่ encapsulation แต่​คือ abstraction ที่​ผิด​ที่ — private method จำนวน​มาก​ที่​พึ่งพา​กันเอง​มัก​หมายความ​ว่า​มี​แนวคิด domain อีก​ตัว​หนึ่ง​ซ่อน​อยู่​ใน class นี้ แต่​ไม่​เคย​ถูก​ตั้ง​ชื่อ​และ​แยก​ออก​มา​เป็น class ของ​ตัวเอง นี่​คือ​การ​ซ่อน​ที่​ผิด​จุด ไม่ใช่​การ​ซ่อน​รายละเอียด​ของ class ที่​มัน​ควร​จะ​เป็น

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

  • ละเมิด Single Responsibility Principle — class เดียว​แบก​ทั้ง​พฤติกรรม​สาธารณะ​ที่​ตั้งใจ​เปิดเผย และ​พฤติกรรม​ของ concept อื่น​ที่​ถูก​ฝัง​ไว้​ข้าง​ใน เมื่อ​แก้ไข logic ส่วน​ใด​ส่วน​หนึ่ง​ก็​เสี่ยง​กระทบ​อีก​ส่วน
  • ทดสอบ​ยาก — เมื่อ private method กลุ่ม​นี้​มี logic ซับซ้อน​พอที่​อยาก​เขียน unit test ตรง ๆ นัก​พัฒนา​มัก​หัน​ไป​ใช้ reflection หรือ​เปลี่ยน method เป็น internal แล้ว​เปิด​ผ่าน InternalsVisibleTo เพื่อ “แอบ” ทดสอบ private method — นี่​คือ​สัญญาณ​เตือน​ว่า logic นั้น​อยาก​มี public interface ของ​ตัวเอง​อยู่​แล้ว ไม่ใช่​ปัญหา​ที่​ต้อง​แก้​ด้วย​เทคนิค​ของ testing framework
  • นำ​กลับ​มา​ใช้​ซ้ำ​ไม่​ได้ — พฤติกรรม​ของ concept ที่​ซ่อน​อยู่​ใช้ได้​เฉพาะ​ภายใน class นี้​เท่านั้น ทั้ง​ที่​จริง ๆ อาจ​มี​ประโยชน์​กับ​ส่วน​อื่น​ของ​ระบบ​ด้วย
  • ไม่มี​ชื่อ​ใน​ภาษา domain — ใน​มุม​ของ DDD แนวคิด​ที่​ไม่​เคย​ถูก​แยก​ออก​มา​เป็น class ก็​ไม่​เคย​ได้​ชื่อ​ใน Ubiquitous Language ทีม​จึง​พูด​ถึง​มัน​ไม่​ได้​ตรง​กัน ต้อง​อธิบาย​เป็น​ประโยค​ยาว ๆ แทนที่​จะ​เอ่ย​ชื่อ type เดียว
  • กลาย​เป็น Large Class ใน​ระยะ​ยาว — เมื่อ private method สะสม​มาก​ขึ้น​เรื่อย ๆ class ก็​บวม​จน​ยาก​ต่อ​การ​อ่าน​และ​แก้ไข แม้ public surface จะ​ยัง​ดู​เล็ก​อยู่​ก็ตาม

ตัวอย่าง​คลาสสิก​ที่ Feathers ใช้​อธิบาย​คือ class SpanFinder ที่​ค้นหา “ช่วง” (span) ของ​อักขระ​ที่​ตรง​เงื่อนไข​ใน string — public method มี​อยู่​ตัว​เดียว แต่​ภายใน​เต็ม​ไป​ด้วย private method ที่​จัดการ​เรื่อง “ช่วง” โดย​เฉพาะ ซึ่ง​ไม่​เกี่ยว​กับ​หน้าที่​หลัก​ของ SpanFinder เลย

public class SpanFinder
{
private readonly string _text;
public SpanFinder(string text)
{
_text = text;
}
// public method เดียวที่โผล่พ้นน้ำ
public List<(int Start, int End)> FindSpans(Func<char, bool> predicate)
{
var spans = new List<(int, int)>();
int index = 0;
while (index < _text.Length)
{
if (predicate(_text[index]))
{
int start = index;
int end = FirstNotMatching(start, predicate);
spans.Add((start, end));
index = end;
}
else
{
index++;
}
}
return spans;
}
// ------- ใต้น้ำ: private method อีกนับสิบตัวที่แท้จริงเป็นเรื่องของ "span" -------
private int FirstNotMatching(int start, Func<char, bool> predicate)
{
int i = start;
while (i < _text.Length && predicate(_text[i])) i++;
return i;
}
private bool IsGeq(int spanStartA, int spanStartB) => spanStartA >= spanStartB;
private bool IsLeq(int spanEndA, int spanEndB) => spanEndA <= spanEndB;
private bool Overlaps(int startA, int endA, int startB, int endB) =>
IsLeq(startA, endB) && IsGeq(endA, startB);
private int ClampToLength(int index) =>
index > _text.Length ? _text.Length : index;
// ... อีกหลาย10 private method ที่จัดการเรื่อง merge/split/compare ของ span โดยเฉพาะ
}

สังเกต​ว่า IsGeq, IsLeq, Overlaps ไม่​ได้​เกี่ยวข้อง​กับ​หน้าที่ “หา span ใน string” โดยตรง แต่​เป็น​พฤติกรรม​ของ​แนวคิด “ช่วง” (span) เอง — มัน​ควร​มี class ของ​ตัวเอง​ที่​ชื่อ Span

// concept ที่เคยซ่อนอยู่ใต้น้ำ ตอนนี้มีชื่อและ public API ของตัวเอง
public class Span
{
public int Start { get; }
public int End { get; }
public Span(int start, int end)
{
Start = start;
End = end;
}
public int Length => End - Start;
public bool IsGeq(int position) => End >= position;
public bool IsLeq(int position) => Start <= position;
public bool Overlaps(Span other) => IsLeq(other.End) && IsGeq(other.Start);
}
public class SpanFinder
{
private readonly string _text;
public SpanFinder(string text)
{
_text = text;
}
public List<Span> FindSpans(Func<char, bool> predicate)
{
var spans = new List<Span>();
int index = 0;
while (index < _text.Length)
{
if (predicate(_text[index]))
{
int start = index;
int end = FirstNotMatching(start, predicate);
spans.Add(new Span(start, end));
index = end;
}
else
{
index++;
}
}
return spans;
}
private int FirstNotMatching(int start, Func<char, bool> predicate)
{
int i = start;
while (i < _text.Length && predicate(_text[i])) i++;
return i;
}
}

หลัง refactor SpanFinder เหลือ public method เดียว​กับ private helper แค่​ตัว​เดียว​ที่แท้​จริง​เป็น​เรื่อง​ของ​การวน​หา​ใน string ส่วน Span กลาย​เป็น class อิสระ​ที่​มี public API ของ​ตัวเอง (IsGeq, IsLeq, Overlaps, Length) — ทดสอบ​ตรง ๆ ได้​โดย​ไม่​ต้อง​พึ่ง SpanFinder เลย และ​ยัง​นำ​ไป​ใช้​ที่​อื่น​ใน​ระบบ​ได้​ด้วย

classDiagram
  SpanFinder --> Span : creates
  class SpanFinder {
    +FindSpans
    -FirstNotMatching
  }
  class Span {
    +IsGeq
    +IsLeq
    +Overlaps
    +Length
  }
  1. นับ​สัดส่วน public ต่อ private method — ถ้า class มี public method อยู่​แค่​หนึ่ง​หรือ​สอง​ตัว แต่​มี private method มากกว่า​นั้น​หลาย​เท่า ให้​สงสัย​ไว้​ก่อน​ว่า​อาจ​เป็น Iceberg Class
  2. มอง​หากลุ่ม​พฤติกรรม​ที่​พึ่งพา​กันเอง — ดู​ว่า private method เหล่า​นั้น​เรียก​กันเอง​เป็นกลุ่ม​ก้อน และ​ใช้ field/parameter ชุด​เดียวกัน​ที่​แยก​จาก state หลัก​ของ class หรือ​ไม่ ถ้า​ใช่ นั่น​คือ concept ที่​รอ​การ​แยก​ออก​มา
  3. ใช้ Extract Class — ย้าย​กลุ่ม​พฤติกรรม​นั้น​ไป​ยัง class ใหม่ ตั้ง​ชื่อ​ให้​ตรง​กับ​แนวคิด domain ที่​มัน​สื่อ​ถึง แล้ว​เปิด public method/property ให้​เหมาะสม ทำที​ละ​น้อย​และ​รัน test หลัง​ย้าย​แต่ละ method เพื่อ​ลด​ความ​เสี่ยง
  4. ตั้ง​ชื่อ class ใหม่​ให้​เข้า​กับ Ubiquitous Language — เมื่อ concept ที่​เคย​ไม่มี​ชื่อ​ได้​ชื่อ​แล้ว ทีม​จะ​พูด​ถึง​มัน​ตรง​กัน​และ​สื่อสาร​ได้​ชัด​ขึ้น
  5. อย่า​ใช้ reflection หรือ InternalsVisibleTo เพื่อ​ทดสอบ private method — ถ้า​รู้สึก​อยาก​ทดสอบ private method ตรง ๆ ให้​มอง​ว่า​เป็น​สัญญาณ​ว่า​ถึง​เวลา extract แล้ว ไม่ใช่​ปัญหา​ที่​ต้อง​แก้​ด้วย​เทคนิค​ของ test framework
  6. ตรวจสอบ​ซ้ำ​หลัง refactor — class เดิม​ควร​เหลือ private method น้อย​ลง​มาก และ class ใหม่​ควร​มี public API ที่​อธิบาย​ตัวเอง​ได้​โดย​ไม่​ต้อง​พึ่งพา caller เดิม