第 68 讲:C# 2 之泛型(二):可空值类型
今天我们来衔接前一节内容说到的 Nullable<T>
类型。这个数据类型在 C# 里是提供了这个 API 的,名字也一样,用法是类似的,因此我们需要单独提出来给大家解释和说明。
Part 1 简化类型声明语法:可空值类型的概念
可空值类型(Nullable Value Type,简称 NVT)是什么呢?它表示一个值类型,但它可以表示“为空”的情况。就像是上一节内容里说到的表格里取出来的单元格为空一样,最终获取到的结果数值为“空”,我们使用的是 null
这个常用的字面量来表达这个数据类型为空的情况。
1-1 可空类型记号 ?
C# 2 里除了泛型的概念外,还为这个数据类型单独设计了一个语法。如果我们要声明一个可空值类型,都不得不写全名:Nullable<T>
,然后把 T
替换为我们的实际值类型,比如 int
。
可问题在于这么写太麻烦了,因为每次都要打这么多字母。因此 C# 提供了一个新的类型记号:?
。之前我们学习过的类型记号一共有三个:
T[]
:表示元素为T
类型的数组;T<>
:表示T
是泛型数据类型;T*
:表示指向一个T
类型的指针。
现在我们可以使用新的记号 ?
来声明一个数据类型:T?
,它表示一个值类型是可空的。请注意,这个 T?
的 T
此时只能是一个值类型。引用类型由于它自身的特性,它自己默认数值就是 null
,因此完全不需要 ?
记号自身就可以表达出 null
的数值,因此,这个记号只对值类型有效:
这样的语法。这个 int?
将直接被翻译成 Nullable<int>
,也就是说,它俩是等价的写法,只不过 int?
更简单一些。没人会因为两个完全一样的语法,却选择了更难书写的 Nullable<int>
吧?
由于它完全等价于 Nullable<int>
,所以:
是原本 int? val = new int?(30);
的等价完整版写法。
1-2 null
字面量在可空值类型里的应用
接着。如果我要赋 Null
类似的数值,那么怎么做呢?C# 的行为是这样的:直接用关键字 null
来临时代替这种情况。也就是说,null
直接表示一个 T?
里的特殊值。
比如这样就行了。
1-3 Nullable<T>
类型自身不允许嵌套使用
另外说一点要注意的地方。按照定义,T?
的 T
可以是任何值类型。但 T?
自己会被翻译成 Nullable<T>
类型,而 Nullable<T>
也是一个值类型。那么是不是就意味着它自己也可以作为 T
而替换过去,使之成为一个嵌套泛型数据类型呢?答案是否定的。因为 Nullable<T>
是特殊的数据类型,它自身代表有 null
数值的值类型。但它自己自身包含 null
数值,所以如果它自己再嵌套进去作为 T?
的 T
出现的话,就显得没有任何意义了。因此,C# 不允许 Nullable<Nullable<T>>
的语法存在,当然,T??
这样的语法也就不存在了。
Part 2 Nullable<T>
类型的常用成员介绍
使用的时候,我们要介绍一下这个类型的真正的 API 有哪些。
2-1 T?(T)
构造器
Nullable<T>
类型(或者以后也可以直接简写成 T?
了)只有一个构造器,传入一个 T
类型的实例作为参数。也就是像是刚才那样,我使用 new int?(30)
来实例化一个 30 这个 int
类型的数据作为 int?
类型的底层数值。
2-2 HasValue
和 Value
属性
贴心的 T?
类型提供了 HasValue
和 Value
属性用于获取里面的数值。和之前我们自己设计的 _realValue
的逻辑类似,HasValue
属性表示获取这个 T?
类型的实例到底是不是包含数值。我们之前是认为,_isNull
为 true
就表示 _realValue
是“没有值”的状态,而这里 T?
类型封装好了一系列的东西,所以 HasValue
可以立刻获取实例是不是包含数值。而 Value
数值的效果和我们设计的 RealValue
属性效果完全一致:如果有数值,就返回结果;否则直接抛出 InvalidOperationException
类型的异常告知用户,因为 T?
不包含值,而你尝试在取它的值。
是这么使用的。
2-3 GetValueOrDefault
实例方法
API 还提供了 GetValueOrDefault
方法,这个方法具有两个重载版本,一个是无参的,一个是带一个 T
类型的参数的。这个方法和我们之前设计的属性 ValueOrDefault
是一致的效果,不过由于有两个重载版本,所以它只和无参这个重载版本是一致的执行效果,而带 T
类型参数的这个重载版本则稍微不一样。
如果我们认为 GetValueOrDefault()
的执行表达式是 _isNull ? default(T) : _realValue
的话,那么带 T
参数的重载版本的执行表达式是 _isNull ? parameter : _realValue
。
2-4 ToString
方法
这个 T?
类型在底层也重写了 object
派生下来的那些方法,不过 Equals
和 GetHashCode
就不多说了,因为我们基本上也用不上它们;而 ToString
稍微可以提一下。
ToString
方法会输出一个 T?
的实际数值。如果 T?
实例里包含的是 null
数值,那么 ToString
也不会因为包含 null
而抛异常,但输出的结果是一个空字符串,因此你可能看不到有任何东西显示出来。
2-5 类型转换器
T?
类型还提供了两个转换器,一个是从 T
转 T?
的,另外一个则是从 T?
转 T
的。在 API 里,从 T
往 T?
转换的是隐式转换,而 T?
到 T
的转换是显式转换。这也就是说,我们如果要给一个 int?
类型赋值 int
数值的时候,是可以直接书写的:
val
是不是真的包含值而非 null
是的,T?
往 T
上强制转换等价于直接调用 Value
属性。因此这里需要你注意,它不会调用 GetValueOrDefault
方法,而是调用 Value
属性。如果 int?
实例不包含任何数值(即 null
)的话,强制转换将会产生 InvalidCastException
异常,表示你的强制转换是失败的。
Part 3 对自带运算符的数据类型,T?
的处理过程
我们常见的 T
的替换数据类型一般就是比如 int
啊、float
啊、bool
这些数据类型。虽然 T
可以被任何值类型所替换,但实际上基本上用不上自定义值类型作为 T
替代的情景。不是说语法不允许,只是很少用。
而正是因为 T
经常被内置值类型所替代,所以 T
类型的运算符处理过程,T?
也具备。换句话说,比如我 int
类型有加法运算,那么 int?
的实例其实也具备加法运算操作,你甚至可以混合加法运算,一个 int
一个 int?
都行。不过,这种运算过程是如何的呢?
C# 是这么设计计算规则的。在操作过程之中,但凡有一个实例是 null
的话,操作就会立刻得到 null
作为结果,否则,将操作的实例的真正数值取出作为处理,并得到结果。
请问,这个例子输出结果是多少?是的,40。
这个例子呢?请注意 (a + b).ToString()
这个表达式。因为 (a + b)
是一个部分,而后面的 .ToString()
是一部分,按照运算符优先级,我们应当先计算 (a + b)
这部分。而按照 C# 语法设计,a
和 b
里有一个是 null
,因此结果为 null
。
问题来了。null
是结果,但它作为表达式结果的实例去调用 ToString
方法,不会抛异常吗?真的不会出现 NullReferenceException
异常吗?是的。可空值类型不会出现这个异常。但是请注意,它作为结果来看的话,因为结果是 null
,所以按照可空值类型的调用 ToString
方法的规则来看,最终输出的结果是一个空字符串。可因为空字符串是什么都没有的字符串,所以输出内容里,你也看不到任何可见字符。
所以,所有运算符的处理规则和运算规则均和这里的操作是一致的,除了……
Part 4 bool?
类型和三值布尔的概念
有一个可空值类型,它可能有些特殊,因为它的处理规则不完全符合上面的所说的那些东西,这个数据类型叫 bool?
。
bool?
类型是 bool
类型的可空版本,也就是说,bool?
包含三个可能取值:true
、false
和 null
,除此之外,别无其它。正是因为它的取值范围只有三个情况,所以它的处理机制有些特殊,也被编译器自身处理和优化掉了。另外,由于它有三个情况可取,所以 bool?
有一个单独的名称叫三值布尔类型(Tri-valued Boolean)。
三值布尔拥有三种情况,而布尔运算有 &
和 |
两种最为常见,在 C# 里,三值布尔运算就显得特别特殊了。在三值布尔运算里,不是一方为 null
结果就一定是 null
。我们来看表格:

该表记录了 x
和 y
两个三值布尔对象的 &
和 |
的结果。可以注意到,true | null
是为 true
而不是为 null
的。
有人问为什么没有异或运算
^
。异或运算的处理机制和 C# 原生的bool
是一致的,而如果其中一方为null
,那么异或运算结果则为null
,它是满足前述内容的运算,因此这里没有单独列出。
这个表格怎么记呢?很简单,不要死记硬背。
首先我们知道基本的不空运算结果,这个不必多说,需要说的也就只有两种情况:null
和正常数值计算,以及 null
和 null
的计算。首先明确一点是 null
和 null
不论是 &
还是 |
,结果都一定是 null
。这个也是符合正常逻辑的:两个对象都表示“没有数值”,那么结果怎么可能会变成有值呢?而一边 null
一边不是 null
的情况只有两种:true
和 null
的运算,以及 false
和 null
的运算,于是表格就只剩下这么一点了:

&
运算符要求严苛一点,因为它需要两个都 true
才能返回 true
,因此有一个 null
我们肯定不会把 null
视为 true
来看,因此 true & null
是 null
。而 |
运算符较为松散,有一个 true
就行。因此,既然我有 true
了,那么我管你剩下那个是不是 null
,我有 true
不就可以了?所以 true | null
是 true
。
接着。false
和 null
的计算行为稍显奇特,这是为了保证数学推导过程的严谨性。我们来使用逻辑运算来看这个处理规则:
其中
是且的意思,而
就是 a 且 b 的意思,而
就是 a 或 b 的意思。
按照这个处理规则进行,我们可以看到我们使用了一次等价变换:德・摩根律。我们参照这个结论表达式 以及对偶的另外一个表达式
来计算
false & null
以及 false | null
。
可以从推导计算里看到,false & null
通过德摩根律迂回了一下之后得到的结果是 false
,而 false | null
也是如此运算,得到的结果是 null
。这就是为什么这两个计算表达式结果会这么奇怪的原因。
除了计算公式别扭以外,使用上和正常的可空值类型是一样的。不过这里我们就不再赘述了,因为操作是一样的,没必要说两遍。
Part 5 判断一个泛型参数的实际类型是否包含 null
值
这是一个好问题。既然说到了可空值类型了,那么我们就得给大家掰扯掰扯如何判断泛型参数的实例是不是 null
,以及泛型参数的实际类型自身是否包含 null
值。
我们都知道,只有引用类型和可空值类型包含一个 null
数值,但在普通的值类型里是不可能有 null
的。但是对于一个泛型参数来说,我们压根不知道它具体是值类型还是引用类型,因此我们无从下手判断是否一个泛型参数作为类型的实例是否为 null
。
实际上,我们可以直接用 ReferenceEquals
或 ==
来和 null
进行比较。由于泛型参数在正常情况下是无从知道它是什么数据类型的,C# 会直接假设为 object
或者它的子类型。注意,这个假设排除掉了指针类型。但这也是前面说过的。正是因为这种假设的存在,所以它必然是一个派生体系上的一环。而 object
这个最终基类型里包含一个 ReferenceEquals
可以判断是否和某个实例引用的地址是相同的,因此我们可以拿这个直接去参与比较。不论是值类型还是引用类型,这个判断 null
都是正确的。可能一些资料或书籍上会直接告诉你,值类型不要使用 ReferenceEquals
方法来比较引用,因为它们要装箱。但我们让一个即使可能是值类型的类型实例判断是否为 null
是可以使用它的,这是因为哪怕它是值类型,装箱之后地址也不可能为 null
。换句话说,只要一个实例是有数值的,那么它不管装箱与否,最终都必然有一个地址数值(值类型会因为装箱而得到一个地址数值,而引用类型自己则就是地址数值),但它一定不可能是 null
,毕竟它是有值的。所以,我们可以利用这一点判 null
。
那么,如何确认一个泛型参数 T
是可空的呢?可空类型包含两个:引用类型和可空值类型。
如代码所示。要想判断实例 obj
是否为 null
,我们的判断次序是先和 null
比较,如果是,那么很显然这个数据类型就是包含 null
的,直接返回 true
即可。
不过,如果它不是 null
,那么它就具有数值,因此我们无从知道它是不是可空的,所以需要继续判断。此时我们会使用反射机制获取类型的信息。这里我们用到一个 typeof(T)
语法来获取一个泛型参数 T
的类型信息。注意这里虽然是泛型参数,但仍然可以使用此语法来判别,因为它最终会被替代为一个实际类型,那么就相当于是把一个实际类型替代到这里。接着,我们使用其中的 IsValueType
属性就可以知道它是否是值类型了。如果它不是值类型,就一定是引用类型或者指针类型,因此这里我们需要判断它是不是指针类型或引用类型。使用 type.IsPointer
可以确认它是不是指针类型。如果是则一定包含 null
(因为指针类型天生就会用到 null
),因此直接返回 true
;否则它不是指针类型后,我们继续使用 !type.IsValueType
来判断它是不是引用类型。因为对 IsValueType
属性的结果取反就意味着它不是值类型。不是值类型的情况只有指针类型或引用类型两种,而指针类型前面已经判断过了,所以这里只剩下一种情况:它是引用类型。而引用类型也自带 null
的情况,因此引用类型也是包含 null
的,因此也返回 true
。
最后,我们使用 Nullable
这个类型里自带的 GetUnderlyingType
来判断一个 Nullable<T>
类型的 T
是什么。如果它是可空值类型的话,这个方法将会返回 T
类型的类型信息实例(即 Type
类型的实例)作为结果,反之会返回 null
(比如它完全就不是 T?
类型,根本无法获取里面 T
的信息)。如果这里我们比较结果 != null
,那么很显然的就是它一定是 T?
类型了,因此我们返回 true
即可。
最后,如果以上条件没有一个满足,那么就说明它是普通的值类型,因此返回 false
,因为普通值类型没有 null
一说。