C#筆記 – 可空類型

為甚麼值類型變量不能是空
  • 非空引用值提供一個訪問對象的路徑,null引用等同「我不引用任何對象」

    • 在內存空間中,引用類型的表示是全零(引用地址)。本質上與其他引用的存儲方式是一樣的。

  • 值本身由一個字節組成

    • 可以將值0-255存儲到變量中。

    • 如果我們在這基礎上加一個位元表示null,總共就要257個位,沒有辦法用一個字節存儲這麼多的值。

C# 1表示空值的方法
  • 魔值

    • 犧牲一個值來表示空值,如Int.MinValue

    • 魔值不浪費任何內存

    • 同時該值將永遠不能被用來表示真正的數據

  • 引用類型包裝

    • 方法一:用object作用變量類型,根據需要進行裝箱/拆箱

    • 方法二:假定值類型A可空,就為它準備一個引用類型B。在引用類型B中,包含值類型A的一個實例變量,並在B中聲明一個隱式轉換操作符。

    • 允許直接使用null

    • 要求在堆上創建對象,可能導致GC困難、內存消耗增加

  • 額外的bool

    • 將值和bool封裝到另一個值類型中

      • 由於也是值類型,因此可以避免GC

      • 通過封裝的值內表示可空性,而不是通過空引用表示

    • 針對每一個值類型創建一個新的類型

    • 如果值因為某種原因要進行裝箱,那不管它是否被認為是空值,都要像平時那樣進行裝箱

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<T>仍然是值類型,所以實例仍然是在棧上

      • 在C#中,在值類型後跟上”?”使之等價於對應的可空值類型

        • int? a;
          Nullable<int> a;
        • 以上兩者相互等價

  • 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<T>,CLR會將Nullable<T>的值設為null

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

語法糖
  • ? 修飾符
    • 使用?修飾符修飾的值類型變量與使用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]
  • 使用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

可空邏輯
  • 真值表

  • 假如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>中的T,而不是Nullable<T>

  • 調用接口方法

    • int? a = 10;
      int? b = 30;
      ((IComparable)a).CompareTo(b);
    • 雖然Nullable<int>沒有和int一樣實現了IComparable的接口,但是仍然可以將Nullable<T>轉換成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