值型別和參考型別在 .NET 裡最根本的差別之一,就是參考型別天然可以用 null 表示「沒有物件」,而普通值型別不行。Nullable<T> 的設計,就是在不把值型別整個改成參考型別的前提下,補上一個「有值 / 沒值」的語意層。這個功能看起來只是多了一個 ?,但背後其實牽涉到 CLR、裝箱、運算提升與語法糖。

為什麼普通值型別不能直接是 null

參考型別變數裡存的是「物件參考」,所以 null 的意思很直接:目前沒有指向任何物件。

值型別不一樣。像 intDateTime 這類型別,變數本身就直接承載資料位元。int 沒有「指到哪裡」這件事,它本身就是值,所以不能像參考型別那樣靠 null 表達缺席。

如果你硬要讓每個值型別都天然支援 null,runtime 就得替每一個值型別再額外攜帶「這格到底是不是空值」的資訊。Nullable<T> 正是在做這件事,只是它不是隱性地改掉所有值型別,而是明確包一層新型別。

C# 1 時代常見的替代方案都不理想

Nullable<T> 出現之前,常見做法大致有幾種:

  • 用 magic value,例如 int.MinValue 代表「沒有資料」
  • object 包起來,需要時靠 boxing / unboxing 還原
  • 自己寫一個包裝結構,裡面放值和一個 bool

這幾種都各有問題:

  • magic value 會污染真實資料域
  • object 會引入 boxing 與額外配置
  • 自製封裝雖然可行,但每個值型別都重做一次很笨重

Nullable<T> 的價值就在於把這個模式標準化,而且直接做進語言與 BCL。

Nullable<T> 本質上是一個 struct

它的核心輪廓可以理解成這樣:

public struct Nullable<T> where T : struct
{
    public Nullable(T value);
    public bool HasValue { get; }
    public T Value { get; }
    public T GetValueOrDefault();
    public T GetValueOrDefault(T defaultValue);
}

也就是說,可空值型別不是什麼神祕 runtime 魔法物件,它仍然是值型別,而且只允許包住另一個值型別。

最關鍵的兩個成員是:

  • HasValue: 是否真的有值
  • Value: 若有值,取出底層 T

因此 int? 的思考模型其實很簡單:本質上就是「一個 int 加上一個是否存在的旗標」。

T? 只是 Nullable<T> 的語法糖

下面兩種寫法完全等價:

int? a = 10;
Nullable<int> b = 10;

int? 只是語法層的簡寫。編譯之後,兩者都會落到 System.Nullable<int>

這個 ? 只對值型別成立。string? 在現代 C# 有另一套 nullable reference types 語意,那是編譯器分析功能,不是本文這個 Nullable<T> 機制。

Nullable<T> 自己仍然是值型別

這點很重要。很多人第一次接觸 int? 會下意識以為它像參考型別一樣變成「可以是 null 的物件」。其實不是。

Nullable<int> 仍然是 struct,所以:

  • 當它是區域變數時,通常仍然按值儲存
  • 不會因為宣告成 int? 就自動變成 heap object
  • HasValueValue 是直接存在該 struct 內部的狀態

所以 Nullable<T> 的好處之一就是:它提供缺席語意,但不必先為此付出參考型別配置成本。

從非可空到可空、再回來,語言都幫你定好了

Nullable<T> 內建兩條非常重要的轉換規則。

T -> T? 是隱式轉換

int a = 5;
int? b = a;

這代表一個明確有值的 int,可以自然地變成一個 HasValue == trueint?

T? -> T 是顯式轉換

int? a = 5;
int b = (int)a;

之所以需要顯式轉換,是因為 a 可能沒有值。若 HasValuefalse,這個轉換就會丟例外。

因此,從可空回到非可空時,通常更穩妥的是先檢查 HasValue,或直接用 GetValueOrDefault()?? 等寫法。

null 比較時,實際上是在看 HasValue

對可空值型別來說,下面這種判斷很常見:

DateTime? death = null;

if (death == null)
{
    Console.WriteLine("still alive");
}

這不是把 DateTime? 當成一般參考型別去比對位址,而是編譯器把它翻成對 HasValue 的判斷。

所以:

  • nullable == null 本質上是在問「有沒有值」
  • nullable != null 本質上是在問「是否持有值」

這也是為什麼 Nullable<T> 的「空」語意是結構內建狀態,而不是另一個外部物件。

Boxing Nullable<T> 時有一個特殊規則

這是 Nullable<T> 最值得記住的 CLR 細節之一。

若你把一個 Nullable<T> 裝箱成 object

  • 如果 HasValue == false,boxing 結果是 null
  • 如果 HasValue == true,boxing 結果不是 Nullable<T> 盒子,而是底層 T 的盒子

也就是說:

  • int? x = null; object o = x; 之後,o == null
  • int? x = 10; object o = x; 之後,o 會是一個 boxed int

這個規則非常合理,因為對外界來說,「可空但現在有值」其實就等同於「那個值本身」;而「可空但現在沒值」最自然的參考表示就是 null

Unboxing 時也有對應規則

既然 boxing 有特殊行為,unboxing 也會跟著有規則:

  • 你可以把 boxed T 拆成 T
  • 你也可以把 boxed T 拆成 T?
  • 若來源是 null,只能安全拆成 T?,不能拆成非可空 T

這讓 Nullable<T> 在和 object、非泛型容器或舊 API 互動時,不至於變成一個完全格格不入的型別。

EqualsGetHashCodeToString 都有特化行為

Nullable<T> 對一些常見方法做了語意化處理:

  • GetHashCode() 在沒有值時回傳 0
  • ToString() 在沒有值時回傳空字串
  • Equals(object) 會依照「有值 / 沒值」與底層值相等性做判斷

這些行為的重點不是背 API 細節,而是理解:Nullable<T> 嘗試讓「空值」在常見場景裡有一致、可預期的表現。

可空轉換與 lifted conversion

若底層非可空型別之間原本就存在轉換,C# 通常也會自動替可空版本提供對應的提升轉換,也就是 lifted conversion。

例如底層 int -> long 可以轉換,那麼:

  • int -> long?
  • int? -> long?
  • int? -> long

在特定規則下也會成立。

你可以把它理解成:語言先看底層 TU 有沒有合法轉換,再決定 T?U? 是否能沿用同一套語意。

這讓可空值型別能更自然地融入一般數值運算與 API 轉換,而不是變成一個需要到處特判的孤島。

可空運算子是「把底層運算提升到可空層」

如果某個非可空值型別支援某個運算子,C# 通常也會為其可空版本自動提供 lifted operator。

int? a = 4;
int? b = 5;
int? c = null;

像這些運算都成立:

Console.WriteLine(-b);
Console.WriteLine(a + b);
Console.WriteLine(a + c);

一般規則是:

  • 若任一運算元是 null,多數算術運算結果會變成 null
  • 若兩邊都有值,就用底層型別的正常運算

這本質上不是 Nullable<T> 自己重載了一大堆運算,而是編譯器根據底層型別能力自動提升出來的結果。

比較運算和布林運算要特別小心

==!=<<=>>= 這類比較運算,規則和一般算術運算不同,因為它們回傳的是 bool,而不是 T?

最常見需要記住的幾點是:

  • null == null 對可空值型別來說是 true
  • null 和任何有值實例相比通常是不相等
  • 對大小比較來說,只要有一邊是 null,結果通常不是你直覺期待的「缺值最小」,而是遵循語言規則直接得出 false

也就是說,== 的空值語意和 < / > 的空值語意不是同一套。

bool? 是三值邏輯,不只是「可空 bool」

bool? 特別值得單獨提一下,因為它不是簡單的 true / false 二元系統,而是:

  • true
  • false
  • null

這使得某些邏輯運算進入三值邏輯模型。

因此:

  • 有些邏輯結果會傳播出 null
  • null 不等於 false
  • 寫條件判斷時要明確知道你要的是「明確為 true」還是「不是 false」

這也是為什麼在商業邏輯裡,bool? 雖然有時很貼切,但也很容易讓判斷分支變得難讀。

?? 是日常最好用的可空工具之一

空值合併運算子 ?? 的語意非常直接:

  • 左邊不是 null,就回傳左邊
  • 左邊是 null,就回傳右邊
int? a = 5;
int b = 10;

int result = a ?? b;

這段的結果型別會是 int,不是 int?,因為右側已經提供了明確後備值。

在日常程式碼中,?? 常常比手寫 HasValue ? Value : fallback 更清楚,也更少犯錯。

GetType() 與介面呼叫也有實務意義

如果某個 Nullable<T> 有值,對它呼叫 GetType() 時,你看到的通常是底層 T 的型別效果,而不是把 Nullable<T> 當成一個完全獨立的新型別來看待。

同樣地,當底層型別實作了某些介面,例如 IComparable,可空版本在適當情況下也能參與相關呼叫。這再次說明:Nullable<T> 雖然是包裝層,但它的整體設計目標是盡量讓底層值的語意自然延伸上來。

方便不代表免費,Nullable<T> 仍有額外成本

雖然 Nullable<T> 不像直接改用參考型別那樣一定帶來 GC 壓力,但它也不是零成本。

例如這段:

int? a = 10;
int? b = 30;
int? result = a + b;

編譯後需要額外處理:

  • HasValue 檢查
  • 底層值提取
  • 新的 Nullable<T> 組裝

相比之下,普通 int + int 的路徑就直接得多。

所以結論不是「不要用 Nullable<T>」,而是:

  • 在業務語意上需要表達缺值時,放心用
  • 在極高頻熱路徑裡,知道它比普通值型別運算更重

實務上該記住什麼

  • T?Nullable<T> 的語法糖,只適用於值型別。
  • Nullable<T> 本身仍然是值型別,不會因為可空就自動變成參考型別。
  • HasValueValue 是理解所有可空行為的核心。
  • boxing Nullable<T> 時,空值會變成 null,有值時會盒成底層 T
  • lifted conversion 與 lifted operator 讓可空值型別能自然參與轉換與運算。
  • bool? 不是普通布林,而是三值邏輯。
  • ?? 是處理可空值時最常用也最清楚的工具之一。

參考資料

  • 《CLR via C#》 Jeffrey Richter
  • 《C# in Depth》 Jon Skeet