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 ส่วน
- กำหนด interface/abstract class ร่วม ที่ทั้งพฤติกรรมจริงและพฤติกรรมว่างต้อง implement
- ย้ายการเช็ก null ไปไว้จุดเดียว — โดยทั่วไปคือ factory method, repository หรือ getter ที่เดิมเคยคืน
nullให้เปลี่ยนไปคืน instance null object แทน (มักทำเป็น singleton เพราะไม่มี state ให้ต้องแยก instance) - ลบเงื่อนไข
if (x != null)ที่กระจายอยู่ทั่ว code ที่เหลือ เพราะตอนนี้ทุกจุดสามารถเรียก method ผ่าน interface ได้อย่างปลอดภัยเสมอ
ตัวอย่าง code
หัวข้อที่มีชื่อว่า “ตัวอย่าง code”// interface ร่วมที่ทั้งลูกค้าจริงและลูกค้า "ว่าง" ต้อง implementpublic 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/Maybemonad หรือ 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 ที่กระจัดกระจาย