
海倫,是斯巴達王墨涅拉俄斯的妻子,也是希臘神話裡出了名的美女。
按照大家最熟悉的故事,特洛伊王子帕里斯把她帶回特洛伊,墨涅拉俄斯為了搶回妻子,找來一大票希臘聯軍出兵,最後打了長達十年的特洛伊戰爭。
但有趣的是在另一個版本裡,海倫根本沒有去過特洛伊! 事情得從帕里斯得罪赫拉說起。
當年赫拉、雅典娜與阿芙蘿黛蒂爭奪「誰才是最美的女神」,最後找來帕里斯當評審,帕里斯選了阿芙蘿黛蒂,因此得到了她承諾的獎賞,世界上最美的女人,海倫。
但落選的赫拉心裡超級不爽,她動了手腳,用空氣做出一個和海倫一模一樣的幻象,讓帕里斯帶回特洛伊,而真正的海倫,則被送到了埃及。
更荒謬的是,墨涅拉俄斯自己一開始也不知道,戰爭結束後,他帶著從特洛伊救回來的「海倫」漂流到埃及,卻在那裡碰見了真正的妻子,他當場就傻眼了。
如果真正的海倫站在眼前,那自己從特洛伊千辛萬苦帶回來的那個,到底是誰?
就在這時,水手跑來告訴他:「剛才留在洞穴裡的那個「海倫」,剛剛突然消失了。」
也就是說希臘和特洛伊打了整整十年,爭奪的其實是一個「假的海倫」。
Null 這個東西真的很像假的海倫,真正應該在的物件根本不在,但只要程式還沒走到需要它的地方,一切看起來都很正常,直到執行路徑真的用到這個物件,NullReferenceException 才會拋出來讓我們知道。
與其說 Null 在幹嘛,哪些時候用的到它,不如今天我們就從一個很簡單的範例,ㄧ步步從實作開始慢慢講述,你想要的 Null,應該都點得到 XD
在今日範例,背景同步程式 ParcelTrackingService 中,我們要替這段既有流程增加一個診斷功能:
每次向物流商同步包裹狀態後,記錄同步是否成功,並顯示這個 service 從建立到現在的同步成功率。
public class ParcelTrackingService
{
private readonly ICarrierGateway _carrier;
private readonly DeliveryPolicyCatalog _policies;
private readonly IDeliveryNotifier _notifier;
public ParcelTrackingService(
ICarrierGateway carrier,
DeliveryPolicyCatalog policies,
IDeliveryNotifier notifier)
{
_carrier = carrier;
_policies = policies;
_notifier = notifier;
}
// TODO: 實作同步成功率
// 既有功能,向物流商同步包裹狀態
public CarrierSyncResult RefreshStatus(string trackingNumber)
{
var result = _carrier.GetLatestStatus(trackingNumber);
if (result.Success && result.Status == ParcelStatus.ReadyForPickup)
{
_notifier.Push(
result.RecipientPhone,
$"包裹 {trackingNumber} 已送達取件門市");
}
return result;
}
// 既有功能,依照配送政策申請重新配送
public RedeliveryResult RequestRedelivery(string trackingNumber)
{
var policy = _policies.FindFor(trackingNumber);
return _carrier.RequestRedelivery(
trackingNumber,
policy.MaximumAttempts);
}
}
這次的新功能不能只呼叫一個剛新增的純查詢 Method 就結束,因為成功率來自既有的 RefreshStatus,測試必須真的向物流商同步幾次,才能知道成功次數有沒有被正確累計。
ICarrierGateway 會被 RefreshStatus 使用,我們需要 Stub 預先安排「成功」或「失敗」;IDeliveryNotifier 則可以沿用前面學過的 Verify 來進行驗證。
剩下 DeliveryPolicyCatalog 雖然只在申請重新配送時才會使用,新功能完全不會用到它,但偏偏它是不能覆寫的舊類別,建立時還會讀取外部規則檔,我們又不得不注入它:
public sealed class DeliveryPolicyCatalog
{
public DeliveryPolicyCatalog(string policyFilePath)
{
_rules = File.ReadAllLines(policyFilePath);
}
// ...
}
我們先想辦法把 ParcelTrackingService 初始化。
在前幾天我們用過 Mock.Of() 來補齊不會被使用的介面,所以直覺上可能會想照樣寫:
var policies = Mock.Of<DeliveryPolicyCatalog>();
不過雖然能通過 C# 編譯,Moq 在執行時卻無法替 sealed class 建立可用的替身,而我們如果要真的建立 DeliveryPolicyCatalog 又得準備規則檔,我們沒那麼多時間。
對於很難建立、而且預期不會進入目前執行路徑的參數,我們可以先在測試裡傳入 null,我們稱這個技巧為 Pass Null。
蛤?阿不就注入 null 進去,哪來這麼多專有名詞?
var carrier = new Mock<ICarrierGateway>();
var sut = new ParcelTrackingService(
carrier.Object,
policies: null!,
notifier: Mock.Of<IDeliveryNotifier>());
有個很重要的觀念,這樣做並不是 DeliveryPolicyCatalog 不重要,只是我們在假設呼叫 RefreshStatus 與查詢成功率時,不會使用 _policies。
但是如果執行路徑真的跑到 _policies.FindFor(trackingNumber),就會拋出 NullReferenceException,告訴我們「這條路徑確實需要它」,到了那時候,就該補上真物件、替身,或另外找一條 Seam,在很多時候我們在面對很麻煩的 Legacy Code 的時候就可以用到這個技巧,一方面節省時間,一方面也是告訴正在讀這個測試的人,我們暫時沒有用到這東西,概念跟 Dummy 其實很像,你說把 Pass Null 當成是 Dummy 其實也沒問題啦,核心定義都是為了湊齊參數,測試過程根本不會用到它,更嚴格地來說 null 本身不是 Test Double 物件,但它可以作為Dummy 使用**,能解決我們的問題就好了。
昨天已經介紹過 Characterization Test,在加入成功率計數之前,先記錄 RefreshStatus 現在的兩條通知路徑:
1.物流商回傳「同步成功、包裹待取件」時,Service 會發送一次取件通知
2.包裹仍在配送中時,則不會發送通知。
public class ParcelTrackingServiceTests
{
private const string TrackingNumber = "TW-001";
private const string Phone = "0912345678";
private readonly Mock<ICarrierGateway> _carrier = new();
private readonly Mock<IDeliveryNotifier> _notifier = new();
private readonly ParcelTrackingService _sut;
public ParcelTrackingServiceTests()
{
_sut = new ParcelTrackingService(
_carrier.Object,
policies: null!,
_notifier.Object);
}
[Fact]
public void 同步成功且包裹待取件_回傳結果並通知收件人()
{
SetupCarrier(ParcelStatus.ReadyForPickup);
var result = _sut.RefreshStatus(TrackingNumber);
Assert.True(result.Success);
Assert.Equal(ParcelStatus.ReadyForPickup, result.Status);
Assert.Equal(Phone, result.RecipientPhone);
_notifier.Verify(
x => x.Push(
Phone,
$"包裹 {TrackingNumber} 已送達取件門市"),
Times.Once);
}
[Fact]
public void 包裹仍在配送中_不發送取件通知()
{
SetupCarrier(ParcelStatus.InTransit);
_sut.RefreshStatus(TrackingNumber);
_notifier.Verify(
x => x.Push(
It.IsAny<string>(),
It.IsAny<string>()),
Times.Never);
}
private void SetupCarrier(ParcelStatus parcelStatus)
{
_carrier
.Setup(x => x.GetLatestStatus(TrackingNumber))
.Returns(new CarrierSyncResult
{
Success = true,
Status = parcelStatus,
RecipientPhone = Phone
});
}
}
這裡解釋一下 Setup 在做什麼,它在告訴 Moq:「如果等等有人用這組參數呼叫這個 Method,請照我安排的方式回應。」像這次 GetLatestStatus 有回傳值,就在後面接上 Returns,其實就是 Moq 套件做為 Stub 的用法。
這兩支測試第一次執行都應該是綠燈,因為我們還沒有改變任何行為,像昨天我們說的,這時候綠燈只能代表「程式目前確實這樣做」,不代表這些行為已經和需求確認過,更不代表整支都測完了,它們只是先替這次即將碰觸的區域架起一層安全網。
接下來我們就來加入成功率計數。
建構子的問題排除後,我們就來完成新功能,礙於篇幅,我就一次附上所有實作,練習的時候可以依照範例中的 commit 順序做練習:
[Fact]
public void 尚未執行過同步_成功率回傳零()
{
var rate = _sut.GetSyncSuccessRate();
Assert.Equal(0m, rate);
}
[Fact]
public void 同步三次全部成功_成功率為一百()
{
SetupCarrier(ParcelStatus.InTransit);
_sut.RefreshStatus(TrackingNumber);
_sut.RefreshStatus(TrackingNumber);
_sut.RefreshStatus(TrackingNumber);
Assert.Equal(100m, _sut.GetSyncSuccessRate());
}
[Fact]
public void 同步兩次成功一次失敗一次_成功率為五十()
{
_carrier
.SetupSequence(x => x.GetLatestStatus(TrackingNumber))
.Returns(new CarrierSyncResult { Success = true, Status = ParcelStatus.InTransit })
.Returns(new CarrierSyncResult { Success = false, Status = ParcelStatus.InTransit });
_sut.RefreshStatus(TrackingNumber);
_sut.RefreshStatus(TrackingNumber);
Assert.Equal(50m, _sut.GetSyncSuccessRate());
}
測試會因為 NotImplementedException 亮紅燈,接著實作計數邏輯,記錄總同步次數與成功次數,並在查詢時換算成百分比。
private int _syncAttempts;
private int _successfulSyncs;
public CarrierSyncResult RefreshStatus(string trackingNumber)
{
var result = _carrier.GetLatestStatus(trackingNumber);
_syncAttempts++;
if (result.Success)
_successfulSyncs++;
if (result.Success && result.Status == ParcelStatus.ReadyForPickup)
{
_notifier.Push(
result.RecipientPhone,
$"包裹 {trackingNumber} 已送達取件門市");
}
return result;
}
public decimal GetSyncSuccessRate()
{
if (_syncAttempts == 0)
return 0m;
return _successfulSyncs * 100m / _syncAttempts;
}
測試綠燈後,到這裡,Pass Null 我們也會用了,但除了知道怎麼用,我們也花點時間來探討一下 Null 這個東西。
建構子明明寫的是 DeliveryPolicyCatalog,不是 DeliveryPolicyCatalog?,為什麼我們可以注入 null?
C# 的 nullable reference types 主要是編譯期的靜態分析機制,它會透過 warning 提醒我們哪裡可能有 null,但不會在執行期自動幫我們擋下 null。
如果今天是寫 DeliveryPolicyCatalog 而不是 DeliveryPolicyCatalog?,代表編譯器「預期」這個值不會是 null,所以我們硬塞 null 進去,它就會跳一條黃色警告提醒我們。
而 null! 後面那個驚嘆號叫 null-forgiving operator(可以理解成「寬恕運算子」),白話就是跟編譯器說「我知道我在幹嘛,別唸了!」
重點是 null! 只會壓制掉編譯器的 nullable warning,對執行期間完全沒有作用,傳進去的值仍然是 null,只要執行路徑真的執行, NullReferenceException 一樣會拋出,所以這個符號比較像是我們在測試裡簽了一張「風險我知道」的切結書,並不是把值變安全的魔法。
回到這個例子,ParcelTrackingService 的建構子從頭到尾沒有一行:
ArgumentNullException.ThrowIfNull(policies);
所以它收到什麼都沒關係,也因此 null 才能一路進去 _policies,甚至測試跑完都沒事,這是 Pass Null 能成立的另一個前提,如果既有建構子本來就有 ThrowIfNull,我們不應該為了套用技巧把檢查拆掉,這條路就是走不通,得改用能通過建構子的替身或另外找 Seam 才行。
看到這裡,應該會有人想吐槽:「與其在測試裡硬塞一個 null!,那我幹嘛不直接幫 ParcelTrackingService 多開一個不需要 DeliveryPolicyCatalog 的建構子?」
public ParcelTrackingService(
ICarrierGateway carrier,
IDeliveryNotifier notifier)
{
_carrier = carrier;
_notifier = notifier;
}
很乾淨啊,測試只要傳 carrier 和 notifier,連 null 都不用塞。

圖片擷取自網路
先等等,看看這個建構子實際做了什麼,它讓 _policies 維持 null,我們只是把「在測試裡塞 null」變成「在 production code 裡留 null」而已啊!
今天測試確實不會出事,可是這個建構子是公開的,哪天一定會有人用它建構,又呼叫了 RequestRedelivery,會出錯的機率又更大了,而且是直接錯在 production。
Pass Null 至少把違反契約的事情限制在測試裡,公開的空建構子卻會改變 production code 的使用方式,所以為了測試方便就多開一個不完整的建構子,代價反而更大。
不然怎麼會時不時就有客戶反應看到這樣的錯誤訊息呢?
object reference not set to an instance of an object.
那麼既然 Null 這麼麻煩,似乎我們從來沒看過他帶來的好處,因為人都是這樣,沒事的時候覺得理所當然,出事的時候就會瘋狂責怪。
Null Reference 的歷史可以追朔到 1965 年,Tony Hoare 在設計 ALGOL W 的 reference type system 時加入了 null reference,多年後他自己回頭談這件事,甚至把它稱為 「billion-dollar mistake」,並提到當初之所以加入,其中一個原因就是實作起來實在太容易了。
問題不是「沒有值」這個需求不存在,而是當一個 reference 隨時可能是 null,卻沒有辦法從型別或工具清楚知道時,呼叫端就得自己記得檢查,現代 C# 已經有 Nullable Reference Types 幫我們在編譯期找出不少風險,但它終究只是靜態分析。
而在我碰過的專案中,幾乎都能看到許多 method 都會存在這樣形式的物件
public sealed class Parcel
{
private readonly ParcelStatus _status;
public Parcel(ParcelStatus status)
=> _status = status;
public string Summary => _status switch
{
ParcelStatus.Pending => "待出貨",
ParcelStatus.InTransit => "配送中",
ParcelStatus.ReadyForPickup => "待取件",
ParcelStatus.DeliveryFailed => "配送失敗",
ParcelStatus.Delivered => "已簽收",
_ => throw new ArgumentOutOfRangeException(nameof(_status))
};
}
會有一個 method 去把 Parcel 給找出來
public Parcel? FindParcel(string trackingNumber)
=> _dal.Find(trackingNumber);
然後在呼叫端會這樣去做判斷防止拋出錯誤:
var parcel = repo.FindParcel(trackingNumber);
if (parcel == null)
return "查無包裹";
如果團隊協作上對程式碼品質有極高的共識跟講究,Method 都拆得極為乾淨、符合 SRP 的情況下也許能夠接受,但往往在遺留系統中並不是這樣的,很多時候我們會需要輸出很多 null,在一堆 null 判斷裡周旋,時不時就會遇到 object reference not set to an instance of an object 的錯誤。
那麼,Null Object 就能夠派上用場了。
比較好的方向是讓「查不到這個追蹤號碼」本身變成一個有語意的結果,會變成怎樣呢?
我們先替這個 Parcel 抽出一個介面 IParcel,以展現它應該具有的行為
public interface IParcel
{
string Summary { get; }
bool CanRequestRedelivery { get; }
}
並且實作 UnknownParcel:
public sealed class UnknownParcel : IParcel
{
public string Summary => "查無包裹";
public bool CanRequestRedelivery => false;
}
UnknownParcel 不是假裝真的有一件包裹,而是替公開查詢裡很常見的「查無資料」給出合理行為,顯示查無包裹,也不允許申請重新配送。
找到資料時回傳 Parcel,在查不到的時候回傳 UnknownParcel:
public IParcel FindParcel(string trackingNumber)
{
var parcel = _dal.Find(trackingNumber);
return parcel is null
? new UnknownParcel();
: new Parcel(parcel.Status)
}
對,我們確實多寫了幾行程式碼,也在這裡多寫了一個 null 的判斷,但是換來的是呼叫端再也不用先問「你是不是 null」,而是直接用物件行為做取代,判斷的重心從「物件存不存在」移到「行為」,呼叫端關心的就只會是行為,這樣的做法是不是也比較能夠表達業務邏輯了呢?我相信是的,比起一堆 null 的判斷。
但切記並不是在呼叫端把:
if (parcel is null)
換成:
if (parcel is UnknownParcel)
那只是換個寫法在做更多事情而已,別搞自己了!
Null Object 做的事情很簡單,把「沒有這個物件」這個狀態,從「可能是 Null」變成「是一個有明確意義的物件」,從這一刻起,呼叫端就不需要知道有沒有這個東西,只管呼叫就好,因為 Null Object 會自己做好它該做的事。
這個問題很好,如果 FindParcel 原本回傳的是父類別的 Parcel,UnknownParcel 繼承 Parcel 在技術上絕對是可行的,也不少人會這樣去做,但我自己是不太喜歡這種做法就是了,因為語意上就會很奇怪,「查無包裹」反而繼承了一件真的包裹...
所以要做 Null Object,我建議還是要把回傳型別一起換成 interface 或 abstract class,才是比較好的改動。
不一定,其實兩個都可以,大多數場景 interface 就夠了,選 abstract class 則是因為有共用邏輯或是概念需要封裝,而不是因為「感覺比較正式」或是很「酷」。
之前我也這樣想:「只要團隊有共識、有 review,大家夠有紀律,null 就不會跑出去」,後來發現這要有一個很大的前提,就是每一個工程師,在每一個 PR,在每一個疲勞的下午,都要記得做這件事,而現實是,只要某件事是完全靠記憶,時間一長就很容易漏掉。
更根本的問題是,is null 的判斷是分散在每一個呼叫端的,今天有三個地方呼叫同一個 method,三個地方都得要記得判斷 null,明年功能長到十個地方,十個地方就都得記得。
Null Object 適合的場景是:「沒有這個物件」本身是一種正常狀態,而且有合理的預設行為。
UnknownParcel 之所以成立,是因為公開追蹤頁查不到號碼,本來就是一種需要被呈現的結果,畫面顯示「查無包裹」、不開放重新配送,這些在系統功能上都是合理的。
但反過來,如果「沒有這個物件」代表資料出了問題:
例如出貨清單明明已經記著某個追蹤號碼,關聯的包裹資料卻不見了,這時如果硬塞一個 UnknownParcel,再讓重量回傳 0,只會把資料不一致這件事情覆蓋掉,這種比直接讓 null 拋出錯誤更危險,因為它不是一種預設行為,而是一個值得被知道的事實,所以這種情境下就直接拋出吧!
最後,還是要說 Null Object 不是萬靈丹,它解決的問題是「合理的」,不是「不應該發生的」。
如果前面已經會用 Mock.Of,第一次看到 Pass Null 確實很容易覺得莫名其妙,明明能塞一個 Mock.Of(),為什麼現在反而硬塞 null?
Pass Null 不是 Mock.Of 的替代品,只有類別被一個很難建立、而當前測試又不會使用的物件卡住的時候就可以用,它替我們省下不相干的建構成本,並利用 C# 的執行期例外驗證「這條路徑真的沒有使用到」的假設,假設一旦不成立,測試開始使用這個依賴的時候,Pass Null 的任務就結束了,就得要老老實實的處理,在後面的章節中,我們也會介紹到這方面的內容。
至於既有系統裡滿坑滿谷的 null,也不適合一天之內全部重構掉,也別指望馬上處理掉,我們能做的就是一步一腳印,慢慢來。
明天我們繼續看:怎麼有規範的刪除程式碼