欢迎光临散文网 会员登陆 & 注册

第 68 讲:C# 2 之泛型(二):可空值类型

2021-11-29 21:39 作者:SunnieShine  | 我要投稿

今天我们来衔接前一节内容说到的 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 HasValueValue 属性

贴心的 T? 类型提供了 HasValueValue 属性用于获取里面的数值。和之前我们自己设计的 _realValue 的逻辑类似,HasValue 属性表示获取这个 T? 类型的实例到底是不是包含数值。我们之前是认为,_isNulltrue 就表示 _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 派生下来的那些方法,不过 EqualsGetHashCode 就不多说了,因为我们基本上也用不上它们;而 ToString 稍微可以提一下。

ToString 方法会输出一个 T? 的实际数值。如果 T? 实例里包含的是 null 数值,那么 ToString 也不会因为包含 null 而抛异常,但输出的结果是一个空字符串,因此你可能看不到有任何东西显示出来。

2-5 类型转换器

T? 类型还提供了两个转换器,一个是从 TT? 的,另外一个则是从 T?T 的。在 API 里,从 TT? 转换的是隐式转换,而 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# 语法设计,ab 里有一个是 null,因此结果为 null

问题来了。null 是结果,但它作为表达式结果的实例去调用 ToString 方法,不会抛异常吗?真的不会出现 NullReferenceException 异常吗?是的。可空值类型不会出现这个异常。但是请注意,它作为结果来看的话,因为结果是 null,所以按照可空值类型的调用 ToString 方法的规则来看,最终输出的结果是一个空字符串。可因为空字符串是什么都没有的字符串,所以输出内容里,你也看不到任何可见字符。

所以,所有运算符的处理规则和运算规则均和这里的操作是一致的,除了……

Part 4 bool? 类型和三值布尔的概念

有一个可空值类型,它可能有些特殊,因为它的处理规则不完全符合上面的所说的那些东西,这个数据类型叫 bool?

bool? 类型是 bool 类型的可空版本,也就是说,bool? 包含三个可能取值:truefalsenull,除此之外,别无其它。正是因为它的取值范围只有三个情况,所以它的处理机制有些特殊,也被编译器自身处理和优化掉了。另外,由于它有三个情况可取,所以 bool? 有一个单独的名称叫三值布尔类型(Tri-valued Boolean)。

三值布尔拥有三种情况,而布尔运算有 &| 两种最为常见,在 C# 里,三值布尔运算就显得特别特殊了。在三值布尔运算里,不是一方为 null 结果就一定是 null。我们来看表格:

该表记录了 xy 两个三值布尔对象的 &| 的结果。可以注意到,true | null 是为 true 而不是为 null 的。

有人问为什么没有异或运算 ^。异或运算的处理机制和 C# 原生的 bool 是一致的,而如果其中一方为 null,那么异或运算结果则为 null,它是满足前述内容的运算,因此这里没有单独列出。

这个表格怎么记呢?很简单,不要死记硬背。

首先我们知道基本的不空运算结果,这个不必多说,需要说的也就只有两种情况:null 和正常数值计算,以及 nullnull 的计算。首先明确一点是 nullnull 不论是 & 还是 |,结果都一定是 null。这个也是符合正常逻辑的:两个对象都表示“没有数值”,那么结果怎么可能会变成有值呢?而一边 null 一边不是 null 的情况只有两种:truenull 的运算,以及 falsenull 的运算,于是表格就只剩下这么一点了:

& 运算符要求严苛一点,因为它需要两个都 true 才能返回 true,因此有一个 null 我们肯定不会把 null 视为 true 来看,因此 true & nullnull。而 | 运算符较为松散,有一个 true 就行。因此,既然我有 true 了,那么我管你剩下那个是不是 null,我有 true 不就可以了?所以 true | nulltrue

接着。falsenull 的计算行为稍显奇特,这是为了保证数学推导过程的严谨性。我们来使用逻辑运算来看这个处理规则:

%5Cbegin%7Balign%7D%0Ax%20%5Cland%20y%20%26%3D%20z%5C%5C%0A%5Ctext%7BNegation%7D%5C%20%5Cdownarrow%5C%5C%0A!(x%20%5Cland%20y)%20%26%3D%20!z%5C%5C%0A%5Ctext%7BDe%20Morgan's%20laws%7D%5C%20%5Cdownarrow%5C%5C%0A!x%20%5Clor%20!y%20%26%3D%20!z%5C%5C%0A%5Ctext%7BNegation%20again%7D%5C%20%5Cdownarrow%5C%5C%0A!(!x%20%5Clor%20!y)%20%26%3D%20z%0A%5Cend%7Balign%7D

其中 %5Cland 是且的意思,而 %5Clor 是或的意思。比如 a%20%5Cland%20b 就是 ab 的意思,而 a%20%5Clor%20b 就是 ab 的意思。

按照这个处理规则进行,我们可以看到我们使用了一次等价变换:德・摩根律。我们参照这个结论表达式 !(!x%20%5Clor%20!y)%20%3D%20z 以及对偶的另外一个表达式 !(!x%20%5Cland%20!y)%20%3D%20z 来计算 false & null 以及 false | null

%5Cbegin%7Balign%7D%0A%5Ctext%7Bfalse%7D%5C%20%5C%26%5C%20%5Ctext%7Bnull%7D%20%26%3D%20!(!(%5Ctext%7Bfalse%7D%5C%20%5C%26%5C%20%5Ctext%7Bnull%7D))%5C%5C%0A%26%3D%20!(!%5Ctext%7Bfalse%7D%5C%20%7C%5C%20!%20%5Ctext%7Bnull%7D)%5C%5C%0A%26%3D%20!(%5Ctext%7Btrue%7D%5C%20%7C%5C%20%5Ctext%7Bnull%7D)%5C%5C%0A%26%3D%20!%5Ctext%7Btrue%7D%5C%5C%0A%26%3D%20%5Ctext%7Bfalse%7D%5C%5C%0A%5C%5C%0A%5Ctext%7Bfalse%7D%5C%20%7C%5C%20%5Ctext%7Bnull%7D%20%26%3D%20!(!(%5Ctext%7Bfalse%7D%5C%20%7C%5C%20%5Ctext%7Bnull%7D))%5C%5C%0A%26%3D%20!(!%5Ctext%7Bfalse%7D%5C%20%5C%26%5C%20!%5Ctext%7Bnull%7D)%5C%5C%0A%26%3D%20!(%5Ctext%7Btrue%7D%5C%20%5C%26%5C%20%5Ctext%7Bnull%7D)%5C%5C%0A%26%3D%20!%5Ctext%7Bnull%7D%5C%5C%0A%26%3D%20%5Ctext%7Bnull%7D%0A%5Cend%7Balign%7D

可以从推导计算里看到,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 一说。


第 68 讲:C# 2 之泛型(二):可空值类型的评论 (共 条)

分享到微博请遵守国家法律