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
ทางแก้: Extract Class
หัวข้อที่มีชื่อว่า “ทางแก้: Extract Class”// 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
}
ทางแก้และการ refactor
หัวข้อที่มีชื่อว่า “ทางแก้และการ refactor”- นับสัดส่วน public ต่อ private method — ถ้า class มี public method อยู่แค่หนึ่งหรือสองตัว แต่มี private method มากกว่านั้นหลายเท่า ให้สงสัยไว้ก่อนว่าอาจเป็น Iceberg Class
- มองหากลุ่มพฤติกรรมที่พึ่งพากันเอง — ดูว่า private method เหล่านั้นเรียกกันเองเป็นกลุ่มก้อน และใช้ field/parameter ชุดเดียวกันที่แยกจาก state หลักของ class หรือไม่ ถ้าใช่ นั่นคือ concept ที่รอการแยกออกมา
- ใช้ Extract Class — ย้ายกลุ่มพฤติกรรมนั้นไปยัง class ใหม่ ตั้งชื่อให้ตรงกับแนวคิด domain ที่มันสื่อถึง แล้วเปิด public method/property ให้เหมาะสม ทำทีละน้อยและรัน test หลังย้ายแต่ละ method เพื่อลดความเสี่ยง
- ตั้งชื่อ class ใหม่ให้เข้ากับ Ubiquitous Language — เมื่อ concept ที่เคยไม่มีชื่อได้ชื่อแล้ว ทีมจะพูดถึงมันตรงกันและสื่อสารได้ชัดขึ้น
- อย่าใช้ reflection หรือ
InternalsVisibleToเพื่อทดสอบ private method — ถ้ารู้สึกอยากทดสอบ private method ตรง ๆ ให้มองว่าเป็นสัญญาณว่าถึงเวลา extract แล้ว ไม่ใช่ปัญหาที่ต้องแก้ด้วยเทคนิคของ test framework - ตรวจสอบซ้ำหลัง refactor — class เดิมควรเหลือ private method น้อยลงมาก และ class ใหม่ควรมี public API ที่อธิบายตัวเองได้โดยไม่ต้องพึ่งพา caller เดิม