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

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 ถูก​เปิดเผย​ตรง ๆ ประโยชน์​ข้อ​นี้​ก็​หาย​ไป

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

code ที่​มี​กลิ่น: คืน​ค่า List&lt;T&gt; ที่​เป็น 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]