Indecent Exposure
class เปิดเผยไส้ในมากกว่าที่ควร
กลิ่นนี้คืออะไร
หัวข้อที่มีชื่อว่า “กลิ่นนี้คืออะไร”Indecent Exposure คือกลิ่นที่ class เปิดเผย implementation ภายในมากกว่าที่ควร — ผ่าน public field, public setter ที่ควรถูกควบคุม, method ที่มีไว้รองรับผู้เรียกเฉพาะราย หรือ collection ภายในที่ถูกส่งออกไปให้แก้ไขตรง ๆ แทนที่จะเป็นส่วนหนึ่งของ interface ที่ตั้งใจออกแบบไว้จริง
โดยพื้นฐานแล้วนี่คือการละเมิด encapsulation หรือที่ David Parnas เรียกว่า information hiding: หลักการที่ว่าโครงสร้างข้อมูลซึ่งแทนสถานะภายในของอ็อบเจ็กต์ ควรถูกซ่อนจากทุกสิ่งยกเว้น method public ที่ออกแบบมาให้เข้าถึงมันโดยเฉพาะ เมื่อ class เผลอ “เปลือย” ไส้ในออกมา ผู้เรียกภายนอกก็สามารถอ่านและแก้ไขสถานะได้โดยไม่ผ่านจุดตรวจสอบใด ๆ เลย
สาเหตุที่พบบ่อยคือความเผลอเรอระหว่างพัฒนา: นักพัฒนาประกาศทุกอย่างเป็น public ไว้ก่อนเพื่อความสะดวกตอนเขียน code แล้วลืมกลับมาลดระดับการมองเห็น (access modifier) ให้เหมาะสมภายหลัง
วิธีสังเกต
หัวข้อที่มีชื่อว่า “วิธีสังเกต”- Public field ที่ไม่มีการตรวจสอบ — เช่น
public decimal Balance;ที่ใครก็ตั้งค่าติดลบได้โดยตรง - Public setter แบบเปิดกว้าง — property ที่มี
setแบบไม่มีเงื่อนไข ทั้งที่ค่านั้นควรถูกเปลี่ยนผ่านพฤติกรรมของ domain เท่านั้น (เช่นDeposit(),Withdraw()) - method ที่มีไว้รองรับผู้เรียกเฉพาะราย — method public ที่ตั้งใจให้เรียกจากที่เดียวหรือ class เดียวเท่านั้น (บางครั้งตั้งชื่อว่า
ForTesting,Internal..., หรือมีคอมเมนต์กำกับว่า “อย่าเรียกจากที่อื่น”) - คืนค่า collection ภายในตรง ๆ — คืน
List<T>ที่เป็น field จริงของ class ทำให้ผู้เรียกเพิ่ม/ลบสมาชิกได้โดย class เจ้าของไม่รู้ตัว (ดู Exposing Collection Properties) - field/method จำนวนมากที่ไม่เกี่ยวกับ public API — เมื่อดู IntelliSense ของ class แล้วเห็นสมาชิกที่ควรเป็นรายละเอียดภายในปนอยู่กับ public API ที่ตั้งใจให้ผู้อื่นใช้จริง ๆ
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”- ผูก coupling แน่นเกินจำเป็น — code ภายนอกอ้างอิงถึงรายละเอียดการ implement ภายใน เมื่อ class ต้องเปลี่ยนโครงสร้างข้อมูลในอนาคต (เช่นเปลี่ยนจาก field เดี่ยวเป็นการคำนวณ) จะกระทบผู้เรียกจำนวนมากพร้อมกัน กลายเป็นความเสี่ยงแบบ Shotgun Surgery
- ไม่มีจุดตรวจสอบ invariant — เมื่อสถานะถูกแก้ไขได้โดยตรงผ่าน field/setter ก็ไม่มีที่ใดบังคับกฎของ domain ได้เลย (เช่น ยอดคงเหลือห้ามติดลบ) ทำให้อ็อบเจ็กต์ตกอยู่ในสถานะที่ไม่ถูกต้องได้ตลอดเวลา — อาการนี้ใกล้เคียงกับ Anemic Model ที่พฤติกรรมถูกดึงออกจากข้อมูลจนหมด
- เปิดช่องให้เกิดกลิ่นอื่นตามมา — เมื่อ class หนึ่งเข้าถึงไส้ในของอีก class ได้ง่ายเกินไป มักลงเอยด้วย Feature Envy (code ของ class A ไปยุ่งกับข้อมูลของ class B มากกว่าของตัวเอง) หรือ Insider Trading/Inappropriate Intimacy ที่2 class รู้เรื่องกันมากเกินไปจนแยกกันไม่ได้จริง
- เพิ่มภาระทางปัญญา (information overload) — ผู้ใช้ class ต้องแยกแยะเองว่าสมาชิก public ตัวไหนเป็น “API จริง” ตัวไหนเป็นแค่รายละเอียด implementation ที่หลุดออกมา ทำให้ contract ของ class ไม่ชัดเจนและใช้งานผิดพลาดได้ง่ายขึ้น
- ปิดโอกาสเปลี่ยนแปลง implementation อย่างปลอดภัย — หนึ่งในประโยชน์หลักของ encapsulation คือความสามารถเปลี่ยนวิธีเก็บข้อมูลภายในโดยไม่กระทบผู้เรียก เมื่อ field ถูกเปิดเผยตรง ๆ ประโยชน์ข้อนี้ก็หายไป
ตัวอย่างและการ refactor
หัวข้อที่มีชื่อว่า “ตัวอย่างและการ refactor”ตัวอย่างที่ 1 — public field ไม่มีการตรวจสอบ
หัวข้อที่มีชื่อว่า “ตัวอย่างที่ 1 — public field ไม่มีการตรวจสอบ”code ที่มีกลิ่น: Balance เป็น public field ธรรมดา ใครก็ตั้งค่าอะไรก็ได้ รวมถึงค่าติดลบ
public class BankAccount{ // Indecent Exposure: field เปิดเผยตรง ๆ ไม่มีการตรวจสอบใด ๆ public decimal Balance;}
// ผู้เรียกทำอะไรกับ Balance ก็ได้ ไม่มีใครห้ามvar account = new BankAccount();account.Balance = -500m; // ยอดคงเหลือติดลบ ทั้งที่ไม่ควรเกิดขึ้นใช้การ refactor Encapsulate Field ร่วมกับการเปลี่ยนวิธีแก้ไขสถานะให้ผ่านพฤติกรรมของ domain แทนที่จะเป็น setter แบบเปิดกว้าง:
public class BankAccount{ // field ถูกซ่อนเป็น private แล้ว private decimal _balance;
// เปิดให้ "อ่าน" ได้ผ่าน property แบบ read-only public decimal Balance => _balance;
public void Deposit(decimal amount) { if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount), "จำนวนเงินฝากต้องมากกว่าศูนย์");
_balance += amount; }
public void Withdraw(decimal amount) { if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount), "จำนวนเงินถอนต้องมากกว่าศูนย์"); if (amount > _balance) throw new InvalidOperationException("ยอดคงเหลือไม่เพียงพอ");
_balance -= amount; }}ตอนนี้ทุกการเปลี่ยนแปลงยอดคงเหลือต้องผ่าน Deposit หรือ Withdraw เท่านั้น จุดเดียวที่บังคับ invariant “ยอดคงเหลือห้ามติดลบ” ก็คือใน class นี้ ไม่กระจัดกระจายไปทั่ว codebase
ตัวอย่างที่ 2 — เปิดเผย collection ภายในตรง ๆ
หัวข้อที่มีชื่อว่า “ตัวอย่างที่ 2 — เปิดเผย collection ภายในตรง ๆ”code ที่มีกลิ่น: คืนค่า List<T> ที่เป็น field จริงของ class
public class Order{ public List<OrderLine> Lines = new();}
// ผู้เรียกแก้ไข collection ภายในได้โดยตรง โดย Order ไม่รู้ตัวเลยorder.Lines.Clear();order.Lines.Add(new OrderLine(sku: "BAD-SKU", quantity: -1));refactor ด้วย Encapsulate Collection: ซ่อน field เดิม เปิด read-only view ให้อ่าน และเพิ่ม method เฉพาะเพื่อแก้ไขพร้อมตรวจสอบ:
public class Order{ private readonly List<OrderLine> _lines = new();
public IReadOnlyList<OrderLine> Lines => _lines;
public void AddLine(string sku, int quantity) { if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity), "จำนวนต้องมากกว่าศูนย์");
_lines.Add(new OrderLine(sku, quantity)); }
public void RemoveLine(string sku) { _lines.RemoveAll(l => l.Sku == sku); }}ผู้เรียกยังอ่านรายการสินค้าผ่าน Lines ได้ตามปกติ แต่การแก้ไขทุกครั้งต้องผ่าน AddLine/RemoveLine ซึ่งเป็นจุดเดียวที่ Order ควบคุม invariant ของตัวเองได้
flowchart LR
Caller[Caller] -->|ก่อน: เขียนตรง| Field[Balance field]
Caller -->|หลัง: ผ่านพฤติกรรม| Deposit[Deposit method]
Caller -->|หลัง: ผ่านพฤติกรรม| Withdraw[Withdraw method]
Deposit --> Invariant[Invariant check]
Withdraw --> Invariant
Invariant --> State[Internal state]
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Encapsulation
- Anemic Model
- Exposing Collection Properties
- Data Class
- Inappropriate Intimacy
- Feature Envy