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

Encapsulation

object ควร​จัดการ​สถานะ​และ​พฤติกรรม​ของ​ตัวเอง

Encapsulation (การ​ห่อ​หุ้ม) หมาย​ถึง​แนวคิด​ที่​ว่า object ควร​จัดการ​พฤติกรรม​และ​สถานะ (state) ของ​ตัวเอง เพื่อ​ให้​ผู้​ร่วม​งาน​ของ​มัน​ไม่​ต้อง​กังวล​กับ​การ​ทำงาน​ภายใน​ของ object นั้น มัน​คือ​หนึ่ง​ใน​สี่​เสา​หลัก​ดั้งเดิม​ของ object-oriented programming (ร่วม​กับ abstraction, inheritance และ polymorphism) และ​มัก​ถูก​มอง​ว่า​เป็น​หลักการ​พื้นฐาน​ที่สุด​ใน​สี่​ข้อ​นี้ เพราะ​การ​ออกแบบ​เชิง​วัตถุ​ที่​ดี​แทบ​ทั้งหมด​พึ่งพา encapsulation เป็น​ฐานราก

Encapsulation มี​สอง​ความหมาย​ที่​เกี่ยวโยง​กัน​แต่​แยก​จาก​กัน​ได้:

  1. การ​รวม​ข้อมูล​กับ​พฤติกรรม (bundling) — จัด​กลุ่ม​ข้อมูล (state) เข้า​กับ method ที่​ทำงาน​บน​ข้อมูล​นั้น​ให้​อยู่​ใน​หน่วย​เดียวกัน (class) แทนที่​จะ​กระจาย​ข้อมูล​กับ logic ที่​ประมวล​ผล​มัน​ไว้​คนละ​ที่
  2. การ​จำกัด​การ​เข้าถึง (access restriction) — ใช้​กลไก​ของ​ภาษา​เพื่อ​ซ่อน​รายละเอียด​ภายใน​ของ object ไม่​ให้ code ภายนอก​เข้าถึง​หรือ​แก้ไข​ได้​โดยตรง

ภาษา​เชิง​วัตถุ​มี built-in support สำหรับ​ควบคุม​การ​มอง​เห็น (visibility) ของ​โครงสร้าง​ระดับ class เช่น public, private, protected, internal และ​นัก​พัฒนา​ควร​ใช้​เครื่องมือ​เหล่า​นี้​เพื่อ​แยก​ระหว่าง interface สาธารณะ​และ​ไม่​สาธารณะ​ของ object การ​ไม่​ใช้​หลัก encapsulation อย่าง​เหมาะสม​นำ​ไป​สู่ code smell และ​ปัญหา​การ​ออกแบบ​ที่​เกี่ยวข้อง​มากมาย เช่น การ​ละเมิด Don’t Repeat Yourself, Tell Don’t Ask และ Flags Over Objects

โดย​อุดมคติ object ควร​ถูก​รักษา​ให้​อยู่​ใน​สถานะ​ที่​ถูกต้อง (valid state) เสมอ โดย​ควบคุม​วิธี​ที่​สถานะ​ถูก​แก้ไข​เพื่อ​หลีก​เลี่ยง​การ​ตก​ไป​อยู่​ใน​สถานะ​ที่​ไม่​สม​เหตุ​สม​ผล ตัวอย่าง​เช่น class ที่​แทน​สินค้า​อาจ​มี​สถานะ​แทน​ปริมาตร (สมมติ​เป็น​ลิตร) ปริมาตร​นี้​อาจ​แทน​ด้วย​ชนิด​ตัวเลข เช่น double แต่​ไม่มี​ทาง​สม​เหตุ​สม​ผล​ที่​ค่า​จะ​ติดลบ หาก class เปิดเผย​ข้อมูล​เป็น public field code ใด ๆ ใน​ระบบ​ก็​สามารถ​ตั้ง​ค่า​ให้​ติดลบ​ได้:

public field เปิด​ช่อง​ให้​ตั้ง​ค่าที่​ไม่​ถูกต้อง​ได้

public class Product
{
public double Volume;
}

ภาษา​เชิง​วัตถุ​สมัย​ใหม่​ส่วน​ใหญ่​รองรับ​แนวคิด Property นัก​พัฒนา​หลาย​คน​จึง​ปรับปรุง​เล็กน้อย​ด้วย​การ​ใช้ property:

ใช้ property แต่​ยัง​ไม่​รับประกัน​ค่าที่​ถูกต้อง

public class Product
{
public double Volume { get; set; }
}

อย่างไร​ก็ตาม นี่​ยัง​ไม่​รับประกัน​ว่า​ปริมาตร​ของ Product จะ​เป็น​บวก จะ​ทำได้​ก็​ต่อ​เมื่อ​มี​การ​ใส่ private backing field เข้า​มา​อย่าง​ชัดเจน​เท่านั้น:

private backing field บังคับ invariant ให้​ค่า​เป็น​บวก

public class Product
{
private double _volume;
public double Volume {
get { return _volume; }
set
{
if (value < 0)
{
throw new ArgumentOutOfRangeException("Volume must be non-negative.");
}
_volume = value;
}
}
}

การ​ออกแบบ​นี้​ดี​ขึ้น แต่​ยัง​มี​กลิ่น​ของ code smell แบบ Primitive Obsession อยู่​บ้าง — ทำไม​การ​รู้​ว่า​สถานะ​ใด​ถูกต้อง​สำหรับ Volume จึง​เป็น​ความ​รับผิดชอบ​ของ class Product? ปริมาตร​ใน​โลก​เรขาคณิต​แบบ​ยุคลิด​ไม่มี​ทาง​ติดลบ​ใน​บริบท​ใด ๆ ไม่ใช่​แค่​สำหรับ Product เท่านั้น จึง​อาจ​สม​เหตุ​สม​ผล​ที่​จะ​แทน Volume เป็น​ชนิด​ของ​มัน​เอง น่า​จะ​เป็น Value Object ซึ่ง​มี​พฤติกรรม​ของ​ตัวเอง (อย่าง​น้อย​คือ​วิธี​บังคับ​ขอบเขต​ค่าที่​ถูกต้อง แต่​คง​มี​รายละเอียด​สำคัญ​อื่น​ด้วย เช่น หน่วย):

แยก Volume เป็น Value Object ที่​ดูแล​ความ​ถูกต้อง​ของ​ตัวเอง

public class Volume
{
private Volume(double amount, string unitOfMeasure)
{
if(amount < 0) throw new ArgumentOutOfRangeException("Volume amount must be non-negative.");
Amount = amount;
UnitOfMeasure = unitOfMeasure;
}
public static Volume InLiters(double amount)
{
return new Volume(amount, "Liters");
}
// เพิ่ม static factory method อื่น ๆ สำหรับหน่วยอื่นตรงนี้
public double Amount { get; private set; }
public string UnitOfMeasure { get; private set; }
// อาจเพิ่ม method สำหรับแปลงหน่วยจากหน่วยหนึ่งไปอีกหน่วยตรงนี้
}

ณ จุด​นี้ Product ไม่​ต้อง​กังวล​อีกว่า Volume อยู่​ใน​สถานะ​บวก​หรือ​ไม่ จึง​ทำให้​เรียบ​ง่าย​ลง​ได้ อย่างไร​ก็ตาม มัน​ก็​ยัง​ไม่​ควร​เปิดเผย​สถานะ​ภายใน​โดยตรง เพราะ​เป็น​ไป​ได้​ว่า Product จะ​อยาก​ตอบ​สนอง​ต่อ​การ​เปลี่ยนแปลง​ใด ๆ ที่​เกิด​กับ​สถานะ​ของ​มัน

Martin Fowler เสริม​มุมมอง​ที่​สำคัญ​ไว้​ใน Getter Eradicator: เป้าหมาย​ที่แท้​จริง​ของ encapsulation ไม่ใช่​การ​ซ่อน​ข้อมูล​เฉย ๆ แต่​คือ​การ​ซ่อน การ​ตัดสิน​ใจ​ใน​การ​ออกแบบ (design decisions) ที่​อาจ​เปลี่ยนแปลง​ได้​ใน​อนาคต — คำถาม​ที่​ควร​ถาม​เสมอ​คือ “เรา​กำลัง​ซ่อน​ความ​ผันแปร (variability) อะไร​อยู่ และ​ทำไม” มุมมอง​นี้​ขยาย​จาก​การ​ซ่อน field ธรรมดา​ไป​ถึง​การ​ซ่อน protocol การ​สื่อสาร​และ​การ​ตัดสิน​ใจ​เชิง​สถาปัตยกรรม​อื่น ๆ ด้วย

  • ป้องกัน invalid state — เมื่อ object ควบคุม​วิธี​ที่​สถานะ​ของ​ตัวเอง​ถูก​แก้ไข มัน​สามารถ​บังคับ invariant (กฎ​ที่​ต้อง​เป็น​จริง​เสมอ) ได้ ป้องกัน​ไม่​ให้​ระบบ​ตก​ไป​อยู่​ใน​สถานะ​ที่​ไม่​สม​เหตุ​สม​ผล
  • ลด coupling — เมื่อ code ภายนอก​พึ่งพา​เฉพาะ public interface ไม่ใช่​รายละเอียด​ภายใน การ​เปลี่ยนแปลง​การ implement ภายใน​จึง​ไม่​กระทบ​ผู้​เรียก​ใช้ ทำให้​ระบบ​ยืดหยุ่น​ต่อ​การ​เปลี่ยนแปลง
  • รองรับ​การ​เปลี่ยนแปลง​ใน​อนาคต — ตาม​ที่ Fowler ชี้ การ​ซ่อน “การ​ตัดสิน​ใจ​ที่​อาจ​เปลี่ยน” ไว้​หลัง interface ที่​มั่นคง ทำให้​ทีม​ปรับปรุง implementation ได้​โดย​ไม่​ต้อง​แก้ code ทุก​จุด​ที่​เรียก​ใช้
  • จัดสรร behavior ให้​ถูก​ที่ — encapsulation ที่​ดี​ผลักดัน​ให้ logic ที่​เกี่ยว​กับ​ข้อมูล​หนึ่ง ๆ อยู่​ใกล้​ข้อมูล​นั้น ตาม Tell Don’t Ask แทนที่​จะ​ดึง​ข้อมูล​ออก​มา​ประมวล​ผล​ข้าง​นอก ซึ่ง​นำ​ไป​สู่ Data Class ที่ Fowler มอง​ว่า​เป็น code smell
  • เป็น​ฐาน​ของ​หลักการ​อื่น — การ​ละเมิด encapsulation มัก​ลาก​ไป​สู่​การ​ละเมิด​หลักการ​อื่น​ตาม​มา เช่น DRY, Tell Don’t Ask, Make Illegal States Unrepresentable และ Law of Demeter

การ​เปิดเผย property โดยตรง​ผ่าน getter/setter ที่​ไม่มี​การ​ตรวจสอบ และ​การ​เปิดเผย property ที่​เป็น collection (แม้​ไม่มี setter) เป็น​สอง​การ​ละเมิด encapsulation ที่​พบ​บ่อย​ที่สุด ลอง​ดู​ตัวอย่าง collection เพิ่มเติม:

ละเมิด encapsulation — collection ภายใน​ถูก​เปิดเผย​โดยตรง

public class Order
{
public List<OrderLine> Lines { get; } = new();
public decimal Total => Lines.Sum(l => l.Price * l.Quantity);
}
// code ภายนอกสามารถทำลาย invariant ของ Order ได้ตรง ๆ
order.Lines.Clear();
order.Lines.Add(new OrderLine(product, -5)); // จำนวนติดลบ ไม่มีใครห้ามได้

แม้ Lines จะมี getter อย่าง​เดียว (ไม่มี setter) แต่​เพราะ​มัน​คืน​ค่า List<OrderLine> ตรง ๆ code ภายนอก​จึง​ยัง​เรียก .Add(), .Clear(), .Remove() บน collection นั้น​ได้​โดยตรง ทำให้ Order ไม่มี​ทาง​รู้​หรือ​ควบคุม​การ​เปลี่ยนแปลง​เลย — Fowler เรียก​ปัญหา​นี้​ว่า Encapsulated Collection ที่​ยัง​ไม่​สมบูรณ์

รักษา encapsulation — เปิดเผย​เฉพาะ read-only view และ​ควบคุม​การ​แก้ไข​ผ่าน method

public class Order
{
private readonly List<OrderLine> _lines = new();
public IReadOnlyList<OrderLine> Lines => _lines;
public decimal Total => _lines.Sum(l => l.Price * l.Quantity);
public void AddLine(Product product, int quantity)
{
if (quantity <= 0)
throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");
_lines.Add(new OrderLine(product, quantity));
}
public void RemoveLine(OrderLine line)
{
_lines.Remove(line);
}
}

ตอน​นี้ Order เป็น​เจ้าของ invariant ของ​ตัวเอง​อย่าง​สมบูรณ์: field ภายใน​เป็น private, มุมมอง​ที่​เปิดเผยออก​ไป​เป็น IReadOnlyList<T> ที่​แก้ไข​ไม่​ได้​จาก​ภายนอก และ​การ​แก้ไข​ทุก​ครั้ง​ต้อง​ผ่าน method AddLine/RemoveLine ที่​ตรวจสอบ​ความ​ถูกต้อง​ก่อน​เสมอ — สอดคล้อง​กับ​หลัก Tell Don’t Ask ที่​บอก​ให้ object ทำงาน​ให้​เรา แทนที่​จะ​ดึง​ข้อมูล​ออก​มา​ทำ​เอง

  • Public field ที่​เก็บ state โดย​ไม่มี​การ​ควบคุม​ใด ๆ
  • Property ที่​มี { get; set; } แบบ​เปิด​กว้าง โดย​ไม่มี​การ​ตรวจสอบ​ค่าที่​รับ​เข้า​มา
  • collection ที่​เปิดเผย​เป็น List<T> แทนที่​จะ​เป็น IReadOnlyList<T> หรือ method ที่​ควบคุม​การ​เพิ่ม/ลบ
  • code เรียก​หลาย getter ของ object เดียวกัน​ติด​กัน​เพื่อ​ไป​คำนวณ logic ข้าง​นอก (สัญญาณ​ของ​การ​ละเมิด Tell Don’t Ask และ​มัก​มา​คู่​กับ​การ​ละเมิด Law of Demeter)
  • class ที่​มี​แต่ field/property กับ getter-setter ล้วน ไม่มี behavior เลย (Data Class / Anemic Domain Model)
  • Object ที่​สามารถ​ตก​ไป​อยู่​ใน​สถานะ​ที่​ไม่​สม​เหตุ​สม​ผล​ได้ (เช่น Volume ติดลบ, EndDate มา​ก่อน StartDate) เพราะ​ไม่มี​จุด​ใด​บังคับ invariant ไว้​เลย
  • ใช้ constant แบบ magic string/number ที่​ควร​ถูก​ห่อ​หุ้ม​เป็น Value Object แทน (อาการ​ของ Primitive Obsession)