前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >[深入解析C#] 可空值类型

[深入解析C#] 可空值类型

作者头像
科控物联
发布2022-03-29 16:24:33
2.3K0
发布2022-03-29 16:24:33
举报
文章被收录于专栏:科控自动化

2.2 可空值类型

Tony Hoare于1965年在Algol语言中首次引入了null引用的概念,后来他把这项举措称为“十亿美金的过失”。无数开发人员饱受NullReferenceException(.NET)、NullPointerException(Java)等的折磨。由于此类问题的普遍性,Stack Overflow上有大量与之相关的典型问题。既然可空特性如此声名狼藉,为何C# 2以及.NET 2.0要引入可空值类型呢?

在深入可空值类型的实现细节之前,首先看看它可以解决哪些问题,以前又是如何解决这些问题的。

2.2.1 目标:表达信息的缺失

有时我们需要一种变量来保存某种信息,但是相关信息并不需要时刻都“在场”,例如以下几种场景。

为客户订单建模,订单中包含公司信息一栏,但并不是所有人都以公司名义提交订单。

为个人信息建模,个人信息中包含生卒年月,但并不是每个人都有卒年信息。

为某款产品进行筛选器建模,筛选条件中包含产品的价格范围,但是客户可能并没有给出产品的最高价格。

上述场景都指向了一个需求,那就是表示“未提供的值”。即便当前我们能够获得所有信息,但依然需要为信息缺失的可能情况建模,因为在某些场景中,获得的信息可能是不完整的。在第2个场景中,我们甚至可能连某个人的出生日期也不知道,可能系统刚好没有登记或者是其他情况。有时我们还需要详细区分哪些信息是一定会缺失的,哪些信息是不知是否会缺失的。不过在多数情况下,只需要能够表达出“信息

缺失”就足够了。

对于引用类型,C#语言已经提供了表示其信息缺失的方法:null引用。假设有一个Company类和一个Order类,Order类中有一个与公司信息关联的引用。当客户没有指定具体的公司信息时,就可以把该引用设为null。

而对于值类型,C# 1中并没有相应的表示null值的方法,当时普遍采用下面两种方式实现。

当数据缺失时,采用预设值。比如第3个场景中的价格筛选器,当没有指定最高价格时,可以采用decimal.MaxValue作为默认的最大值。

单独维护一个布尔型的标志来表示其他字段是实际值还是默认值,这样在访问字段前先检查该标志,即可知道该字段当前值是否有效。

然而以上两种方式都不太理想。第1种方式挤压了有效值的范围(decimal类型还没什么太大问题,但如果是byte类型,就必须覆盖所有取值范围)。第2种方式则会导致很多冗余和逻辑重复。

更严重的是,这两种方式都容易出错,因为二者都需要在使用前检查变量。不经过检查,就无法知晓变量是否为有效值,之后代码可能一直默默地使用错误的数据,错误地执行,并把这些错误传递给系统其他部分。这种“静默”的失败是最棘手的,因为很难追踪和撤销。相对而言,能够在执行路径中明确抛出异常会好很多。

可空值类型封装了前面第2种方式:为每个值类型维护一个额外的标志,用该标志来指示当前值是否可用。封装这一步是关键:它把对值类型访问的安全性和易用性结合了起来。如果当前访问的值是无效的,抛出异常即可。可空值类型维持了原有类型的对外使用方式不变,还具备表达信息缺失的能力。这样的实现方式既减轻了开发人员的编码负担,也保证了类库开发人员设计API时符合语法标准。

有了这些基础概念,下面看一下framework和CLR为实现可空值类型提供了哪些支持。讲解完这部分内容后,还会介绍C#引入的一些特性,这些特性可以简化可空值类型的使用方式。

2.2.2 CLR和framework的支持:Nullable<T>结构体

可空值类型特性背后的核心要素是Nullable<T>结构体。Nullable<T>的一个早期版本如下所示:

代码语言:javascript
复制
public struct Nullable<T> where T : struct <------ 泛型结构体,其类型约束为非空值类型
{
    private readonly T value;
    private readonly bool hasValue;


    public Nullable(T value) <------ 提供了值的构造器
    {
        this.value = value;
        this.hasValue = true;
    }


    public bool HasValue { get { return hasValue; } } <------ 用于检查值是否存在的属性


    public T Value  (本行及以下10行) 访问值,如果值不存在则抛出异常
    {
        get
        {
            if (!hasValue)
            {
                throw new InvalidOperationException();
            }
            return value;
        }
    }
}

以上代码显示:该结构体声明了唯一的构造器,并将hasValue的初始值设为true,该结构体类型还隐含了一个无参构造器(结构体类型的共性)。无参构造器则会将hasValue的初始值设为false,将value的初始值设为T类型的默认值:Nullable<int> nullable = new Nullable<int>();

Console.WriteLine(nullable.HasValue); <------ 打印结果:FalseNullable<T>中的where T : struct约束表示T可以是除Nullable<T>外的任意值类型,原始类型、枚举、系统内置结构体和用户自定义结构体等都满足该约束,因此以下写法均合法:Nullable<int>Nullable<FileMode>Nullable<Guid>

Nullable<LocalDate>(来自Noda Time项目)

以下写法皆非法:Nullable<string>(string是引用类型);Nullable<int[]>(数组是引用类型,与内部元素是否是值类型无关);Nullable<ValueType>(ValueType本身并不是值类型);Nullable<Enum>(Enum本身也不是值类型);Nullable<Nullable<int>>(Nullable<int>是可空类型本身);Nullable<Nullable<Nullable<int>>>(将可空类型嵌套也没有用)。

在Nullable<T>中,T称为基础类型,比如Nullable<int>的基础类型是int。

至此,已经可以在没有CLR、framework或语言的支持下,通过Nullable<T>类解决之前那个价格筛选器的问题了:

代码语言:javascript
复制
public void DisplayMaxPrice(Nullable<decimal> maxPriceFilter)
{
    if (maxPriceFilter.HasValue)
    {
        Console.WriteLine("Maximum price: {0}", maxPriceFilter.Value);
    }
    else
    {
        Console.WriteLine("No maximum price set.");
    }
}

以上代码可谓良质,使用变量前会对其进行检查。如果没有检查变量或者检查错了对象会怎么样呢?即使这样也无须担忧,因为当HasValue为false时,任何访问maxPriceFilter的操作都会引发异常。说明 虽然此前已经强调过,不过现在仍有必要重申一下:语言的进步不仅仅体现在让编码变得更简单,还体现在能够让开发人员编写出更健全的代码,或者可以降低错误后果的严重性。

另外,Nullable<T>结构体还提供了如下一些方法和运算符。

无参数的GetValueOrDefault()方法负责返回结构体中的值,如果HasValue是false,则返回默认值。

带参数的GetValueOrDefault(T defaultValue)方法同样负责返回结构体中的值,如果HasValue是false,则返回由实参指定的默认值。Nullable<T>重写了object类的Equals(object)和GetHashCode()方法,使其行为更加明确:首先比较HasValue属性;当两个比较对象的HasValue均为true时,再比较Value属性是否相等。

可以执行从T到Nullable<T>的隐式类型转换。该转换总是会返回对应的可空值,并且其HasValue为true。该隐式转换等同于调用带参数的构造器。

可以执行从Nullable<T>到T的显式类型转换。当HasValue为true时返回封装于其中的值,当HasValue为false时则抛出InvalidOperationException。该转换等同于使用Value属性。

后面讲到语言支持部分时,还会继续讨论类型转换。至此,CLR需要做的事情,就是保证struct类型约束。CLR针对可空值类型还提供了一项帮助:装箱(boxing)。装箱行为

当涉及装箱行为时,可空值类型和非可空值类型的行为有所不同。当非可空值类型被装箱时,返回结果的类型就是原始的装箱类型,例如:int x = 5;

object o = x;o是对“装箱int”对象的引用。在C#中,“装箱int”和int之间的区别通常是不可见的:如果执行o.GetType(),返回的Type值会和typeof(int)的结果相同。诸如C++/CLI这样的语言,则允许开发人员对装箱前后的类型加以区分。

然而可空值类型并没有直接对等的装箱类型。Nullable<T>类型的值进行装箱后的结果,视HasValue属性的值而定:

如果HasValue为false,那么结果是一个null引用;

如果HasValue为true,那么结果是“装箱T”对象的引用。

代码清单2-9展现了以上两点。代码清单2-9 可空值类型的装箱效果

代码语言:javascript
复制
Nullable<int> noValue = new Nullable<int>();
object noValueBoxed = noValue; <------ 值的装箱操作,HasValue为false
Console.WriteLine(noValueBoxed == null); <------ 打印结果:True。装箱操作的结果是null引用


Nullable<int> someValue = new Nullable<int>(5);
object someValueBoxed = someValue; <------ 值的装箱操作,HasValue为true
Console.WriteLine(someValueBoxed.GetType());

<------ 打印结果:System.Int32,装箱操作的结果是装箱后的int

这正是理想的装箱行为,不过它有一个比较奇怪的副作用:在System.Object中声明的GetType()方法为非虚方法(不能重写),对某个值类型调用GetType()方法时总会先触发一次装箱操作。该行为或多或少会影响效率,但是还不至于造成困扰。如果对可空值类型调用GetType(),要么会引发NullReferenceException,要么会返回对应的非可空值类型,如代码清单2-10所示。代码清单2-10 可空值类型调用GetType方法会得到奇特的结果

代码语言:javascript
复制
Nullable<int> noValue = new Nullable<int>();
// Console.WriteLine(noValue.GetType()); <------ 会抛出NullReferenceException
Nullable<int> someValue = new Nullable<int>(5);
Console.WriteLine(someValue.GetType());

<------ 打印结果:System.Int32。与调用typeof(int)得到的结果一致

除了framework和CLR对可空值类型的支持,C#语言还有其他设计来保证可空值类型的易用性。

2.2.3 语言层面支持

如果当初C# 2发布时只提供了struct类型约束来让编译器只知道可空值类型,简直不可想象。C#团队完全可以给可空值类型特性提供这种最基本的支持。当初若只提供了最基本的支持,不知会有多少局促、困顿,那些为了将可空值类型融入语言标准而增加的特性就更令人心生敬意了。下面从一个最简单特性开始:可空值类型命名的简化。?后缀

Nullable<T>类型有一个简化版的写法,就是在类型名后添加?后缀。两种写法效果等同,而且该写法对简版类型名(int、double等)和全版类型名都适用。下面4个声明完全等价:

代码语言:javascript
复制
Nullable<int> x;
Nullable<int32> x;
int? x;
int32 x;

上述4种写法任意组合、混用都没有问题,它们产生的IL代码没有任何区别。在实际编码中,我一贯使用?写法,不过不同的团队或许有不同的编码习惯。由于?在文字内容中会引起歧义,因此之后我只在代码中使用?符号,其他地方仍使用Nullable<T>。

这应该是C#语言中最简单的一项改进了,本章后续内容也将贯彻“编写更简洁的代码”这一主题。?后缀用于简化类型的表达,下一个特性则用于简化值的表达。

2.null字面量

C# 1中null表达式永远代指一个null引用。到了C# 2,null的含义扩展了:或者表示一个null引用,或者表示一个HasValue为false的可空类型的值。null值可用于赋值、函数实参以及比较等任何地方。有一点需要强调:当null用于可空值类型时,它表示HasValue为false的可空类型的值,而不是null引用。null引用和可空值类型不容易辨明,例如以下两行代码是等价的:

代码语言:javascript
复制
int? x = new int?();


int? x = null;

一般我更倾向于使用null(第2种写法)而不是显式调用无参构造函数。不过当涉及比较逻辑时,这两种写法就不容易抉择了,例如:if (x != null)

if (x.HasValue)

对于书写习惯上的偏好,我自己也很难一以贯之。不是说保持一致的编码风格不重要,只是就这部分内容来说,确实影响不大。可自由切换编码风格,无须考虑兼容性问题。转换

前面讲过,存在从T到Nullable<T>的隐式类型转换,以及从Nullable<T>到T的显式类型转换。此外,C#语言还允许链式转换。对于任意两个非可空的值类型S和T,

有操作数是非可空值类型的运算符才能被提升;

对于一元运算符和二元运算符(等价运算符和关系运算符除外),原运算符的返回类型必须是非可空的值类型;

对于等价运算符和关系运算符,原运算符的返回类型必须是bool类型;

作用于Nullable<bool>的&和|运算符具有单独定义的行为,稍后介绍。

对于所有运算符来说,操作数的类型都成了对应的可空等价类型。对于一元操作数和二元操作数,返回类型也成为可空类型。如果任意一个操作数为null,那么返回值也为null。等价运算

和关系运算符可以保证返回类型是非可空的布尔型。进行等价操作时,两个null被视作相等,而一个null和任意一个非null值是不相等的。对于关系运算符,当任意一个操作数为空时,总是返回false。当两个操作数均为非空时,执行方式与原运算符相同。

这些规则听起来可能比较复杂,但多数情况下它们的执行结果不会超出我们的预期。接下来用int来说明,因为int有众多预定义运算符(而且类型简单),用它举例再好不过了。表2-1列举了一些相关的表达式、提升运算符及其结果。假定共有3个变量:four、five和nullInt,它们的类型都是Nullable<int>,对应的值与变量名一致。表2-1 向可空整数应用提升运算符的例子表达式

提升运算符

结果

代码语言:javascript
复制
-nullIntint? -(int? x)null-fiveint? -(int? x)-5five + nullIntint? +(int? x, int? y)nullfive + fiveint? +(int? x, int? y)10four & nullIntint? &(int? x, int? y)nullfour & fiveint? &(int? x, int? y)4nullInt == nullIntbool ==(int? x, int? y)truefive == fivebool ==(int? x, int? y)truefive == nullIntbool ==(int? x, int? y)falsefive == fourbool ==(int? x, int? y)falsefour < fivebool <(int? x, int? y)truenullInt < fivebool <(int? x, int? y)falsefive < nullIntbool <(int? x, int? y)falsenullInt < nullIntbool <(int? x, int? y)falsenullInt <= nullIntbool <=(int? x, int? y)false

该表中最让人不解的应该是最后一行:为什么null值小于等于另外一个null值,其结果会是false呢?而且第7行显示二者相等的命题为真。

可空逻辑

真值表,是用于列举布尔逻辑中所有可能输入的组合和对应结果的表。学习Nullable<bool>类型逻辑,也可以采用相同的办法。只不过输入值除了true和false,还需要加上null。还好条件逻辑运算符(&&运算符和||运算符)不适用于Nullable<bool>类型,省去不少事。

表2-2是Nullable<bool>全部4个逻辑运算符的真值表。其中与运算符(&)和或运算符(|)具有特殊行为。非运算符(!)和异或运算符(^)与其他提升运算符的规则相同。列表中额外规则不适用于Nullable<bool>类型的情况都已加粗。表2-2 Nullable<bool>运算符真值表xyx & yx | yx ^ y!xtruetruetruetruefalsefalsetruefalsefalsetruetruefalsetruenullnulltruenullfalsefalsetruefalsetruetruetruefalsefalsefalsefalsefalsetruefalsenullfalsenullnulltruenulltruenulltruenullnullnullfalsefalsenullnullnullnullnullnullnullnullnull

理解这些规则有一个简单方法:可以把bool?类型的值看作“某种程度的可能”,把输入中的null看作一个变量,如果结果取决于该变量的值,那么结果一定是null。例如表2-2第3行表达式true & y,当且仅当y为true时,表达式的结果才是true。因此,如果y的值是null,则其结果是null。而对于表达式true | y,无论y的值是什么,其结果总是true。

就提升运算符和可空值逻辑的原理而言,C#语言和SQL语言在处理null值问题上存在两处轻微的冲突:C# 1的null引用和SQL的NULL值。绝大部分情况下二者并不会发生冲突:C# 1没有为null引用设计逻辑运算符,因此在C#中使用早期类SQL语言的结果没有问题,但当涉及比较操作时,二者的矛盾就凸显了。在标准SQL中,如果参与比较(仅就大于、等于、小于而言)的两个值中有一个是NULL,则其结果不可预知;C# 2则规定比较操作的结果不能为null,两个null值相等。提升运算符的执行结果是C#特有的

本节所讨论的提升运算符、类型转换以及Nullable<bool>逻辑等特性都是由C#编译器提供的,而不是由CLR或framework本身提供的。如果使用ildasm工具检查上述可空值运算符的代码,就会发现是编译器创建了所有IL代码来进行空值检查,并做出相应处理。

因此,不同语言处理null值的方式会有所不同。如果需要在基于.NET平台的不同语言之间移植代码,就需要格外小心了。例如Visual Basic中提升运算符的行为就更接近SQL:当x或y为null时,x < y的结果也为null。

下面介绍另一个可以应用于可空值类型的运算符,其行为更符合我们的直观预期:只需要把null引用的行为照搬到null值上即可。

as运算符与可空值类型

在C# 2之前,as运算符只能用于引用类型;到了C# 2,as运算符也可以用于可空值类型了。该运算符的返回值为一个可空类型的值:当原始引用的类型为null或与目标类型不匹配时,返回null值,或者返回一个有意义的值,示例如下: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

使用as运算符,仅需一步操作就能把任意引用安全地转换成一个值。转换结束后,通常还需手动检查结果是否为null。在C# 1时代,转换类型后,还需要用is运算符来判断转换是否成功。这种方式不太优雅,本质上等同于请求CLR执行了两次相同的类型检查。说明 对可空类型使用as运算符,性能出奇地低。大部分情况下,这不算太大的问题(还是要比I/O操作效率高),但是依然比采用is运算符完成转换性能低。我在几乎所有framework和编译器的组合上都试过上述操作,慢得确乎无疑。

对于目标结果是Nullable<T>类型的表达式来说,as是很方便的运算符;而且C# 7对大部分可空值类型采用模式匹配(详见第12章),故使用as运算符是更优的解决方案。最后,C# 2还引入了一个全新的运算符,用于优雅地处理null值。空合并运算符??

在实际编码中,总会有使用可空值类型的需求:当一个表达式运算结果为null时,为变量提供一个默认值。C# 2引入了??运算符来解决上述问题,称为空合并运算符。??是一个二元运算符,first ?? second表达式的计算分为以下几个步骤:

(1) 计算first表达式;

(2) 若结果不为null,则整个表达式的结果等于first的计算结果;

(3) 若结果为空,则继续计算second表达式,整个表达式的结果为second的计算结果。

上述过程只是粗略的描述,语言规范中的正式规则还包括了处理first与second之间的类型转换。不过类型转换的过程对于该运算符的大部分使用场景来说不重要,因此这里略去相关内容。如有兴趣继续探究,可参考相关语言规范。

上述规则中有一个重点需要强调:如果第1个操作数的类型是可空值类型,同时第2个操作数是第1个操作数对应的非可空值类型,整个表达式的类型就是该非可空值类型。例如以下代码是合法的:int? a = 5;

int b = 10;

int c = a ?? b;

以上代码中,a是可空值类型,表达式a ?? b的值可以不经类型转换直接赋值给非可空类型的c。这样的赋值之所以合法,是因为b是非可空的,所以整个表达式的返回值将不可能为null。另外,??表达式还可以自组合使用,例如x ?? y ?? z,如果x为空就计算y;如果x和y都为空,就计算z。

C# 6引入了空值条件运算符?.(详见10.3节),该运算符便利了作为表达式结果的空值处理。在代码中把?.和??运算符组合使用,可以发挥出处理空值的强大作用。一如既往,对于新技术的使用要遵循适度原则。如果过度应用运算符使得代码可读性变差,不如考虑将单条语句拆分为多条,优先增强可读性。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-10-24,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 科控物联 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档