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

Null Object

ห่อ​พฤติกรรม​ของ “ค่า​ว่าง” ไว้​ใน object พิเศษ เพื่อ​ลด​การ​เช็ก null

code จำนวน​มาก​เต็ม​ไป​ด้วย​รูปแบบ​ซ้ำ ๆ แบบ​นี้

if (customer != null)
{
count = customer.OrderCount;
}

ทุก​จุด​ที่​มี​ค่า​ซึ่ง “อาจ​ไม่มี” (เช่น หา​ลูกค้า​ไม่​พบ, ยัง​ไม่​ได้​ตั้ง​ค่า, collection ว่าง) โปรแกรมเมอร์​มัก​ต้อง​เพิ่ม​เงื่อนไข if (x != null) ก่อน​เรียก​ใช้​เสมอ ปัญหา​คือ​การ​เช็ก​แบบ​นี้ ไม่​ได้​อยู่​จุด​เดียว แต่​กระจาย​ไป​ทุก​ที่​ที่​เรียก​ใช้​ค่า​นั้น ยิ่ง​ระบบ​โต การ​เช็ก null ก็​ยิ่ง​ลุกลาม กลาย​เป็น noise ที่​บดบัง business logic จริง และ​เสี่ยง​ต่อ NullReferenceException ทุก​ครั้ง​ที่​มี​จุด​ใด​จุด​หนึ่ง​ลืม​เช็ก

Null Object (ตี​พิมพ์​ครั้ง​แรก​ใน​ชื่อ “Void Value” โดย Thomas Kühne ปี 1996 แล้ว​บันทึก​เป็น pattern อย่าง​เป็น​ทางการ​โดย Bobby Woolf ใน Pattern Languages of Program Design 3) แก้​ปัญหา​นี้​ด้วย​แนวคิด​ง่าย ๆ คือ แทนที่​จะ​คืน null ให้​คืน instance พิเศษ​ของ type เดียวกัน​ที่​มี​พฤติกรรม “ว่าง” แต่​ปลอดภัย — implement interface หรือ base class เดิม​ทุก​ประการ เพียง​แต่ method ของ​มัน​ไม่​ทำ​อะไร (no-op) หรือ​คืน​ค่า​เป็นกลาง (เช่น 0, string ว่าง, list ว่าง)

ผล​คือ​ฝั่ง​ผู้​เรียก​ใช้ polymorphism เรียก method ได้​เหมือน​เดิม​โดย​ไม่​ต้อง​รู้​เลย​ว่า​กำลัง​ถือ instance จริง​หรือ instance ว่าง การ​เช็ก null (ถ้า​ยัง​จำเป็น) จะ​ถูก​ผลัก​ไป​รวม​อยู่​ที่​จุด​เดียว — ตรง​จุด​ที่​สร้าง​หรือ​ค้นหา object นั้น — แทนที่​จะ​กระจาย​ซ้ำ​หลาย​สิบ​ครั้ง​ทั่ว codebase

Martin Fowler อธิบาย​แนวคิด​ที่​กว้าง​กว่า​นี้​ใน​ชื่อ Special Case ว่า “nulls are awkward things in object-oriented programs because they defeat polymorphism” — Null Object คือ​กรณี​เฉพาะ​หนึ่ง​ของ Special Case ที่​พฤติกรรม “พิเศษ” คือ​การ​ไม่​ทำ​อะไร​เลย (ใน​ขณะ​ที่ Special Case อื่น ๆ อาจ​มี​พฤติกรรม​เชิง​รุก​กว่า​นั้น เช่น ลูกค้า​ประเภท “ผู้​เช่า​ไม่​ระบุ​ชื่อ” ใน​ระบบ​บิล​ค่าน้ำ​ค่า​ไฟ)

classDiagram
    class ICustomer {
      <<interface>>
      +GetOrderCount() int
      +GetDiscountRate() decimal
      +GetName() string
    }
    class RealCustomer {
      -orders List
      +GetOrderCount() int
      +GetDiscountRate() decimal
      +GetName() string
    }
    class NullCustomer {
      +GetOrderCount() int
      +GetDiscountRate() decimal
      +GetName() string
    }
    class CustomerRepository {
      +Find(id) ICustomer
    }
    ICustomer <|.. RealCustomer
    ICustomer <|.. NullCustomer
    CustomerRepository --> ICustomer

ICustomer คือ interface (หรือ abstract class) ที่​ทั้ง​ของ​จริง​และ​ของว่าง​ต่าง implement ร่วม​กัน RealCustomer มี​ข้อมูล​และ​พฤติกรรม​จริง ส่วน NullCustomer มี method ครบ​เหมือน​กัน​ทุก​ตัว แต่​คืน​ค่า​กลาง เช่น GetOrderCount() คืน 0, GetDiscountRate() คืน 0m, GetName() คืน​ข้อความ​อย่าง "(ไม่พบลูกค้า)" จุด​สำคัญ​คือ CustomerRepository.Find() ไม่​คืน null อีก​ต่อ​ไป — มัน​คืน NullCustomer.Instance แทน​เมื่อ​หาไม่​พบ

ควบคุม​การ​ไหล​ของ​โปรแกรม​ง่าย​ลง​มาก เพราะ​ผู้​เรียก (client) ไม่​ต้อง​รู้​ว่า​ตน​กำลัง​คุย​กับ instance จริง​หรือ instance ว่าง — เรียก method เดิม​ได้​เสมอ

sequenceDiagram
    participant Client
    participant Repo as CustomerRepository
    participant Real as RealCustomer
    participant Nil as NullCustomer

    Client->>Repo: Find(id)
    alt พบลูกค้า
      Repo-->>Client: RealCustomer
      Client->>Real: GetDiscountRate()
      Real-->>Client: 0.10
    else ไม่พบ
      Repo-->>Client: NullCustomer.Instance
      Client->>Nil: GetDiscountRate()
      Nil-->>Client: 0.00
    end

ขั้นตอน​หลัก​มี 3 ส่วน

  1. กำหนด interface/abstract class ร่วม ที่​ทั้ง​พฤติกรรม​จริง​และ​พฤติกรรม​ว่าง​ต้อง implement
  2. ย้าย​การ​เช็ก null ไป​ไว้​จุด​เดียว — โดย​ทั่วไป​คือ factory method, repository หรือ getter ที่​เดิม​เคย​คืน null ให้​เปลี่ยน​ไป​คืน instance null object แทน (มัก​ทำ​เป็น singleton เพราะ​ไม่มี state ให้​ต้อง​แยก instance)
  3. ลบ​เงื่อนไข if (x != null) ที่​กระจาย​อยู่​ทั่ว code ที่​เหลือ เพราะ​ตอน​นี้​ทุก​จุด​สามารถ​เรียก method ผ่าน interface ได้​อย่าง​ปลอดภัย​เสมอ
// interface ร่วมที่ทั้งลูกค้าจริงและลูกค้า "ว่าง" ต้อง implement
public interface ICustomer
{
string Name { get; }
int OrderCount { get; }
decimal DiscountRate { get; }
bool IsNull { get; } // เผื่อบางจุดยังอยากรู้ว่านี่คือ null object หรือไม่
}
// ลูกค้าจริง มีข้อมูลและพฤติกรรมตามธุรกิจ
public sealed class RealCustomer : ICustomer
{
private readonly List<Order> _orders;
public RealCustomer(string name, List<Order> orders)
{
Name = name;
_orders = orders;
}
public string Name { get; }
public int OrderCount => _orders.Count;
public decimal DiscountRate => OrderCount >= 10 ? 0.10m : 0.0m;
public bool IsNull => false;
}
// null object: implement ครบทุก method แต่คืนค่ากลางที่ปลอดภัยเสมอ
public sealed class NullCustomer : ICustomer
{
// มัก implement เป็น singleton เพราะไม่มี state ให้แยก
public static readonly NullCustomer Instance = new();
private NullCustomer() { }
public string Name => "(ไม่พบลูกค้า)";
public int OrderCount => 0;
public decimal DiscountRate => 0.0m;
public bool IsNull => true;
}
public sealed class CustomerRepository
{
private readonly Dictionary<int, RealCustomer> _customers;
public CustomerRepository(Dictionary<int, RealCustomer> customers)
{
_customers = customers;
}
// จุดเดียวที่เช็ก "หาไม่พบ" — คืน NullCustomer แทน null เสมอ
public ICustomer Find(int id)
{
return _customers.TryGetValue(id, out var customer)
? customer
: NullCustomer.Instance;
}
}
// code ฝั่งผู้เรียก ไม่ต้องเช็ก null อีกเลย
public decimal CalculateOrderTotal(int customerId, decimal subtotal, CustomerRepository repo)
{
ICustomer customer = repo.Find(customerId);
// ไม่มี if (customer != null) — polymorphism จัดการให้ทั้งสองกรณี
return subtotal * (1 - customer.DiscountRate);
}
  • เมื่อ​การ​เช็ก if (x != null) ซ้ำ ๆ กัน​หลาย​จุด​ทั่ว codebase สำหรับ type เดียวกัน (อาการ​ของ Conditional Complexity)
  • เมื่อ​สามารถ​นิยาม “พฤติกรรม​ว่าง​ที่​ปลอดภัย” ได้​อย่าง​สม​เหตุ​สม​ผล​จริง เช่น ยอด​สั่ง​ซื้อ = 0, ส่วนลด = 0%, list ว่างเปล่า
  • กับ collection: คืน list/array ว่าง​แทน null เสมอ เพื่อ​ให้ foreach วน​ได้​โดย​ไม่ crash — เป็นการ​ใช้งาน Null Object ที่​พบ​บ่อย​ที่สุด​และ​ปลอดภัย​ที่สุด
  • เมื่อ​ออกแบบ dependency ที่​ต้อง inject แต่​ใน​บาง​บริบท​ยัง​ไม่มี​ของ​จริง (เช่น INullLogger, NoOpCache) เพื่อ​ให้​ระบบ​ทำงาน​ได้​แม้​ยัง​ไม่​ผูก implementation จริง
  • เมื่อ​การ​ไม่​พบ​ค่า​คือ ข้อ​ผิดพลาด​ที่​ต้อง​หยุด​การ​ทำงาน​ทันที — การ​ใช้ Null Object จะ​กลบ error ให้​ดูเหมือน​โปรแกรม​ทำงาน​ปกติ ทั้ง​ที่​ควร throw exception หรือ​ใช้​แนวทาง​แบบ Fail Fast แทน
  • เมื่อ​ไม่มี “พฤติกรรม​ว่าง​ที่​ปลอดภัย” ที่​สม​เหตุ​สม​ผล — การ​เดา​ค่า​กลาง​แบบ​ผิด ๆ (เช่น ให้ discount = 0 ทั้ง​ที่​จริง ๆ ควร​ปฏิเสธ​คำ​สั่ง​ซื้อ) อาจ​สร้าง bug เชิง business logic ที่​ตรวจ​จับ​ยาก​กว่า​การ​เช็ก null ธรรมดา
  • เมื่อ​ภาษา​ที่​ใช้​มี nullable reference types, Option/Maybe monad หรือ null-conditional operator (?., ??) ที่ compiler บังคับ​เช็ก​ให้​อยู่​แล้ว — บาง​กรณี​เครื่องมือ​ภาษา​แก้​ปัญหา​เดียวกัน​ได้​โดย​ไม่​ต้อง​เพิ่ม class
  • เมื่อ​มี type เดียว​เท่านั้น​และ​ใช้ null object แค่​จุด​เดียว​ใน​ระบบ — สร้าง class เพิ่ม​อาจ​ไม่​คุ้ม​ค่า​กับ​ความ​ซับซ้อน​ที่​เพิ่ม​ขึ้น (ขัด​กับ YAGNI)
ด้านรายละเอียด
ข้อดีลบ​เงื่อนไข if (x != null) ที่​ซ้ำซาก​ทั่ว code ทำให้​อ่าน​ง่าย​ขึ้น
ข้อดีรวม​จุด​ตัดสิน​ใจ “ไม่​พบ/ไม่มี​ค่า” ไว้​ที่​เดียว แก้ไข/ทดสอบ​ง่าย​กว่า
ข้อดีลด​ความ​เสี่ยง NullReferenceException เพราะ​ไม่มี null หลุด​ออก​ไป​ให้​ผู้​เรียก​ต้อง​เช็ก​เอง
ข้อดีเป็น​ประโยชน์​ตอน test: ใช้ null object แทน mock ง่าย ๆ ได้​เมื่อ feature บาง​อย่าง​ยัง​ไม่​พร้อม​ใช้งาน
ข้อ​เสียเพิ่ม class ใหม่​ใน​ระบบ — “the price of getting rid of conditionals is creating yet another new class”
ข้อ​เสียอาจ​ทำให้ error กลาย​เป็น​สิ่ง​ที่​ดู “ทำงาน​ปกติ” ทั้ง​ที่​ควร​แจ้ง​เตือน​หรือ​หยุด​ทำงาน
ข้อ​เสียถ้า​มี​คน​ลืม​ใช้ null object แล้ว​คืน null จริง ๆ แทรก​เข้า​มา​บาง​จุด ก็​ยัง​พัง​ได้​เหมือน​เดิม
  • Strategy — Null Object คือ​กรณี​พิเศษ​ของ Strategy ที่​หนึ่ง​ใน strategy คือ “ไม่​ทำ​อะไร”
  • State — แนวคิด​ใกล้​เคียง​กัน​ใน​แง่​ที่​สลับ​พฤติกรรม​ผ่าน​การ​เปลี่ยน object ที่​อ้าง​ถึง
  • Defensive Programming — Null Object เป็น​เทคนิค​หนึ่ง​ใน​การ​เขียน code ป้องกัน​ตัวเอง​จาก​ค่าที่​ไม่​คาด​คิด
  • Fail Fast — แนวทาง​ตรง​ข้าม​ที่​ควร​ใช้​แทน​เมื่อ “ไม่​พบ​ค่า” คือ​ข้อ​ผิดพลาด​จริง ไม่ใช่​กรณี​ปกติ
  • Conditional Complexity — กลิ่น code ที่ Null Object ช่วย​กำจัด​ได้​โดยตรง
  • Code Readability — เป้าหมาย​ปลายทาง​ของ​การ​ลด​การ​เช็ก null ที่​กระจัดกระจาย