深入理解C#(第3版)-- 【C#2】第4章 可空类型(学习笔记)
4.1 没有值时怎么办
4.1.1 为什么值类型的变量不能是null
对于一个引用类型的变量来说,其值是一个引用。而值类型变量的值是它本身的真实数据。可以认为,一个非空引用值提供了访问一个对象的途径。然而,null相当于一
个特殊的值,它意味着我不引用任何对象。
如果把引用想象为URL,null就大致相当于about:blank 。内存中用全零来表示null(这也解释了为什么所有引用类型的默认值是null——清除一整块内存的开销最低,所以对象选择用这种方式来初始化),但它本质上采用的是和其他引用一样的方式来存储的。
4.1.2 在C#1中表示空值的模式
模式1 :魔值
第一种模式是牺牲一个值来表示空值,主要是作为DateTime 的解决方案,因为很少有人希望自己的数据库中真的包含公元元年中的某个日期。
模式2 :引用类型包装
第二个解决方案可以采取两种形式。较简单的形式是直接用object 作为变量类型,并根据需要进行装箱和拆箱。较复杂(同时更吸引人)的形式是假定值类型A 可空,就为它准备一个引用类型B 。在引用类型B 中,包含值类型A 的一个实例变量。B 中还声明了隐式转换操作符,允许将B 转换成A ,以及将A 转换成B 。
虽然允许直接使用null,但都要求在堆上创建对象。
模式3 :额外的布尔标志
最后一种模式的基本思路是使用一个普通的值类型的值,同时用另一个值(一个布尔标志)来表示值是“真正”存在,还是应该被忽略。同样,有两种方式来实现这个解决方案。要么在代码中维护两个单独的变量,要么将“值和标志”封装到另一个值类型中。
这种方式存在相同的缺点:针对想要处理的每个值类型,都必须创建一个新的类型。最后一种模式(采用封装方式)实际就是C# 2 的可空类型的工作方式。
4.2 System.Nullable<T> 和System.Nullable
可空类型的核心部分是System.Nullable<T>
4.2.1 Nullable<T>简介
Nullable<T> 是一个泛型类型。类型参数T有一个值类型约束,所以不能使用Nullable<Stream> 。
不能使用另一个可空类型作为实参,Nullable<Nullable<int>> 是不允许的。
对于任何具体的可空类型来说,T的类型称为可空类型的基础类型(underlying type)。
记住,Nullable<T>仍然为值类型。
最后,框架提供了两个转换。首先,是T 到Nullable<T> 的隐式转换。转换结果为一个HasValue 属性为true的实例。同样,Nullable<T> 可以显式转换为T ,其作用与Value属性相同,在没有真正的值可供返回时将抛出一个异常。
包装(wrapping)和拆包(unwrapping)
将T 的实例转换成Nullable<T> 的实例的过程在C#语言规范中称为包装,相反的过程则称为拆包。
代码清单4-1 使用Nullable<T> 的各个成员
static void Display(Nullable<int> x) { Console.WriteLine("HasValue: {0}", x.HasValue); if (x.HasValue) { Console.WriteLine("Value: {0}", x.Value); Console.WriteLine("Explicit conversion: {0}", (int)x); } Console.WriteLine("GetValueOrDefault(): {0}", x.GetValueOrDefault()); Console.WriteLine("GetValueOrDefault(10): {0}", x.GetValueOrDefault(10)); Console.WriteLine("ToString(): \"{0}\"", x.ToString()); Console.WriteLine("GetHashCode(): {0}", x.GetHashCode()); Console.WriteLine(); } //包装等于5 的值 Nullable<int> x = 5; x = new Nullable<int>(5); Console.WriteLine("Instance with value:"); Display(x); //构造没有值的实例 x = new Nullable<int>(); Console.WriteLine("Instance without value:"); Display(x);
4.2.2 Nullable<T>装箱和拆箱
请牢记,Nullable<T> 是一个结构——一个值类型!
已装箱的值可以拆箱成普通类型,或者拆箱成对应的可空类型。
代码清单4-2 可空类型的装箱和拆箱行为
Nullable<int> nullable = 5; //装箱成“有值的可空类型的实例” object boxed = nullable; Console.WriteLine(boxed.GetType()); //拆箱成非可空变量 int normal = (int)boxed; Console.WriteLine(normal); //拆箱成可空变量 nullable = (Nullable<int>)boxed; Console.WriteLine(nullable); //装箱成“没有值的可空类型的实例” nullable = new Nullable<int>(); boxed = nullable; Console.WriteLine(boxed == null); //拆箱成可空变量 nullable = (Nullable<int>)boxed; Console.WriteLine(nullable.HasValue);
4.2.3 Nullable<T>实例的相等性
Nullable<T> 覆盖了object.Equals(object)
调用first.Equals(second)的具体规则如下:
如果first没有值,second为null,它们就是相等的;
如果first没有值,second不为null,它们就是不相等的;
如果first有值,second为null,它们就是不相等的;
否则,如果first 的值等于second ,它们就是相等的。
注意,我们不必考虑second是另一个Nullable<T>的情况,因为装箱规则杜绝了此类情况的发生。second的类型是object,所以如果要成为Nullable<T>,它就必须进行装箱。而前面提到,对一个可空实例进行装箱,会创建非可空类型的一个“箱子”,或者返回一个空引用。
4.2.4 来自非泛型Nullable 类的支持
Nullable 类提供了3个方法:
public static int Compare<T>(Nullable<T> n1, Nullable<T> n2) public static bool Equals<T>(Nullable<T> n1, Nullable<T> n2)
上述每个方法返回的值都遵从.NET的约定:空值与空值相等,小于其他所有值。
System.Nullable的第3个方法不是泛型的!其签名如下:
public static Type GetUnderlyingType(Type nullableType)
如果参数是一个可空类型,方法就返回它的基础类型;否则就返回null。
4.3 C#2为可空类型提供的语法糖
4.3.1 ?修饰符
可空类型的?修饰符是指定可空类型的一种快捷方式。所以,现在不需要写Nullable<byte>,写byte? 就可以了。
4.3.2 使用null进行赋值和比较
C#编译器允许使用null在比较和赋值时表示一个可空类型的空值。
代码清单4-4 Person类的部分代码,其中包含了年龄的计算
class Person { DateTime birth; DateTime? death; string name; public TimeSpan Age { get { if (death == null) { return DateTime.Now - birth; } else { return death.Value - birth;//拆包以进行计算 } } } public Person(string name,DateTime birth,DateTime? death) { this.birth = birth; this.death = death; this.name = name; } } ... Person turing = new Person("Alan Turing ", new DateTime(1912, 6, 23), new DateTime(1954, 6, 7)); Person knuth = new Person("Donald Knuth ", new DateTime(1938, 1, 10), null);
为什么一定要对值进行拆包处理呢?为什么不能直接返回death-birth 呢?
4.3.3 可空转换和操作符
假如一个非可空的值类型支持一个操作符或者一种转换,而且那个操作符或者转换只涉及其他非可空的值类型时,那么可空的值类型也支持相同的操作符或转换,并且通常是将非可空的值类型转换成它们的可空等价物。
1. 涉及可空类型的转换
null到T?的隐式转换;
T 到T?的隐式转换;
T?到T的显式转换。
现在来看看类型所支持的预定义转换和用户自定义转换。例如,int 到long存在一个预定义转换。假如允许从非可空值类型(S)转换成另一个非可空值类型(T),那么同时允许进行以下转换:
S?到T?(可能是显式或隐式的,具体取决于原始转换);
S 到T?(可能是显式或隐式的,具体取决于原始转换);
S?到T(总是显式的)。
对于用户自定义的转换,这些涉及可空类型的额外转换称为提升转换(lifted conversion )。
2. 涉及可空类型的操作符
C#允许重载以下操作符:
一元:+ ++ - -- ! ~ truefalse
二元:+ - * / % & | ^ << >>
相等:== !=
关系:< > <= >=
当为非可空的值类型T重载了上述操作符之后,可空类型T?将自动拥有相同的操作符,只是操作数和结果的类型稍有不同。这些操作符称为提升操作符——不管它们是预定义的操作符(比如对数值类型执行加法运算),还是用户自定义的操作符(比如将一个TimeSpan 加到一个DateTime 上)。提升操作符在使用时存在着一些限制:
true和false 操作符永远不会被提升,这两个操作符本身就十分少用,所以这个限制对我们的影响不大;
只有操作数是非可空值类型的操作符才会被提升;
对于一元和二元操作符(相等和关系操作符除外),返回类型必须是一个非可空的值类型;
对于相等和关系操作符,返回类型则必须是bool;
应用于bool?的&和|操作符有单独定义的行为
对于所有操作符,操作数的类型都成为它们的可空等价物。对于一元和二元操作符,返回类型也成为可空类型。如果任何一个操作数是空值,就返回一个空值。相等和关系操作符的返回类型为非可空布尔型。进行相等测试时,两个空值被认为相等,空值和任何非空值被认为不等。
对于关系操作符,如果它的任何一个操作数是空值,那么返回的始终是false。如果没有任何操作数是空值,那么自然应该使用非可空类型的操作符。
表 达 式 | 经提升的操作符 | 结 果 |
-nullInt |
int? –(int? x) |
null |
在这个表格中,恐怕最让人不解的就是最后一行,尽管这两个空值真的相等(如第5行所示),但却不能认为一个空值“小于或等于”另一个空值。是不是很奇怪?但根据我的经验,这个“矛盾”在实际应用中并不会造成任何问题。
int i = 5; if (i == null) { Console.WriteLine ("Never goingto happen"); }
编译器看到了==左侧的int 表达式,又看到了右侧的null,并知道两者都存在到int?的一个隐式转换。由于对两个int?值进行比较是完全有效的,所以代码不会产生错误——而只是警告。
为什么在代码清单4-4中要使用death.Value- birth,而不是直接使用death-birth。根据前面描述的规则,death-birth这个表达式是可以使用的,但结果就会是一个TimeSpan?,而不是一个TimeSpan 。
4.3.4 可空逻辑
表4-2 逻辑操作符&、|、^和!应用于bool? 类型时的真值表
x | y | x&y | x|y | x^y | !x |
true |
true |
true |
true |
false |
false |
记住:这是C#特有的!
有必要记住,本节讨论的提升操作符和转换,还有bool?逻辑,它们都是由C#编译器提供的,而不是由CLR 或者框架本身提供的。
4.3.5 对可空类型使用as操作符
在C# 2 之前,as操作符只能用于引用类型。而在C# 2 中,它也可以用于可空类型。其结果为可空类型的某个值——空值(如果原始引用为错误类型或空)或有意义的值。
static void PrintValueAsInt32(object o) { int? nullable = o as int?; Console.WriteLine(nullable.HasValue ? nullable.Value.ToString() : "null"); } ... PrintValueAsInt32(5);//生成5 PrintValueAsInt32("some string");//生成null
4.3.6 空合并操作符
空合并(null coalescing)操作符,用两个操作数之间的?? 符号来表示。
在对first ?? second求值时,大致会经历以下步骤:
(1) 对first进行求值;
(2) 如结果非空,则该结果就是整个表达式的结果;
(3) 否则求second的值,其结果作为整个表达式的结果。
之所以说“大致”,是因为在语言规范的完整描述中,有许多情况都涉及first和second 的类型转换问题。
下面的代码是完全合法的:
int? a = 5; int b = 10; int c = a ?? b;
??操作符的另外两个特性增强了它的可用性。首先,它并非只能用于可空值类型,还能应用于引用类型。
送货问题时,为了找到联系人,C# 1 的代码可能会这样写:
Address contact = user.ContactAddress; if (contact == null) { contact = order.ShippingAddress; if (contact == null) { contact = user.BillingAddress; } }
如果使用?? 操作符,代码就会变得相当直观易懂:
Address contact = user.ContactAddress ?? order.ShippingAddress ?? user.BillingAddress;
4.4 可空类型的新奇用法
4.4.1 尝试一个不使用输出参数(out)的操作
代码清单4-5 TryXXX模式的备选实现
static int? TryParse(string text) { int ret; if (int.TryParse(text, out ret))//使用输出参数的经典调用 { return ret; } else { return null; } } ... int? parsed = TryParse("Not valid");//可空调用 if (parsed != null) { Console.WriteLine ("Parsed to {0}", parsed.Value); } else { Console.WriteLine ("Couldn't parse"); }
或者使用元组(Tuple)
4.4.2 空合并操作符让比较不再痛苦
假定已经有一个恰当的Product类型,那么在C# 1 中,可以像下面这样写比较代码:

public int Compare(Product first, Product second) { // Reverse comparison of popularity to sort descending int ret = second.Popularity.CompareTo(first.Popularity); if (ret != 0) { return ret; } ret = first.Price.CompareTo(second.Price); if (ret != 0) { return ret; } return first.Name.CompareTo(second.Name); }
代码清单4-6 提供“部分比较”的辅助类

public static class PartialComparer { public static int? Compare<T>(T first, T second) { return Compare(Comparer<T>.Default, first, second); } public static int? Compare<T>(IComparer<T> comparer, T first, T second) { int ret = comparer.Compare(first, second); return ret == 0 ? new int ? () : ret; } public static int? ReferenceCompare<T>(T first, T second) whereT : class { return first == second ? 0 : first == null ? -1 : second == null ? 1 : new int? (); } } public int Compare(Product first, Product second) { return PC.ReferenceCompare(first, second) ?? // Reverse comparison of popularity to sort descending PC.Compare(second.Popularity, first.Popularity) ?? PC.Compare(first.Price, second.Price) ?? PC.Compare(first.Name, second.Name) ?? 0; }
空值和条件操作符
在第二个Compare方法中,我在返回空值时使用了new int?(), 而不是null,你可能会对此感到诧异。但是条件操作符要求它的第二个和第三个操作数
要么为相同的类型,要么存在隐式转换。