C#筆記 – 可空類型
為甚麼值類型變量不能是空
- 非空引用值提供一個訪問對象的路徑,null引用等同「我不引用任何對象」
- 在內存空間中,引用類型的表示是全零(引用地址)。本質上與其他引用的存儲方式是一樣的。
- 值本身由一個字節組成
- 可以將值0-255存儲到變量中。
- 如果我們在這基礎上加一個位元表示null,總共就要257個位,沒有辦法用一個字節存儲這麼多的值。
C# 1表示空值的方法
- 魔值
- 犧牲一個值來表示空值,如Int.MinValue
- 魔值不浪費任何內存
- 同時該值將永遠不能被用來表示真正的數據
- 引用類型包裝
- 方法一:用object作用變量類型,根據需要進行裝箱/拆箱
- 方法二:假定值類型A可空,就為它準備一個引用類型B。在引用類型B中,包含值類型A的一個實例變量,並在B中聲明一個隱式轉換操作符。
- 允許直接使用null
- 要求在堆上創建對象,可能導致GC困難、內存消耗增加
- 額外的bool
- 將值和bool封裝到另一個值類型中
- 由於也是值類型,因此可以避免GC
- 通過封裝的值內表示可空性,而不是通過空引用表示
- 針對每一個值類型創建一個新的類型
- 如果值因為某種原因要進行裝箱,那不管它是否被認為是空值,都要像平時那樣進行裝箱
- 將值和bool封裝到另一個值類型中
System.Nullable(泛型)
-
public struct Nullable<T> where T : struct { public Nullable(T value); public bool HasValue { get; } public T Value { get; } [NullableContextAttribute(2)] public override bool Equals(object? other); public override int GetHashCode(); public T GetValueOrDefault(); public T GetValueOrDefault(T defaultValue); [NullableContextAttribute(2)] public override string? ToString(); public static implicit operator T?(T value); public static explicit operator T(T? value); } -
Nullable(可空類型)是一個泛型類型
- 對於任何具體可空類型而言,T的類型稱為可空類型的基礎類型(underlying type)
- Nullable的基礎類型為int
- Nullable
仍然是值類型,所以實例仍然是在棧上 - 在C#中,在值類型後跟上”?”使之等價於對應的可空值類型
-
int? a; Nullable<int> a; -
以上兩者相互等價
-
- 對於任何具體可空類型而言,T的類型稱為可空類型的基礎類型(underlying type)
-
Nullable的屬性
- HasValue
- Value
- 另外,由於Nullable仍然為值類型,因此對於Nullable類型的變量來說,其值將直接包含一bool(HasValue)和 int(Value),而不會是其他對象的引用
-
Nullable的方法
-
構造方法:可指定值決定要不要創建一個有值的實例
-
GetValueOrDefault:如果實例存在值,就返回該值,否則返回一個默認值
-
GetHashCode/ToString等重載
- GetHashCode在沒有值時返回0
- ToString在沒有值時設回空字符串
-
轉換方法
-
非可空類型T轉換到可空類型T的隱式轉換
- 轉換結果為一個HasValue == true的實例
-
可空類型T轉到到非可空類型T的顯式轉換
- 沒有可以返回的Value時拋出異常
-
將T的實例轉換成Nullable的實例稱為「包裝」
-
int a = 5; Nullable<int> b = a;
-
-
將Nullable的實例轉換為T的實例稱為「拆包」
-
Nullable<int> a = 5; int b = (int)a;
-
-
-
比較方法
-
來自靜態類Nullable(非泛型)的兩個靜態方法
- 對於沒有值的實例,比較方法的返回值遵從.NET的約定:空與空相等;空小於所有值
-
public static int Compare<T>(Nullable<T> n1, Nullable<T> n2) //使用Comparer.Default public static bool Equals<T>(Nullable<T> n1, Nullable<T> n2) //使用EqualityComparer.Default
-
來自Nullable(非泛型)的一個支持
-
public static Type GetUnderlyingType(Type nullableType) -
如果參數是可空類型,方法返回其基礎類型,否則返回null
-
-
-
Nullable(泛型)的裝箱和拆箱
- Nullable是一個struct(值類型),因此在進行引用類型操作時需要進行裝/拆箱
- Nullable的實例裝箱
- 在沒有值時裝箱成空引用
- 在有值時,等同將其值(非可空)裝箱
- Nullable裝箱 = T裝箱
- 拆箱:已裝箱的值可拆箱成普通類型/對應的可空類型
- 拆箱一個空引用時
- 只能拆成可空類型,否則報錯
- 空引用拆成可空類型後,會拆成一個沒有值的實例
- 如果對已裝箱值類型的引用是null,而且要把它拆箱成一個Nullable
,CLR會將Nullable 的值設為null
- 如果對已裝箱值類型的引用是null,而且要把它拆箱成一個Nullable
- 拆箱一個空引用時
Nullable(泛型)實例的Equals(object)
- 設有一可空值類型first,一可空/非可空值類型seconds,first.Equals(second)在不同情況下的結果:
- 不必考慮second是否一個Nullable,基於Equals(object)的參數類型object將會對second進行裝箱。
- 當second有值時,會裝箱成一個非可空值類型的箱子
- 當second沒有值時,會返回一個空引用
- if(first.HasValue && second == null) => first == second
- if(first.HasValue && second != null) => first != second
- if(!first.HasValue && second == null) => first != second
- if(first.Value == second) => first == second
- 不必考慮second是否一個Nullable,基於Equals(object)的參數類型object將會對second進行裝箱。
語法糖
-
? 修飾符
-
- 使用?修飾符修飾的值類型變量與使用Nullable聲明的變量會被編譯成同樣的IL(System.Nullable`1[T])
-
Nullable<int> a = 10; //IL : System.Nullable`1[System.Int32] int? a = 10; //IL : System.Nullable`1[System.Int32]
-
- 使用?修飾符修飾的值類型變量與使用Nullable聲明的變量會被編譯成同樣的IL(System.Nullable`1[T])
-
使用null進行賦值和比較
-
設有一個Person類
- 類中有一個可空類型:死亡日期
static void Main(string[] args) { Person turing = new Person("Alan Turing", new DateTime(1912, 6, 23), new DateTime(1954, 6, 7)); //將null當作可空類型的實例來傳遞時,實際上是通過調用不可空類型的構造函數,為這個類型創建空值 Person knuth = new Person("Donald Knuth", new DateTime(1938, 1, 10), null); Console.ReadKey(); } public class Person { DateTime birth; DateTime? death; string name; public TimeSpan Age { get { //當將可空變量同null進行比較時,實際上是在裡面的hasValue屬性 if (death == null) { return DateTime.Now - birth; } else { return death.Value - birth; } } } public Person(string name, DateTime birth, DateTime? death) { this.name = name; this.birth = birth; this.death = death; } }- 對IL代碼的觀察
//if(death == null) //調用HasValue屬性檢查death是否為空 IL_0007: call instance bool valuetype [System.Runtime]System.Nullable`1<valuetype [System.Runtime]System.DateTime>::get_HasValue()//在將null作為DateTime?類型的實參傳遞時 //Person knuth = new Person("Donald Knuth", new DateTime(1938, 1, 10), null); //實際上是調用Nullable的默認構造方法(Initobj) //有參構造方法的調用是newobj IL_003e: initobj valuetype [System.Runtime]System.Nullable`1<valuetype [System.Runtime]System.DateTime>IL_0001: ldstr "Alan Turing" IL_0006: ldc.i4 0x778 //1912 IL_000b: ldc.i4.6 //6 IL_000c: ldc.i4.s 23 //23 IL_000e: newobj instance void [System.Runtime]System.DateTime::.ctor(int32, int32, int32) IL_0013: ldc.i4 0x7a2 //1954 IL_0018: ldc.i4.6 //6 IL_0019: ldc.i4.7 //7 //創建普通的DateTime構造函數 IL_001a: newobj instance void [System.Runtime]System.DateTime::.ctor(int32, int32, int32) //將上面的結果傳給下面這條有一個參數的Nullable的構造函數中 IL_001f: newobj instance void valuetype [System.Runtime]System.Nullable`1<valuetype [System.Runtime]System.DateTime>::.ctor(!0) IL_0024: newobj instance void CSharpInDepth.Person::.ctor(string, valuetype [System.Runtime]System.DateTime, valuetype [System.Runtime]System.Nullable`1<valuetype [System.Runtime]System.DateTime>)
-
-
可空轉換和操作符
- 假如一個非可空的值類型支持一個操作符或一種轉換,而且那個操作符或者轉換只涉及其他非可空類型時,那麼可空的值類型也支持相同的操作符或轉換
- 并且通常是將非可空的值類型轉換成它們的可空等價物
- 可空轉換
- 已知的可空轉換
- null -> T?(隱式)
- T -> T?(隱式)
- T? -> T(顯式)
- 根據上述定義,以非可空類型int和long為例,他們之間有一系列的轉換操作,同樣地,可空類型int?和long?也有這些操作(這些涉及可空類型的轉換稱為「提升轉換」(lifted conversion)。
- S? -> T? (顯/隱,取決於原始轉換)=>可空轉可空
- S -> T?(顯/隱,取決於原始轉換)=>不可空轉可空
- S? -> T(顯)=>可空轉不可空
- 已知的可空轉換
- 可空操作符
- 當非可空的值類型T重載了操作符,可空類型T?將自動擁有相同的操作符,但操作數和結果類型稍有不同(這些操作符稱為「提升操作符」)
- 假如一個非可空的值類型支持一個操作符或一種轉換,而且那個操作符或者轉換只涉及其他非可空類型時,那麼可空的值類型也支持相同的操作符或轉換

-
- 這些操作符的使用存在一些限制
- true/false操作符永遠不會被提升
- 只有操作數是非可空值類型的操作符才會被提升
- 對於一元和二元操作符(相等和關係操作符除外),返回類型必須是一個非可空的值類型
- 對於相等和關係操作符,返回類型必須是bool
- 應用於bool?的&和|操作符有單獨定義的行為
-
int? four = 4; int? five = 5; int? nullInt = null; //Rules //對於所有操作符,操作數的類型都成為它們的可空等價物。 //對於一元和二元操作符,返回類型也為可空類型 //對於相等和關係操作符,返回類型為非可空bool //如果任何一個操作數是空值,就返回一個空值 //進行相等測試時,兩個空值被認為相等 //進行相等測試時,空值和任何非空值被認為不相等 //對於關係操作符,任何一個操作數為空值,返回始終為false //如果沒有空值操作數,自然使用非可空類型的操作符 Console.WriteLine(-nullInt); //提升後:int? -(int? nullInt) Output: null Console.WriteLine(-five); //提升後:int? -(int? five) Output: -5 Console.WriteLine(five + nullInt); //提升後:int? +(int? five, int? nullInt) Output: null Console.WriteLine(five + five); //提升後:int? +(int? five, int? five) Output: 10 Console.WriteLine(nullInt == nullInt); //提升後:bool ==(int? nullInt, int? nullInt) Output: true Console.WriteLine(five == five); //提升後:bool ==(int? five, int? five) Output: true Console.WriteLine(five == nullInt); //提升後:bool ==(int? five, int? nullInt) Output: false Console.WriteLine(five == four); //提升後:bool ==(int? five, int? four) Output: false Console.WriteLine(four < five); //提升後:bool <(int? four, int? five) Output: false Console.WriteLine(nullInt < five); //提升後:bool <(int? nullInt, int? five) Output: false Console.WriteLine(five < nullInt); //提升後:bool <(int? five, int? nullInt) Output: false Console.WriteLine(nullInt < nullInt); //提升後:bool <(int? nullInt, int? nullInt) Output: false Console.WriteLine(nullInt <= nullInt); //提升後:bool <=(int? nullInt, int? nullInt) Output: false -
關於最後一個兩個空值的「小於等於」關係:雖然在相等測試(==)中,空值應該等於空值,但是不能認為一個空值「小於等於」另一個空值,因此為false
-
- 這些操作符的使用存在一些限制
- true/false操作符永遠不會被提升
- 只有操作數是非可空值類型的操作符才會被提升
- 對於一元和二元操作符(相等和關係操作符除外),返回類型必須是一個非可空的值類型
- 對於相等和關係操作符,返回類型必須是bool
- 應用於bool?的&和|操作符有單獨定義的行為
可空邏輯
- 真值表

- 假如bool?的結果取決於某變量的值,而該變量為null,結果必然為null
- bool?的結果是true/false還是null,取決於具體的操作值
可空類型使用as操作符
- 對可空類型使用as操作符的結果:
- 空值(原始引用為錯誤類型 || HasValue == false)
- 有意義的值(Value)
空合并操作符(null coalescing)- ??
- ”??” 操作符
- 獲取兩個操作數,如果左邊操作數 != null,返回該操作數的值;否則返回右邊操作數的值
- 既可用於引用類型,也可用於值類型
- 設有兩個可空類型值first和second,進行first ?? second求值的過程大致為:
- 對first求值
- 如結果非空,返回first.Value
- 否則返回second(!second.HasValue ? null : second.Value)
- 假如second的類型是first的基礎類型(非可空)(int? first, int second)
-
??左側的項必須是可空類型;??右側的項可空 || 非可空
-
最終的結果類型為基礎類型(非可空)
-
int? a = 5; int b = 10; //int? c = a ?? b; 即使聲明為int?,c.GetType()後會發現類型依然為System.Int32 int c = a ?? b; //結果為int類型, 由於a != null,因此結果為 int c = 5; -
表達式從左到右求值,遇到第一個非空的值返回,如果全部為空則返回null
-
CLR對可空值類型的支持
- GetType
- 在可空值類型上調用GetType
-
int? a = 10; Console.WriteLine(a.GetType()); -
會輸出可空實例內的值的類型
- 也就是會輸出Nullable
中的T,而不是Nullable
- 也就是會輸出Nullable
-
- 在可空值類型上調用GetType
- 調用接口方法
-
int? a = 10; int? b = 30; ((IComparable)a).CompareTo(b); -
雖然Nullable
沒有和int一樣實現了IComparable的接口,但是仍然可以將Nullable 轉換成IComparable並通過編譯
-
可空類型的操作性能
- 雖然C#允許開發人員在可空實例上執行轉換、轉型和應用操作符
- 但是操作可空實例會生成大量代碼,運行速度也會低於非可空類型
-
可空實例
-
int? a = 10; int? b = 30; int? result = a + b; -
IL_0001: ldloca.s V_0 IL_0003: ldc.i4.s 10 IL_0005: call instance void valuetype [System.Runtime]System.Nullable`1<int32>::.ctor(!0) IL_000a: ldloca.s V_1 IL_000c: ldc.i4.s 30 IL_000e: call instance void valuetype [System.Runtime]System.Nullable`1<int32>::.ctor(!0) IL_0013: ldloc.0 IL_0014: stloc.s V_6 IL_0016: ldloc.1 IL_0017: stloc.s V_7 IL_0019: ldloca.s V_6 IL_001b: call instance bool valuetype [System.Runtime]System.Nullable`1<int32>::get_HasValue() IL_0020: ldloca.s V_7 IL_0022: call instance bool valuetype [System.Runtime]System.Nullable`1<int32>::get_HasValue() IL_0027: and IL_0028: brtrue.s IL_0036 IL_002a: ldloca.s V_8 IL_002c: initobj valuetype [System.Runtime]System.Nullable`1<int32> IL_0032: ldloc.s V_8 IL_0034: br.s IL_004a IL_0036: ldloca.s V_6 IL_0038: call instance !0 valuetype [System.Runtime]System.Nullable`1<int32>::GetValueOrDefault() IL_003d: ldloca.s V_7 IL_003f: call instance !0 valuetype [System.Runtime]System.Nullable`1<int32>::GetValueOrDefault() IL_0044: add IL_0045: newobj instance void valuetype [System.Runtime]System.Nullable`1<int32>::.ctor(!0)
-
-
非可空實例
-
int c = 10; int d = 30; int result_2 = c + d; -
IL_0061: ldc.i4.s 10 IL_0063: stloc.3 IL_0064: ldc.i4.s 30 IL_0066: stloc.s V_4 IL_0068: ldloc.3 IL_0069: ldloc.s V_4 IL_006b: add IL_006c: stloc.s V_5 IL_006e: ldstr "Result_2: {0}"
-
-
- 但是操作可空實例會生成大量代碼,運行速度也會低於非可空類型
參考書目
- 《CLR via C#》(第4版) Jeffrey Richter
- 《深入理解C#》(第3版) Jon Skeet