C#可空值类型用法详解(附带实例)
在 C# 中,引用类型可以使用空引用表示一个不存在的值,然而值类型不能直接表示为 null:
若要在值类型中表示 null,则必须使用特殊的结构即可空值类型。可空值类型是由值类型后加一个“?”表示的:
T? 会转换为 System.Nullable<T>,它是一个轻量级的不可变的结构体。它只有两个字段,分别代表 Value 和 HasValue。System.Nullable<T> 的本质是很简单的:
T? 的默认值为 null。从 T 到 T? 的转换是隐式的,反之则是显式的。例如:
当 T? 类型的对象装箱后,堆中的装箱值包含的是 T,而非 T?。这种优化方式是可行的,因为装箱值已经是一个可以赋值为 null 的引用类型了。
C# 允许通过 as 运算符对一个可空值类型进行拆箱。如果强制转换出错,那么结果为 null:
在语义上,它会将前面的比较表达式转换为如下语句:
运算符提升意味着可以隐式使用 T 的运算符来处理T?。可以专门针对T?进行运算符重载来实现特殊的空值行为。但是在大多数情况下,最好通过编译器来自动地应用系统的空值逻辑。以下是一些示例:
编译器会根据运算符的分类来执行空值逻辑。下面将介绍这些不同的规则。
类似地,null & false 的结果为 false。这个行为和 SQL 非常相似,以下例子说明了一些其他组合用法:
可空值类型同样适用于 null 条件运算符。在下面的例子中,length 的值为 null:
结合使用 null 合并运算符和 null 条件运算符可最终得到 0 而不是 null:
数据库编程通常需要将类映射到具有可空列的数据表中。如果这些列是字符串类型(例如,Customer 表的 EmailAddress 列),这样就没有任何问题,因为字符串是一种 CLR 的引用类型,所以可以为 null。
然而有些 SQL 列的类型是值类型,因此使用可空值类型可以将这些 SQL 的列映射到 CLR 中。例如:
可空值类型还可以表示支持字段,即所谓的环境属性(ambient property)。如果环境属性的值为 null,则返回父一级的值。例如:
例如,String.IndexOf 在找不到字符时会返回一个特殊的“魔法值” -1:
然而,Array.IndexOf 只有在索引是基于 0 的时候才会返回 -1。实际的规则是 IndexOf 返回比数据下限小 1 的值。在下一个例子中,IndexOf 在没有找到某个元素的时候返回 0:
string s = null; // OK, Reference Type int i = null; // Compile Error, Value Type cannot be null
若要在值类型中表示 null,则必须使用特殊的结构即可空值类型。可空值类型是由值类型后加一个“?”表示的:
int? i = null; // OK, Nullable Type Console.WriteLine(i == null); // True
T? 会转换为 System.Nullable<T>,它是一个轻量级的不可变的结构体。它只有两个字段,分别代表 Value 和 HasValue。System.Nullable<T> 的本质是很简单的:
public struct Nullable<T> where T : struct { public T Value { get; } public bool HasValue { get; } public T GetValueOrDefault(); public T GetValueOrDefault(T defaultValue); }以下代码:
int? i = null; Console.WriteLine(i == null); // True将转换为:
Nullable<int> i = new Nullable<int>(); Console.WriteLine(!i.HasValue); // True当 HasValue 为 false 时,试图获得 Value 会抛出 InvalidOperationException 异常。当 HasValue 为 true 时,GetValueOrDefault() 会返回 Value,否则返回 new T() 或者一个特定的自定义默认值。
T? 的默认值为 null。从 T 到 T? 的转换是隐式的,反之则是显式的。例如:
int? x = 5; // implicit int y = (int)x; // explicit显式强制转换与直接调用可空对象的 Value 属性实质上是等价的。因此,当 HasValue 为 false 的时候将抛出 InvalidOperationException。
当 T? 类型的对象装箱后,堆中的装箱值包含的是 T,而非 T?。这种优化方式是可行的,因为装箱值已经是一个可以赋值为 null 的引用类型了。
C# 允许通过 as 运算符对一个可空值类型进行拆箱。如果强制转换出错,那么结果为 null:
object o = "string"; int? x = o as int?; Console.WriteLine(x.HasValue); // False
运算符优先级提升
Nullable<T> 结构并没有定义诸如 <、>、== 之类的运算符。尽管如此,以下代码仍然能够正常编译和执行:int? x = 5; int? y = 10; bool b = x < y; // true这是因为编译器会从实际值类型借用或者“提升”小于运算符。
在语义上,它会将前面的比较表达式转换为如下语句:
bool b = (x.HasValue && y.HasValue) ? (x.Value < y.Value) : false;换句话说,如果 x 和 y 都有值,那么它会通过 int 的小于运算符做比较。否则它会返回false。
运算符提升意味着可以隐式使用 T 的运算符来处理T?。可以专门针对T?进行运算符重载来实现特殊的空值行为。但是在大多数情况下,最好通过编译器来自动地应用系统的空值逻辑。以下是一些示例:
int? x = 5; int? y = null; // Equality operator examples Console.WriteLine(x == y); // False Console.WriteLine(x == null); // False Console.WriteLine(x == 5); // True Console.WriteLine(y == null); // True Console.WriteLine(y == 5); // False Console.WriteLine(y != 5); // True // Relational operator examples Console.WriteLine(x < 6); // True Console.WriteLine(y < 6); // False Console.WriteLine(y > 6); // False // All other operator examples Console.WriteLine(x + 5); // 10 Console.WriteLine(x + y); // null (prints empty line)
编译器会根据运算符的分类来执行空值逻辑。下面将介绍这些不同的规则。
1) 相等运算符(==和!=)
提升后的相等运算符可以像引用类型那样处理空值,这意味着两个 null 值是相等的:Console.WriteLine(null == null); // True Console.WriteLine((bool?)null == (bool?)null); // True而且:
- 如果只有一个操作数为 null,那么两个操作数不相等;
- 如果两个操作数都不能为 null,则比较它们的 Value。
2) 关系运算符(<、<=、>=、>)
对于关系运算符而言比较 null 操作数是没有意义的。因此比较空值和另外一个空值或非空值的结果都是 false。bool b = x < y; // Translation: bool b = (x.HasValue && y.HasValue) ? (x.Value < y.Value) : false; // b is false (assuming x is 5 and y is null)
3) 其他运算符
当任意一个操作数为 null 时,+、-、*、/、%、&、|、^、<<、>>、+、++、--、! 和 ~ 运算符都会返回 null。SQL 用户是非常熟悉这种模式的:int? c = x + y; // Translation: int? c = (x.HasValue && y.HasValue) ? (int?)(x.Value + y.Value) : null; // c is null (assuming x is 5 and y is null)唯一的例外是计算 bool? 的 & 和 | 运算符。
4) 混合使用可空和非空类型的操作数
混合使用可空或不可空值类型是可行的,这是因为 T 与 T? 之间存在着隐式转换机制:int? a = null; int b = 2; int? c = a + b; // c is null - equivalent to a + (int?)b
在bool?上使用&和|运算符
如果操作数的类型为 bool?,那么 & 和 | 运算符会将 null 作为一个未知值(unknown value)看待。所以 null | true 应当返回 true,因为:- 如果未知值是假的,那么结果为真;
- 如果未知值是真的,那么结果为真。
类似地,null & false 的结果为 false。这个行为和 SQL 非常相似,以下例子说明了一些其他组合用法:
bool? n = null; bool? f = false; bool? t = true; Console.WriteLine(n | n); // (null) Console.WriteLine(n | f); // (null) Console.WriteLine(n | t); // True Console.WriteLine(n & n); // (null) Console.WriteLine(n & f); // False Console.WriteLine(n & t); // (null)
可空值类型和null运算符
可空值类型与 ?? 运算符相辅相成,如以下示例所示:int? x = null; int y = x ?? 5; // y is 5 int? a = null, b = 1, c = 2; Console.WriteLine(a ?? b ?? c); // 1 (first non-null value)在可空值类型上使用 ?? 运算符相当于调用 GetValueOrDefault 方法并提供一个显式的默认值,但变量如果不是 null 的话则不会使用默认值。
可空值类型同样适用于 null 条件运算符。在下面的例子中,length 的值为 null:
System.Text.StringBuilder sb = null; int? length = sb?.ToString().Length;
结合使用 null 合并运算符和 null 条件运算符可最终得到 0 而不是 null:
int length = sb?.ToString().Length ?? 0; // Evaluates to 0 if sb is null
C#可空值类型的应用场景
可空值类型常用来表示未知的值,尤其是在数据库编程中最为常见。数据库编程通常需要将类映射到具有可空列的数据表中。如果这些列是字符串类型(例如,Customer 表的 EmailAddress 列),这样就没有任何问题,因为字符串是一种 CLR 的引用类型,所以可以为 null。
然而有些 SQL 列的类型是值类型,因此使用可空值类型可以将这些 SQL 的列映射到 CLR 中。例如:
// Maps to a Customer table in a database public class Customer { ... public decimal? AccountBalance; }
可空值类型还可以表示支持字段,即所谓的环境属性(ambient property)。如果环境属性的值为 null,则返回父一级的值。例如:
public class Row { ... Grid parent; Color? color; public Color Color { get { return color ?? parent.Color; } set { color = value == parent.Color ? (Color?)null : value; } } }
可空值类型的替代方案
在可空值类型成为 C# 语言的一部分之前,也有许多处理可空值类型的方式。出于历史原因,这些方式现在仍然存在于 .NET 库中,其中一种方式是将一个特定的非空值指定为“空值”。字符串和数组类中就使用了这种方式。例如,String.IndexOf 在找不到字符时会返回一个特殊的“魔法值” -1:
int i = "pink".IndexOf('b'); Console.WriteLine(i); // -1
然而,Array.IndexOf 只有在索引是基于 0 的时候才会返回 -1。实际的规则是 IndexOf 返回比数据下限小 1 的值。在下一个例子中,IndexOf 在没有找到某个元素的时候返回 0:
// Create an array whose lower bound is 1 instead of 0: Array a = Array.CreateInstance(typeof(string), new int[] { 2 }, new int[] { 1 }); a.SetValue("a", 1); a.SetValue("b", 2); Console.WriteLine(Array.IndexOf(a, "c")); // 0注意,指定“魔法值”会造成各种问题,以下列举了一些原因:
- 每一个值类型有不同的空值表示方式。而与之相反,使用可空值类型可以用一种通用模式处理任意的值类型;
- 可能无法找到一个合理的值。例如,在上述的例子中,我们无法总是使用 -1。更早的例子中表示一个未知账号的余额的方式也有相同的问题;
- 如果忘记对魔法值进行测试可能导致长期忽略不正确的数据,直至在后续运行中出现一个出乎意料的结果。而如果忘记测试 HasValue 为 null 的情况,则会马上抛出 InvalidOperationException;
- 没有在类型层面上处理 null 值的能力。类型可以传达程序的意图,并允许编译器检查其正确性,从而和编译器的规则保持一致。