← 筆記

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仍然是值類型,所以實例仍然是在棧上
      • 在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,CLR會將Nullable的值設為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,而不是Nullable
  • 調用接口方法
    •   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