值型別和參考型別在 .NET 裡最根本的差別之一,就是參考型別天然可以用 null 表示「沒有物件」,而普通值型別不行。Nullable<T> 的設計,就是在不把值型別整個改成參考型別的前提下,補上一個「有值 / 沒值」的語意層。這個功能看起來只是多了一個 ?,但背後其實牽涉到 CLR、裝箱、運算提升與語法糖。
為什麼普通值型別不能直接是 null
參考型別變數裡存的是「物件參考」,所以 null 的意思很直接:目前沒有指向任何物件。
值型別不一樣。像 int、DateTime 這類型別,變數本身就直接承載資料位元。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 HasValue與Value是直接存在該 struct 內部的狀態
所以 Nullable<T> 的好處之一就是:它提供缺席語意,但不必先為此付出參考型別配置成本。
從非可空到可空、再回來,語言都幫你定好了
Nullable<T> 內建兩條非常重要的轉換規則。
T -> T? 是隱式轉換
int a = 5;
int? b = a;
這代表一個明確有值的 int,可以自然地變成一個 HasValue == true 的 int?。
T? -> T 是顯式轉換
int? a = 5;
int b = (int)a;
之所以需要顯式轉換,是因為 a 可能沒有值。若 HasValue 為 false,這個轉換就會丟例外。
因此,從可空回到非可空時,通常更穩妥的是先檢查 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 == nullint? x = 10;object o = x;之後,o會是一個 boxedint
這個規則非常合理,因為對外界來說,「可空但現在有值」其實就等同於「那個值本身」;而「可空但現在沒值」最自然的參考表示就是 null。
Unboxing 時也有對應規則
既然 boxing 有特殊行為,unboxing 也會跟著有規則:
- 你可以把 boxed
T拆成T - 你也可以把 boxed
T拆成T? - 若來源是
null,只能安全拆成T?,不能拆成非可空T
這讓 Nullable<T> 在和 object、非泛型容器或舊 API 互動時,不至於變成一個完全格格不入的型別。
Equals、GetHashCode、ToString 都有特化行為
Nullable<T> 對一些常見方法做了語意化處理:
GetHashCode()在沒有值時回傳0ToString()在沒有值時回傳空字串Equals(object)會依照「有值 / 沒值」與底層值相等性做判斷
這些行為的重點不是背 API 細節,而是理解:Nullable<T> 嘗試讓「空值」在常見場景裡有一致、可預期的表現。
可空轉換與 lifted conversion
若底層非可空型別之間原本就存在轉換,C# 通常也會自動替可空版本提供對應的提升轉換,也就是 lifted conversion。
例如底層 int -> long 可以轉換,那麼:
int -> long?int? -> long?int? -> long
在特定規則下也會成立。
你可以把它理解成:語言先看底層 T 與 U 有沒有合法轉換,再決定 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對可空值型別來說是truenull和任何有值實例相比通常是不相等- 對大小比較來說,只要有一邊是
null,結果通常不是你直覺期待的「缺值最小」,而是遵循語言規則直接得出false
也就是說,== 的空值語意和 < / > 的空值語意不是同一套。
bool? 是三值邏輯,不只是「可空 bool」
bool? 特別值得單獨提一下,因為它不是簡單的 true / false 二元系統,而是:
truefalsenull
這使得某些邏輯運算進入三值邏輯模型。

因此:
- 有些邏輯結果會傳播出
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>本身仍然是值型別,不會因為可空就自動變成參考型別。HasValue與Value是理解所有可空行為的核心。- boxing
Nullable<T>時,空值會變成null,有值時會盒成底層T。 - lifted conversion 與 lifted operator 讓可空值型別能自然參與轉換與運算。
bool?不是普通布林,而是三值邏輯。??是處理可空值時最常用也最清楚的工具之一。
參考資料
- 《CLR via C#》 Jeffrey Richter
- 《C# in Depth》 Jon Skeet